{"id":106,"date":"2024-08-30T14:56:44","date_gmt":"2024-08-30T14:56:44","guid":{"rendered":"http:\/\/blog.ltzs.us\/?p=106"},"modified":"2026-05-17T22:43:29","modified_gmt":"2026-05-17T22:43:29","slug":"openstick-commands-and-procedures","status":"publish","type":"post","link":"http:\/\/blog.ltzs.us\/?p=106","title":{"rendered":"Openstick commands and procedures"},"content":{"rendered":"\n<p>Plug in stick, log in.<\/p>\n\n\n\n<p><a href=\"http:\/\/192.168.100.1\/usbdebug.html\">http:\/\/192.168.100.1\/usbdebug.html<\/a><\/p>\n\n\n\n<p>Reboot (unplug, replug)<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>adb shell<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>setprop service.adb.root 1; busybox killall adbd<\/code><\/mark><br><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>adb reboot edl<\/code><\/mark><br><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>edl rf uz801-stock.bin<\/code><\/mark><\/p>\n\n\n\n<p>Then download individual partitions.<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>edl rl uz801_stock --genxml<\/code><\/mark><\/p>\n\n\n\n<p>Reboot (unplug, replug)<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>adb reboot bootloader<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>cd OpenStick\/flash\/<\/code><\/mark><br><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>.\/flash.sh<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo mount -o loop ~\/firmwares\/uz801_stock\/modem.bin \/mnt\/test<\/code><\/mark> to mount the modem.bin file<\/p>\n\n\n\n<p>then copy all the files over to the \/lib\/firmware folder on the stick.<\/p>\n\n\n\n<p>on the stick: <mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>mkdir ~\/fw<\/code><\/mark><\/p>\n\n\n\n<p>on the computer you&#8217;re using the do the work: <mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>scp -r \/mnt\/test\/image\/* user@192.168.200.1:~\/fw<\/code><\/mark><\/p>\n\n\n\n<p>on the stick: <mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo mv \/lib\/firmware\/wlan ~\/fw<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo rm -r \/lib\/firmware\/*<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo mv ~\/fw\/* \/lib\/firmware<\/code><\/mark><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>change default modem settings depending on SIM card and available network<\/p>\n\n\n\n<p>easiest might be to delete the profile contained in the debian image and start over with:<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nmcli con down lte<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nmcli con del lte<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nmcli connection add type gsm ifname &#039;wwan0qmi0&#039; con-name lte apn &quot;&quot;<\/code><\/mark><\/p>\n\n\n\n<p> (note that leaving APN blank will allow the SIM to automatically acquire the APN)<\/p>\n\n\n\n<p>&#8212;&#8212;&#8212;<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nmcli con down lte<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nmcli con modify lte gsm.apn web.sentel.com<\/code><\/mark> (shouldn&#8217;t be necessary in normal operation)<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nmcli con modify lte connection.autoconnect no<\/code><\/mark><\/p>\n\n\n\n<p>(Note, this is a change from before, the modem-configure script is adjusted to bring up the LTE connection 30 seconds after changing the preferred mode. Almost always, LTE is stronger signal than 3G. If you do not implement the modem-configure script, you should change this back to yes. However I found that on startup, 3G is always the first to connect, and the system can&#8217;t switch to 4G until the connection is brought down and back up.)<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nmcli con modify lte ipv6.method disabled<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nmcli con modify lte connection.autoconnect-retries 0<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo mmcli -m 0 --set-allowed-modes=&quot;4g|3g&quot; --set-preferred-mode=4g<\/code><\/mark> (note this is mmcli, not nmcli)<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nmcli con up lte<\/code><\/mark><\/p>\n\n\n\n<p>The preferred mode gets reset to 3G every reboot, so the following is a bash script that waits for the modem to become available on boot, then changes preferred mode to 4G.<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/usr\/local\/bin\/configure-modem.sh<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">#!\/bin\/bash\n\nLOG_FILE=&quot;\/var\/log\/modem-config.log&quot;\n\nlog() {\n    echo &quot;$(date &#039;+%Y-%m-%d %H:%M:%S&#039;) - $1&quot; | tee -a &quot;$LOG_FILE&quot;\n}\n\nlog &quot;=== Modem configuration script started ===&quot;\n\n# Wait for modem to be available\nlog &quot;Waiting for modem to be detected...&quot;\nfor i in {1..60}; do\n    MODEM_LIST=$(mmcli -L 2&gt;&amp;1)\n    MODEM_CHECK=$?\n    \n    if [ $MODEM_CHECK -ne 0 ]; then\n        log &quot;Attempt $i\/60: mmcli -L failed with exit code $MODEM_CHECK&quot;\n        log &quot;Output: $MODEM_LIST&quot;\n    else\n        log &quot;Attempt $i\/60: mmcli -L succeeded&quot;\n        if echo &quot;$MODEM_LIST&quot; | grep -q &quot;Modem&quot;; then\n            log &quot;Modem detected! Output: $MODEM_LIST&quot;\n            log &quot;Waiting 5 seconds for modem to fully initialize...&quot;\n            sleep 5\n            \n            log &quot;Configuring modem with: mmcli -m 0 --set-allowed-modes=\\&quot;4g|3g\\&quot; --set-preferred-mode=4g&quot;\n            CONFIG_OUTPUT=$(mmcli -m 0 --set-allowed-modes=&quot;4g|3g&quot; --set-preferred-mode=4g 2&gt;&amp;1)\n            CONFIG_EXIT=$?\n            \n            if [ $CONFIG_EXIT -eq 0 ]; then\n                log &quot;SUCCESS: Modem configured successfully&quot;\n                log &quot;Output: $CONFIG_OUTPUT&quot;\n                \n                log &quot;Waiting 30 seconds before connecting NetworkManager &#039;lte&#039; connection...&quot;\n                sleep 30\n                \n                log &quot;Attempting to connect NetworkManager connection &#039;lte&#039;&quot;\n                NM_OUTPUT=$(nmcli connection up &quot;lte&quot; 2&gt;&amp;1)\n                NM_EXIT=$?\n                \n                if [ $NM_EXIT -eq 0 ]; then\n                    log &quot;SUCCESS: NetworkManager &#039;lte&#039; connection established&quot;\n                    log &quot;Output: $NM_OUTPUT&quot;\n                else\n                    log &quot;ERROR: NetworkManager connection failed with exit code $NM_EXIT&quot;\n                    log &quot;Output: $NM_OUTPUT&quot;\n                fi\n                \n                exit 0\n            else\n                log &quot;ERROR: Modem configuration failed with exit code $CONFIG_EXIT&quot;\n                log &quot;Output: $CONFIG_OUTPUT&quot;\n                exit 1\n            fi\n        else\n            log &quot;Attempt $i\/60: No modem found in output yet&quot;\n        fi\n    fi\n    sleep 1\ndone\n\nlog &quot;ERROR: Timeout reached after 60 seconds. Modem not detected.&quot;\nexit 1<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo chmod +x \/usr\/local\/bin\/configure-modem.sh<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/systemd\/system\/modem-config.service<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">[Unit]\nDescription=Configure Modem Network Modes\nAfter=ModemManager.service\nWants=ModemManager.service\n\n[Service]\nType=oneshot\nExecStart=\/usr\/local\/bin\/configure-modem.sh\nStandardOutput=journal\nStandardError=journal\nSyslogIdentifier=modem-config\nRestart=on-failure\nRestartSec=10\nStartLimitBurst=3\n\n[Install]\nWantedBy=multi-user.target<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl daemon-reload<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl enable modem-config.service<\/code><\/mark><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>*edit* 15NOV25 Change <mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/apt\/sources.list<\/code><\/mark> from stable to bookworm so it doesn&#8217;t pull trixie files.<\/p>\n\n\n\n<p>then update the bookworm install with <mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo apt update\/upgrade<\/code><\/mark><\/p>\n\n\n\n<p>make user not need sudo password <mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo visudo<\/code><\/mark> then replace <mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>%sudo ALL=(ALL:ALL) ALL<\/code><\/mark> with <mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>%sudo ALL=(ALL) NOPASSWD: ALL<\/code><\/mark><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>(optional) install node red (before installing anything else, since the installer script requires 100MB RAM). Node-Red, Telegraf and Victoria Metrics seem to be a bit too much for the stick, it is unstable with less than 50MB RAM remaining. Any of the three can be left out. <\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>bash &lt;(curl -sL https:\/\/raw.githubusercontent.com\/node-red\/linux-installers\/master\/deb\/update-nodejs-and-nodered)<\/code><\/mark><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>install mosquitto and configure (adapted from <a href=\"http:\/\/www.steves-internet-guide.com\/mqtt-username-password-example\/\">here<\/a>)<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo apt install mosquitto<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/mosquitto\/mosquitto.conf<\/code><\/mark> and add the following:<\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">listener 1883\nallow_anonymous false\npassword_file \/etc\/mosquitto\/passwd-file<\/code><\/pre>\n\n\n\n<p>now need to establish logins and passwords for various users of your MQTT broker (IOT devices, telegraf, admin)<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo mosquitto_passwd -c \/etc\/mosquitto\/passwd-file user<\/code><\/mark> (for the first user)<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo mosquitto_passwd \/etc\/mosquitto\/passwd-file user<\/code><\/mark> (for any users after the first)<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>install Victoria Metrics and configure<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>wget https:\/\/github.com\/VictoriaMetrics\/VictoriaMetrics\/releases\/download\/v1.129.1\/victoria-metrics-linux-arm64-v1.129.1.tar.gz<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo tar -xvzf victoria-metrics-linux-arm64-v1.129.1.tar.gz -C \/usr\/local\/bin<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo useradd -s \/usr\/sbin\/nologin victoriametrics<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo mkdir -p \/var\/lib\/victoria-metrics &amp;&amp; sudo chown -R victoriametrics:victoriametrics \/var\/lib\/victoria-metrics<\/code><\/mark><\/p>\n\n\n\n<p>Add a service file:<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/systemd\/system\/victoriametrics.service<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">[Unit]\nDescription=VictoriaMetrics service\nAfter=network.target\n\n[Service]\nType=simple\nUser=victoriametrics\nGroup=victoriametrics\nExecStart=\/usr\/local\/bin\/victoria-metrics-prod -storageDataPath=\/var\/lib\/victoria-metrics -retentionPeriod=365d -selfScrapeInterval=15m\nSyslogIdentifier=victoriametrics\nRestart=always\n\nPrivateTmp=yes\nProtectHome=yes\nNoNewPrivileges=yes\n\nProtectSystem=full\n\n[Install]\nWantedBy=multi-user.target<\/code><\/pre>\n\n\n\n<p>Activate service:<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl daemon-reload &amp;&amp; sudo systemctl enable --now victoriametrics.service<\/code><\/mark><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>If needed, install telegraf and configure (from <a href=\"https:\/\/www.influxdata.com\/blog\/linux-package-signing-key-rotation\/\">here<\/a>)<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>cd ~\/<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>wget -q https:\/\/repos.influxdata.com\/influxdata-archive_compat.key<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>gpg --with-fingerprint --show-keys .\/influxdata-archive_compat.key<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>cat influxdata-archive_compat.key | gpg --dearmor | sudo tee \/etc\/apt\/trusted.gpg.d\/influxdata-archive_compat.gpg &gt; \/dev\/null<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>echo &#039;deb [signed-by=\/etc\/apt\/trusted.gpg.d\/influxdata-archive_compat.gpg] https:\/\/repos.influxdata.com\/debian stable main&#039; | sudo tee \/etc\/apt\/sources.list.d\/influxdata.list<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo rm -f \/etc\/apt\/trusted.gpg.d\/influxdb.gpg<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo apt update<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo apt install telegraf<\/code><\/mark><\/p>\n\n\n\n<p>adjust telegraf.conf to receive data from mqtt (and system metrics if desired) and send to victoria metrics (which can accept influxdb line protocol, so is a drop-in, low memory replacement for influxdb)<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/telegraf\/telegraf.conf<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\"># Telegraf Configuration\n#\n\n# Global tags can be specified here in key=&quot;value&quot; format.\n[global_tags]\n  # dc = &quot;us-east-1&quot; # will tag all metrics with dc=us-east-1\n  # rack = &quot;1a&quot;\n  ## Environment variables can be used as tags, and throughout the config file\n  # user = &quot;$USER&quot;\n\n# Configuration for telegraf agent\n[agent]\n  ## Default data collection interval for all inputs\n  interval = &quot;10s&quot;\n  ## Rounds collection interval to &#039;interval&#039;\n  ## ie, if interval=&quot;10s&quot; then always collect on :00, :10, :20, etc.\n  round_interval = true\n\n  ## Telegraf will send metrics to outputs in batches of at most\n  ## metric_batch_size metrics.\n  ## This controls the size of writes that Telegraf sends to output plugins.\n  metric_batch_size = 1000\n\n  ## Maximum number of unwritten metrics per output.  Increasing this value\n  ## allows for longer periods of output downtime without dropping metrics at the\n  ## cost of higher maximum memory usage.\n  metric_buffer_limit = 10000\n\n  ## Collection jitter is used to jitter the collection by a random amount.\n  ## Each plugin will sleep for a random time within jitter before collecting.\n  ## This can be used to avoid many plugins querying things like sysfs at the\n  ## same time, which can have a measurable effect on the system.\n  collection_jitter = &quot;0s&quot;\n\n  ## Default flushing interval for all outputs. Maximum flush_interval will be\n  ## flush_interval + flush_jitter\n  flush_interval = &quot;10s&quot;\n  ## Jitter the flush interval by a random amount. This is primarily to avoid\n  ## large write spikes for users running a large number of telegraf instances.\n  ## ie, a jitter of 5s and interval 10s means flushes will happen every 10-15s\n  flush_jitter = &quot;0s&quot;\n\n  ## Precision will NOT be used for service inputs. It is up to each individual\n  ## service input to set the timestamp at the appropriate precision.\n  precision = &quot;0s&quot;\n\n  ## Log at debug level.\n   debug = true\n  ## Log only error level messages.\n  # quiet = false\n\n  ## Log target controls the destination for logs and can be one of &quot;file&quot;,\n  ## &quot;stderr&quot; or, on Windows, &quot;eventlog&quot;.  When set to &quot;file&quot;, the output file\n  ## is determined by the &quot;logfile&quot; setting.\n   logtarget = &quot;file&quot;\n\n  ## Name of the file to be logged to when using the &quot;file&quot; logtarget.  If set to\n  ## the empty string then logs are written to stderr.\n   logfile = &quot;\/var\/log\/telegraf\/telegraf.log&quot;\n\n  ## The logfile will be rotated when it becomes larger than the specified\n  ## size.  When set to 0 no size based rotation is performed.\n   logfile_rotation_max_size = &quot;1MB&quot;\n\n  ## Maximum number of rotated archives to keep, any older logs are deleted.\n  ## If set to -1, no archives are removed.\n   logfile_rotation_max_archives = 5\n\n  ## Pick a timezone to use when logging or type &#039;local&#039; for local time.\n  ## Example: America\/Chicago\n   log_with_timezone = &quot;local&quot;\n\n  ## Override default hostname, if empty use os.Hostname()\n  hostname = &quot;&quot;\n  ## If set to true, do no set the &quot;host&quot; tag in the telegraf agent.\n  omit_hostname = false\n\n###############################################################################\n#                            OUTPUT PLUGINS                                   #\n###############################################################################\n[[outputs.influxdb]]\nurls = [&quot;http:\/\/127.0.0.1:8428&quot;]\n# # Configuration for sending metrics to InfluxDB\n# [[outputs.influxdb]]\n#   ## The full HTTP or UDP URL for your InfluxDB instance.\n#   ##\n#   ## Multiple URLs can be specified for a single cluster, only ONE of the\n#   ## urls will be written to each interval.\n#   # urls = [&quot;unix:\/\/\/var\/run\/influxdb.sock&quot;]\n#   # urls = [&quot;udp:\/\/127.0.0.1:8089&quot;]\n#    urls = [&quot;http:\/\/127.0.0.1:8086&quot;]\n\n#   ## The target database for metrics; will be created as needed.\n#   ## For UDP url endpoint database needs to be configured on server side.\n    database = &quot;telegraf&quot;\n##\n#   ## If true, no CREATE DATABASE queries will be sent.  Set to true when using\n#   ## Telegraf with a user without permissions to create databases or when the\n#   ## database already exists.\n skip_database_creation = false\n#\n#   ## Name of existing retention policy to write to.  Empty string writes to\n#   ## the default retention policy.  Only takes effect when using HTTP.\n#   # retention_policy = &quot;&quot;\n#\n#   ## HTTP Basic Auth\n username = &quot;telegraf&quot;\n password = &quot;password&quot;\n#\n###############################################################################\n#                            INPUT PLUGINS                                    #\n###############################################################################\n[[inputs.cpu]]\npercpu = false\n#[[inputs.disk]]\n#ignore_fs = [ &quot;tmpfs&quot;, &quot;devtmpfs&quot; ]\n#[[inputs.diskio]]\n#[[inputs.kernel]]\n[[inputs.mem]]\n#[[inputs.processes]]\n#[[inputs.swap]]\n[[inputs.system]]\n[[inputs.net]]\nfieldpass = [ &quot;bytes*&quot; ]\n[[inputs.netstat]]\n[[inputs.temp]]\n\n#This section reads correct system uptime to VictoriaMetrics so grafana can graph it correctly. \n#Normally telegraf [system] input refers to the build date\/time on startup, which is months out of date.\n[[inputs.exec]]\n  commands = [&quot;sh -c \\&quot;awk &#039;{print \\\\\\&quot;system uptime=\\\\\\&quot; int($1) \\\\\\&quot;i\\\\\\&quot;}&#039; \/proc\/uptime\\&quot;&quot;]\n  timeout = &quot;5s&quot;\n  data_format = &quot;influx&quot;\n  name_override = &quot;system_correct&quot;  # Different measurement name than normal system_uptime\n\n[[inputs.mqtt_consumer]]\nalias = &quot;mqtt&quot;\nstartup_error_behavior = &quot;retry&quot;\nservers = [&quot;tcp:\/\/127.0.0.1:1883&quot;]\ntopics = [\n    &quot;N\/0281f08bbe75\/system\/0\/#&quot;,\n  ]\nclient_id = &quot;telegraf&quot;\nusername = &quot;username&quot;\npassword = &quot;password&quot;\ndata_format = &quot;json&quot;<\/code><\/pre>\n\n\n\n<p>Telegraf is a memory hog and chokes with too many MQTT metrics, so it&#8217;s often easier to just capture a few metrics with Node Red and send directly to Victoria Metrics. Leaving telegraf uninstalled allows enough RAM to run a Grafana server. (an old version that does simple graphs, and uses MUCH less RAM). Another option is to install an old version of telegraf that also uses much less RAM.<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>wget https:\/\/dl.influxdata.com\/telegraf\/releases\/telegraf_1.8.3-1_arm64.deb<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo dpkg -i telegraf_1.8.3-1_arm64.deb<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\"># Telegraf Configuration\n#\n\n# Global tags can be specified here in key=&quot;value&quot; format.\n[global_tags]\n  # dc = &quot;us-east-1&quot; # will tag all metrics with dc=us-east-1\n  # rack = &quot;1a&quot;\n  ## Environment variables can be used as tags, and throughout the config file\n  # user = &quot;$USER&quot;\n\n# Configuration for telegraf agent\n[agent]\n  ## Default data collection interval for all inputs\n  interval = &quot;10s&quot;\n  ## Rounds collection interval to &#039;interval&#039;\n  ## ie, if interval=&quot;10s&quot; then always collect on :00, :10, :20, etc.\n  round_interval = true\n\n  ## Telegraf will send metrics to outputs in batches of at most\n  ## metric_batch_size metrics.\n  ## This controls the size of writes that Telegraf sends to output plugins.\n  metric_batch_size = 1000\n\n  ## Maximum number of unwritten metrics per output.  Increasing this value\n  ## allows for longer periods of output downtime without dropping metrics at the\n  ## cost of higher maximum memory usage.\n  metric_buffer_limit = 10000\n\n  ## Collection jitter is used to jitter the collection by a random amount.\n  ## Each plugin will sleep for a random time within jitter before collecting.\n  ## This can be used to avoid many plugins querying things like sysfs at the\n  ## same time, which can have a measurable effect on the system.\n  collection_jitter = &quot;0s&quot;\n\n  ## Default flushing interval for all outputs. Maximum flush_interval will be\n  ## flush_interval + flush_jitter\n  flush_interval = &quot;10s&quot;\n  ## Jitter the flush interval by a random amount. This is primarily to avoid\n  ## large write spikes for users running a large number of telegraf instances.\n  ## ie, a jitter of 5s and interval 10s means flushes will happen every 10-15s\n  flush_jitter = &quot;0s&quot;\n\n  ## Precision will NOT be used for service inputs. It is up to each individual\n  ## service input to set the timestamp at the appropriate precision.\n  precision = &quot;0s&quot;\n\n  ## Log at debug level.\n   debug = true\n  ## Log only error level messages.\n  # quiet = false\n\n  ## Log target controls the destination for logs and can be one of &quot;file&quot;,\n  ## &quot;stderr&quot; or, on Windows, &quot;eventlog&quot;.  When set to &quot;file&quot;, the output file\n  ## is determined by the &quot;logfile&quot; setting.\n #  logtarget = &quot;file&quot;\n\n  ## Name of the file to be logged to when using the &quot;file&quot; logtarget.  If set to\n  ## the empty string then logs are written to stderr.\n #  logfile = &quot;\/var\/log\/telegraf\/telegraf.log&quot;\n\n  ## The logfile will be rotated when it becomes larger than the specified\n  ## size.  When set to 0 no size based rotation is performed.\n #  logfile_rotation_max_size = &quot;1MB&quot;\n\n  ## Maximum number of rotated archives to keep, any older logs are deleted.\n  ## If set to -1, no archives are removed.\n #  logfile_rotation_max_archives = 5\n\n  ## Pick a timezone to use when logging or type &#039;local&#039; for local time.\n  ## Example: America\/Chicago\n #  log_with_timezone = &quot;local&quot;\n\n  ## Override default hostname, if empty use os.Hostname()\n  hostname = &quot;&quot;\n  ## If set to true, do no set the &quot;host&quot; tag in the telegraf agent.\n  omit_hostname = false\n\n###############################################################################\n#                            OUTPUT PLUGINS                                   #\n###############################################################################\n[[outputs.influxdb]]\nurls = [&quot;http:\/\/127.0.0.1:8428&quot;]\n# # Configuration for sending metrics to InfluxDB\n# [[outputs.influxdb]]\n#   ## The full HTTP or UDP URL for your InfluxDB instance.\n#   ##\n#   ## Multiple URLs can be specified for a single cluster, only ONE of the\n#   ## urls will be written to each interval.\n#   # urls = [&quot;unix:\/\/\/var\/run\/influxdb.sock&quot;]\n#   # urls = [&quot;udp:\/\/127.0.0.1:8089&quot;]\n#    urls = [&quot;http:\/\/127.0.0.1:8086&quot;]\n\n#   ## The target database for metrics; will be created as needed.\n#   ## For UDP url endpoint database needs to be configured on server side.\n    database = &quot;telegraf&quot;\n##\n#   ## If true, no CREATE DATABASE queries will be sent.  Set to true when using\n#   ## Telegraf with a user without permissions to create databases or when the\n#   ## database already exists.\n skip_database_creation = false\n#\n#   ## Name of existing retention policy to write to.  Empty string writes to\n#   ## the default retention policy.  Only takes effect when using HTTP.\n#   # retention_policy = &quot;&quot;\n#\n#   ## HTTP Basic Auth\n username = &quot;telegraf&quot;\n password = &quot;password&quot;\n#\n###############################################################################\n#                            INPUT PLUGINS                                    #\n###############################################################################\n[[inputs.cpu]]\npercpu = true\n[[inputs.disk]]\nignore_fs = [ &quot;tmpfs&quot;, &quot;devtmpfs&quot; ]\n#[[inputs.diskio]]\n#[[inputs.kernel]]\n[[inputs.mem]]\n#[[inputs.processes]]\n#[[inputs.swap]]\n[[inputs.system]]\n[[inputs.net]]\nfieldpass = [ &quot;bytes*&quot; ]\n[[inputs.netstat]]\n[[inputs.temp]]\n\n#This section reads correct system uptime to VictoriaMetrics so grafana can graph it correctly. \n#Normally telegraf [system] input refers to the build date\/time on startup, which is months out of date.\n[[inputs.exec]]\n  commands = [&quot;sh -c \\&quot;awk &#039;{print \\\\\\&quot;system uptime=\\\\\\&quot; int($1) \\\\\\&quot;i\\\\\\&quot;}&#039; \/proc\/uptime\\&quot;&quot;]\n  timeout = &quot;5s&quot;\n  data_format = &quot;influx&quot;\n  name_override = &quot;system_correct&quot;  # Different measurement name than normal system_uptime\n\n[[inputs.mqtt_consumer]]\n#alias = &quot;mqtt&quot;\n#startup_error_behavior = &quot;retry&quot;\nservers = [&quot;tcp:\/\/127.0.0.1:1883&quot;]\ntopics = [\n    &quot;N\/0281f08bbe75\/system\/0\/#&quot;,\n  ]\nclient_id = &quot;telegraf&quot;\nusername = &quot;username&quot;\npassword = &quot;password&quot;\ndata_format = &quot;json&quot;<\/code><\/pre>\n\n\n\n<p>Note the [inputs.exec] portion, which pulls uptime from \/proc\/uptime, which is better than where telegraf normally gets it from (necessary because the system clock is updated a few minutes after boot using the modem).<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo apt-get install -y adduser libfontconfig1 musl<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>wget https:\/\/dl.grafana.com\/enterprise\/release\/grafana-enterprise_6.7.0_arm64.deb<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo dpkg -i grafana-enterprise_6.7.0_arm64.deb<\/code><\/mark><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>Another light option is Perses, but has issues with CORS. <\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>wget https:\/\/github.com\/perses\/perses\/releases\/download\/v0.53.0-beta.2\/perses_0.53.0-beta.2_linux_arm64.tar.gz<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo tar -xvzf ~\/perses_0.53.0-beta.2_linux_arm64.tar.gz -C \/opt\/perses<\/code><\/mark><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>install pivpn<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>curl -L https:\/\/install.pivpn.io | bash<\/code><\/mark><\/p>\n\n\n\n<p>Need to create the tun0 device on each reboot with <mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano ~\/maketun.sh<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">#!\/bin\/bash\n\nmkdir -p \/dev\/net\nmknod \/dev\/net\/tun c 10 200\nchmod 600 \/dev\/net\/tun\n\nsystemctl restart openvpn@server<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo chmod 755 ~\/maketun.sh<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo crontab -e<\/code><\/mark><\/p>\n\n\n\n<p>add <mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>@reboot \/home\/user\/maketun.sh<\/code><\/mark><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>create reverse ssh tunnel as detailed <a href=\"http:\/\/blog.ltzs.us\/?p=57\">here<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>install chrony and edit conf file<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo apt install chrony<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/chrony\/chrony.conf<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">#add to the bottom\nallow 192.0.0.0\/8\nlocal stratum 8\nmanual<\/code><\/pre>\n\n\n\n<p>The &#8216;local stratum 8&#8217; and &#8216;manual&#8217; allows chrony to serve the system time, not just NTP sources. Useful for no network connectivity when you&#8217;re pulling system time from the cell phone tower using sudo mmcli -m 0 &#8211;time.  <\/p>\n\n\n\n<p>Limit journal size<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/systemd\/journald.conf<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">#add to bottom\nSystemMaxUse=50M\nRuntimeMaxUse=50M<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo timedatectl set-timezone Africa\/Dakar<\/code><\/mark><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>Small script to restart after 1 day based on uptime rather than a specific time. Can get into a boot loop otherwise.<\/p>\n\n\n\n<p>sudo nano \/usr\/local\/bin\/check_uptime_reboot.sh<\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-\">#!\/bin\/bash\n# Save as \/usr\/local\/bin\/check_uptime_reboot.sh\n\nUPTIME_SECONDS=$(cat \/proc\/uptime | awk &#039;{print int($1)}&#039;)\n\nif [ &quot;$UPTIME_SECONDS&quot; -ge 86400 ]; then\n    logger &quot;System rebooting after $UPTIME_SECONDS seconds uptime&quot;\n    \/sbin\/reboot\nfi<\/code><\/pre>\n\n\n\n<p>chmod +x \/usr\/local\/bin\/check_uptime_reboot.sh<\/p>\n\n\n\n<p>Put the script into crontab below&#8230;<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>Install cron so various things can be automated<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo apt install cron<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo crontab -e<\/code><\/mark><\/p>\n\n\n\n<p>Has cron jobs for checking data balance using USSD commands (see script on post about using USSD commands)<\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">@reboot \/home\/user\/maketun.sh\n*\/5 * * * * \/usr\/local\/bin\/check_uptime_reboot.sh<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>If you have a weak cell signal or the the stick is working hard, it can overheat causing reboots and general instability. One solution is to disable two of the four cores. Normal operations with this setup (apart from startup) idle at 0-10% cpu, so it&#8217;s not a big deal. Edit the rc.local file as follows:<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/rc.local<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">#!\/bin\/bash\n\nmain() {\n    cleanup_wwan\n    disable_cpu_cores\n}\n\ndisable_cpu_cores() {\n    echo 0 &gt; \/sys\/devices\/system\/cpu\/cpu2\/online\n    echo 0 &gt; \/sys\/devices\/system\/cpu\/cpu3\/online\n}\n\ncleanup_wwan() {\n    ip netns add null || true\n\n    max_wait=20\n    waited=0\n    while [ $waited -lt $max_wait ]; do\n        if ip link show wwan7 &amp;&gt;\/dev\/null; then\n            break\n        fi\n        sleep 1\n        waited=$((waited + 1))\n    done\n\n    for i in wwan1 wwan2 wwan3 wwan4 wwan5 wwan6 wwan7; do\n        ip link set &quot;$i&quot; netns null\n    done\n\n    # this may be required on some boards to initialize the modem\n    sleep 2\n    if ! ip -4 addr show wwan0 | grep -q &quot;inet &quot;; then\n        systemctl restart ModemManager\n    fi\n}\n\nmain\necho heartbeat &gt; \/sys\/devices\/platform\/leds\/leds\/red:power\/trigger\nexit 0<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>If necessary, install fail2ban and configure. If only exposing OpenVPN port, not necessary.<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo apt install fail2ban<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo cp \/etc\/fail2ban\/jail.conf \/etc\/fail2ban\/jail.local<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/fail2ban\/jail.local<\/code><\/mark><\/p>\n\n\n\n<p>After all the defaults is the [sshd] section, enter the following<\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">enabled = yes\n#whatever ips you know you want to always allow through\nignoreip = 127.0.0.1\/8 ::1 56.78.0.0\/16 12.34.0.0\/16\nbantime = 24h\nmaxretry = 2\nfindtime = 24h\nport    = ssh\nlogpath = SYSTEMD-JOURNAL\nbackend = systemd<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>If you&#8217;ll be running the stick as a data collection server in a remote location, it will need some way to access accurate time. If you have a data plan with the SIM card, it&#8217;s no problem and chrony will handle it. However, if you just want to log data and occasionally buy data credit and remote in, the system needs a way to get time. Fortunately, just the act of connecting the SIM card to the GSM network will give us access to relatively accurate time. Create a script as follows:<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano ~\/timecheck.sh<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">#!\/bin\/bash\n\nSAVED_TIME_FILE=&quot;\/home\/user\/saved_time&quot;\n\n# Function to check if modem is connected\nis_connected() {\n    sudo mmcli -m any | grep -q &quot;connected\\|connecting\\|disconnecting\\|registered&quot;\n    return $?\n}\n\n# Function to extract time from modem\nget_modem_time() {\n    sudo mmcli -m any --time | awk &#039;NR == 2 {\n        print substr($0,23,4)&quot;-&quot;substr($0,28,2)&quot;-&quot;substr($0,31,2)&quot; &quot;substr($0,34,2)&quot;:&quot;substr($0,37,2)&quot;:&quot;substr($0,40,2)\n    }&#039;\n}\n\nabs_diff=100000\n\n# Main loop\nwhile [ &quot;$abs_diff&quot; -gt 5 ]; do\n    if is_connected; then\n        modem_time=$(get_modem_time)\n\n        if [ -z &quot;$modem_time&quot; ]; then\n            echo &quot;Could not get modem time. Waiting...&quot;\n            sleep 60\n            continue\n        fi\n\n        # Convert both times to epoch seconds\n        modem_epoch=$(date -d &quot;$modem_time&quot; +%s 2&gt;\/dev\/null)\n        if [ $? -ne 0 ]; then\n            echo &quot;Invalid modem time format: $modem_time. Waiting...&quot;\n            sleep 60\n            continue\n        fi\n        \n        system_epoch=$(date +%s)\n\n        # Calculate time difference in seconds\n        diff=$((system_epoch - modem_epoch))\n        abs_diff=${diff#-}  # Absolute value\n\n        echo &quot;Time difference: $abs_diff seconds&quot;\n\n        # If difference is greater than 5 seconds, update system time\n        if [ &quot;$abs_diff&quot; -gt 5 ]; then\n            echo &quot;Stopping chrony.service...&quot;\n            sudo systemctl stop chrony.service\n\n            echo &quot;Updating system time to modem time: $modem_time&quot;\n            sudo timedatectl set-time &quot;$modem_time&quot;\n\n            # Save the correct time to file for next boot\n            date +%s &gt; &quot;$SAVED_TIME_FILE&quot;\n            echo &quot;Saved current time ($(date +%s)) to $SAVED_TIME_FILE&quot;\n\n            echo &quot;Starting chrony.service...&quot;\n            sudo systemctl start chrony.service\n        else\n            echo &quot;System time is within 5 seconds of modem time. No update needed.&quot;\n            \n            # Still save the time even if no update was needed (time is correct)\n            date +%s &gt; &quot;$SAVED_TIME_FILE&quot;\n            echo &quot;Saved current time ($(date +%s)) to $SAVED_TIME_FILE&quot;\n        fi\n    else\n        echo &quot;Modem is not connected.&quot;\n    fi\n\n    # Wait for 60 seconds\n    sleep 60\ndone\n\n# When loop exits (time is synced), save final timestamp\ndate +%s &gt; &quot;$SAVED_TIME_FILE&quot;\necho &quot;Final time saved to $SAVED_TIME_FILE&quot;<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo chmod 755 ~\/timecheck.sh<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/systemd\/system\/timecheck.service<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">[Unit]\nDescription=Modem Time Synchronization Service\nAfter=network.target\n\n[Service]\nExecStart=\/home\/user\/timecheck.sh\nRestart=always\nRestartSec=10\nUser=root\n\n[Install]\nWantedBy=multi-user.target<\/code><\/pre>\n\n\n\n<p>enable the script with <mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl daemon-reload<\/code><\/mark> and then <mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl enable timecheck.service<\/code><\/mark><\/p>\n\n\n\n<p>Because the 4G stick does not have a hardware clock (RTC), we need to create a fake hardware clock for the system to reference at boot. This makes writing to time series databases more accurate.<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano restore-time.sh<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">#!\/bin\/bash\n# \/home\/user\/restore-time.sh\n\nSAVED_TIME_FILE=&quot;\/home\/user\/saved_time&quot;\n\nif [ -f &quot;$SAVED_TIME_FILE&quot; ]; then\n    SAVED_TIMESTAMP=$(cat &quot;$SAVED_TIME_FILE&quot;)\n    \n    if [ -n &quot;$SAVED_TIMESTAMP&quot; ] &amp;&amp; [ &quot;$SAVED_TIMESTAMP&quot; -gt 0 ]; then\n        # Set system time to saved timestamp\n        sudo timedatectl set-time &quot;@$SAVED_TIMESTAMP&quot; 2&gt;\/dev\/null || sudo date -s &quot;@$SAVED_TIMESTAMP&quot;\n\n        echo &quot;Restored time from last save: $(date)&quot;\n    else\n        echo &quot;Invalid saved timestamp in $SAVED_TIME_FILE&quot;\n    fi\nelse\n    echo &quot;No saved time found at $SAVED_TIME_FILE, using default system time&quot;\nfi<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo chmod +x \/home\/user\/restore-time.sh<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/systemd\/system\/restore-time.service<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">[Unit]\nDescription=Restore saved time on boot\nDefaultDependencies=no\nBefore=sysinit.target time-sync.target telegraf.service\nAfter=local-fs.target\n\n[Service]\nType=oneshot\nExecStart=\/home\/user\/restore-time.sh\nRemainAfterExit=yes\n\n[Install]\nWantedBy=sysinit.target<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl daemon-reload<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl enable restore-time.service<\/code><\/mark><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>*edit* See below for permanent fix&#8230;<\/p>\n\n\n\n<p>With the current version of debian on this stick, there is a <s>bug<\/s> kernel setting that prevents local clients from talking to each other. In my setup, I need access. So I can start a ssh tunnel to forward http traffic via the server.<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>ssh -L 8081:192.168.100.26:80 user@192.168.100.1<\/code><\/mark><\/p>\n\n\n\n<p>Note that the port 8081 doesn&#8217;t matter, as long as it is larger than 1024 and doesn&#8217;t conflict with any other services you&#8217;re running. I can then access the other client on my local web browser with http:\/\/localhost:8081<\/p>\n\n\n\n<p>If you don&#8217;t want to have to do any work on the client computer, you can have socat do it on the stick.<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo apt install socat<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/systemd\/system\/socat-bridge.service<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">[Unit]\nDescription=Socat HTTP Bridge\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=\/usr\/bin\/socat TCP-LISTEN:8081,fork,reuseaddr TCP:192.168.100.10:80\nRestart=always\n\n[Install]\nWantedBy=multi-user.target<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl daemon-reload<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl enable socat-bridge.service<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl start socat-bridge.service<\/code><\/mark><\/p>\n\n\n\n<p>Now you can type in http:\/\/192.168.100.1:8081 on the client computer and the stick will pass the http traffic back and forth. Because the IP address of the remote server is fixed in the service file, you should add a static dhcp assignment for it in \/etc\/dnsmasq.d\/dhcp.conf<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>dhcp-host=C8:C9:A3:10:B3:8B,192.168.100.10,infinite<\/code><\/mark><\/p>\n\n\n\n<p>*edit* Finally figured out how to allow clients to access each other. Need to enable proxy_arp_pvlan in the kernel.<\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-\">sudo tee \/etc\/sysctl.d\/99-pvlan-proxy-arp.conf &lt;&lt; &#039;EOF&#039;\nnet.ipv4.conf.br0.proxy_arp_pvlan = 1\nnet.ipv4.conf.wlan0.proxy_arp_pvlan = 1\nEOF\nsudo sysctl -p \/etc\/sysctl.d\/99-pvlan-proxy-arp.conf<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>For monitoring network connectivity, the following will ping some dns servers and log the data to victoriametrics. <\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo apt install python3 iputils-ping<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/usr\/local\/bin\/connectivity_monitor.py<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-python\">#!\/usr\/bin\/env python3\n&quot;&quot;&quot;\nInternet Connectivity Monitor\nChecks internet connectivity every minute and pushes metrics to Prometheus\/VictoriaMetrics\nUses only Python standard library - no external dependencies required\n&quot;&quot;&quot;\n\nimport time\nimport socket\nfrom datetime import datetime\nimport subprocess\nimport sys\nfrom urllib.request import urlopen, Request\nfrom urllib.error import URLError, HTTPError\n\n# Configuration\nPROMETHEUS_URL = &quot;http:\/\/192.168.100.1:8428\/api\/v1\/import\/prometheus&quot;\nCHECK_INTERVAL = 60  # seconds\nPING_HOSTS = [\n    &quot;8.8.8.8&quot;,      # Google DNS\n    &quot;1.1.1.1&quot;,      # Cloudflare DNS\n    &quot;8.8.4.4&quot;       # Google DNS secondary\n]\nHTTP_CHECK_URL = &quot;https:\/\/www.google.com&quot;\nTIMEOUT = 5  # seconds\n\ndef check_dns_resolution():\n    &quot;&quot;&quot;Check if DNS resolution is working&quot;&quot;&quot;\n    try:\n        socket.gethostbyname(&quot;www.google.com&quot;)\n        return 1\n    except socket.gaierror:\n        return 0\n\ndef check_http_connectivity():\n    &quot;&quot;&quot;Check if HTTP\/HTTPS connectivity is working&quot;&quot;&quot;\n    try:\n        request = Request(HTTP_CHECK_URL, headers={&#039;User-Agent&#039;: &#039;Mozilla\/5.0&#039;})\n        response = urlopen(request, timeout=TIMEOUT)\n        return 1 if response.status == 200 else 0\n    except (URLError, HTTPError, socket.timeout):\n        return 0\n\ndef ping_host(host):\n    &quot;&quot;&quot;Ping a host and return latency in ms, or -1 if failed&quot;&quot;&quot;\n    try:\n        result = subprocess.run(\n            [&quot;ping&quot;, &quot;-c&quot;, &quot;1&quot;, &quot;-W&quot;, str(TIMEOUT), host],\n            capture_output=True,\n            text=True,\n            timeout=TIMEOUT + 1\n        )\n        if result.returncode == 0:\n            # Extract latency from ping output\n            for line in result.stdout.split(&#039;\\n&#039;):\n                if &#039;time=&#039; in line:\n                    latency = line.split(&#039;time=&#039;)[1].split()[0]\n                    return float(latency)\n        return -1\n    except (subprocess.TimeoutExpired, Exception):\n        return -1\n\ndef collect_metrics():\n    &quot;&quot;&quot;Collect all connectivity metrics&quot;&quot;&quot;\n    metrics = {}\n    \n    # DNS check\n    metrics[&#039;internet_dns_working&#039;] = check_dns_resolution()\n    \n    # HTTP connectivity check\n    metrics[&#039;internet_http_working&#039;] = check_http_connectivity()\n    \n    # Ping checks\n    ping_success_count = 0\n    total_latency = 0\n    \n    for host in PING_HOSTS:\n        latency = ping_host(host)\n        host_label = host.replace(&#039;.&#039;, &#039;_&#039;)\n        \n        if latency &gt;= 0:\n            metrics[f&#039;internet_ping_success{{host=&quot;{host}&quot;}}&#039;] = 1\n            metrics[f&#039;internet_ping_latency_ms{{host=&quot;{host}&quot;}}&#039;] = latency\n            ping_success_count += 1\n            total_latency += latency\n        else:\n            metrics[f&#039;internet_ping_success{{host=&quot;{host}&quot;}}&#039;] = 0\n            metrics[f&#039;internet_ping_latency_ms{{host=&quot;{host}&quot;}}&#039;] = 0\n    \n    # Overall connectivity status (1 if at least one ping succeeds)\n    metrics[&#039;internet_connected&#039;] = 1 if ping_success_count &gt; 0 else 0\n    \n    # Average latency (only if at least one ping succeeded)\n    if ping_success_count &gt; 0:\n        metrics[&#039;internet_ping_latency_avg_ms&#039;] = total_latency \/ ping_success_count\n    else:\n        metrics[&#039;internet_ping_latency_avg_ms&#039;] = 0\n    \n    return metrics\n\ndef format_prometheus_metrics(metrics):\n    &quot;&quot;&quot;Format metrics in Prometheus text format&quot;&quot;&quot;\n    lines = []\n    timestamp = int(time.time() * 1000)  # milliseconds\n    \n    for metric_name, value in metrics.items():\n        lines.append(f&quot;{metric_name} {value} {timestamp}&quot;)\n    \n    return &#039;\\n&#039;.join(lines)\n\ndef push_metrics(metrics):\n    &quot;&quot;&quot;Push metrics to Prometheus\/VictoriaMetrics&quot;&quot;&quot;\n    try:\n        prometheus_data = format_prometheus_metrics(metrics)\n        data = prometheus_data.encode(&#039;utf-8&#039;)\n        \n        request = Request(\n            PROMETHEUS_URL,\n            data=data,\n            headers={&#039;Content-Type&#039;: &#039;text\/plain&#039;},\n            method=&#039;POST&#039;\n        )\n        \n        response = urlopen(request, timeout=10)\n        \n        if response.status in [200, 204]:\n            print(f&quot;[{datetime.now().isoformat()}] Metrics pushed successfully&quot;)\n            return True\n        else:\n            print(f&quot;[{datetime.now().isoformat()}] Failed to push metrics: HTTP {response.status}&quot;)\n            return False\n    except (URLError, HTTPError, socket.timeout) as e:\n        print(f&quot;[{datetime.now().isoformat()}] Error pushing metrics: {e}&quot;)\n        return False\n\ndef main():\n    &quot;&quot;&quot;Main monitoring loop&quot;&quot;&quot;\n    print(f&quot;Starting Internet Connectivity Monitor&quot;)\n    print(f&quot;Target: {PROMETHEUS_URL}&quot;)\n    print(f&quot;Check interval: {CHECK_INTERVAL} seconds&quot;)\n    print(f&quot;Ping hosts: {&#039;, &#039;.join(PING_HOSTS)}&quot;)\n    print(&quot;-&quot; * 60)\n    \n    while True:\n        try:\n            # Collect metrics\n            metrics = collect_metrics()\n            \n            # Display status\n            status = &quot;ONLINE&quot; if metrics[&#039;internet_connected&#039;] == 1 else &quot;OFFLINE&quot;\n            print(f&quot;[{datetime.now().isoformat()}] Status: {status}&quot;)\n            \n            # Push to Prometheus\n            push_metrics(metrics)\n            \n            # Wait for next check\n            time.sleep(CHECK_INTERVAL)\n            \n        except KeyboardInterrupt:\n            print(&quot;\\nShutting down gracefully...&quot;)\n            sys.exit(0)\n        except Exception as e:\n            print(f&quot;[{datetime.now().isoformat()}] Unexpected error: {e}&quot;)\n            time.sleep(CHECK_INTERVAL)\n\nif __name__ == &quot;__main__&quot;:\n    main()<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo chmod +x \/usr\/local\/bin\/connectivity_monitor.py<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/systemd\/system\/connectivity-monitor.service<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-\">[Unit]\nDescription=Internet Connectivity Monitor\nAfter=network.target\nWants=network.target\n\n[Service]\nType=simple\nExecStart=\/usr\/bin\/python3 \/usr\/local\/bin\/connectivity_monitor.py\nRestart=always\nRestartSec=10\nUser=root\n\n[Install]\nWantedBy=multi-user.target<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl daemon-reload<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl enable connectivity-monitor<\/code><\/mark><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>Following is a script to check SIM data balance and expiration, and log those stats to victoriametrics. Also sends a weekly summary to an email address.<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo apt install python3 python3-requests modemmanager<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/usr\/local\/bin\/sim-balance-monitor.py<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-python\">#!\/usr\/bin\/env python3\n&quot;&quot;&quot;\nSIM Balance Monitor\nChecks SIM card data balance daily and sends weekly email\/SMS reports\n&quot;&quot;&quot;\n\nimport subprocess\nimport re\nimport csv\nimport time\nimport smtplib\nimport socket\nimport os\nfrom datetime import datetime, timedelta\nfrom pathlib import Path\nfrom email.mime.text import MIMEText\nfrom email.mime.multipart import MIMEMultipart\nimport sys\nimport argparse\nimport logging\nimport configparser\n\n# Default configuration file path\nDEFAULT_CONFIG_FILE = &#039;\/etc\/sim-balance-monitor.conf&#039;\n\n# Default values if not specified in config\nDEFAULT_CONFIG = {\n    &#039;modem_index&#039;: &#039;0&#039;,\n    &#039;data_file&#039;: &#039;\/var\/log\/sim-balance\/balance-data.csv&#039;,\n    &#039;pending_file&#039;: &#039;\/var\/log\/sim-balance\/pending-report.txt&#039;,\n    &#039;log_file&#039;: &#039;\/var\/log\/sim-balance\/monitor.log&#039;,\n    &#039;debug_mode&#039;: &#039;False&#039;,\n    &#039;ussd_init&#039;: &#039;#1234#&#039;,\n    &#039;ussd_responses&#039;: &#039;3,1&#039;,\n    &#039;sms_ussd_init&#039;: &#039;#123#&#039;,\n    &#039;sms_ussd_responses&#039;: &#039;0,3&#039;,\n}\n\ndef load_config(config_file=DEFAULT_CONFIG_FILE):\n    &quot;&quot;&quot;Load configuration from file&quot;&quot;&quot;\n    config = configparser.ConfigParser()\n    \n    if not Path(config_file).exists():\n        print(f&quot;ERROR: Configuration file not found: {config_file}&quot;, file=sys.stderr)\n        print(f&quot;Please create the configuration file at {config_file}&quot;, file=sys.stderr)\n        print(f&quot;See the installation guide for the configuration template.&quot;, file=sys.stderr)\n        sys.exit(1)\n    \n    try:\n        config.read(config_file)\n    except Exception as e:\n        print(f&quot;ERROR: Failed to parse configuration file: {e}&quot;, file=sys.stderr)\n        sys.exit(1)\n    \n    # Build configuration dictionary\n    cfg = {}\n    \n    # General settings\n    # Ensure modem_index is stored as a string as mmcli expects a string\n    cfg[&#039;modem_index&#039;] = config.get(&#039;General&#039;, &#039;modem_index&#039;, fallback=DEFAULT_CONFIG[&#039;modem_index&#039;]) \n    cfg[&#039;data_file&#039;] = config.get(&#039;General&#039;, &#039;data_file&#039;, fallback=DEFAULT_CONFIG[&#039;data_file&#039;])\n    cfg[&#039;pending_file&#039;] = config.get(&#039;General&#039;, &#039;pending_file&#039;, fallback=DEFAULT_CONFIG[&#039;pending_file&#039;])\n    cfg[&#039;log_file&#039;] = config.get(&#039;General&#039;, &#039;log_file&#039;, fallback=DEFAULT_CONFIG[&#039;log_file&#039;])\n    \n    # --- NEW CONFIGURATION LINE ---\n    cfg[&#039;debug_mode&#039;] = config.getboolean(&#039;General&#039;, &#039;debug_mode&#039;, fallback=DEFAULT_CONFIG[&#039;debug_mode&#039;]) \n    \n    # USSD codes\n    cfg[&#039;ussd_init&#039;] = config.get(&#039;USSD&#039;, &#039;data_init&#039;, fallback=DEFAULT_CONFIG[&#039;ussd_init&#039;])\n    cfg[&#039;ussd_responses&#039;] = config.get(&#039;USSD&#039;, &#039;data_responses&#039;, fallback=DEFAULT_CONFIG[&#039;ussd_responses&#039;]).split(&#039;,&#039;)\n    cfg[&#039;sms_ussd_init&#039;] = config.get(&#039;USSD&#039;, &#039;sms_init&#039;, fallback=DEFAULT_CONFIG[&#039;sms_ussd_init&#039;])\n    cfg[&#039;sms_ussd_responses&#039;] = config.get(&#039;USSD&#039;, &#039;sms_responses&#039;, fallback=DEFAULT_CONFIG[&#039;sms_ussd_responses&#039;]).split(&#039;,&#039;)\n    \n    # Email settings\n    cfg[&#039;email_to&#039;] = config.get(&#039;Email&#039;, &#039;to&#039;, fallback=&#039;&#039;)\n    cfg[&#039;email_from&#039;] = config.get(&#039;Email&#039;, &#039;from&#039;, fallback=f&#039;sim-monitor@{socket.gethostname()}&#039;)\n    cfg[&#039;smtp_host&#039;] = config.get(&#039;Email&#039;, &#039;smtp_host&#039;, fallback=&#039;smtp.gmail.com&#039;)\n    cfg[&#039;smtp_port&#039;] = int(config.get(&#039;Email&#039;, &#039;smtp_port&#039;, fallback=&#039;587&#039;))\n    cfg[&#039;smtp_user&#039;] = config.get(&#039;Email&#039;, &#039;smtp_user&#039;, fallback=&#039;&#039;)\n    cfg[&#039;smtp_password&#039;] = config.get(&#039;Email&#039;, &#039;smtp_password&#039;, fallback=&#039;&#039;)\n    \n    # SMS settings\n    cfg[&#039;sms_to&#039;] = config.get(&#039;SMS&#039;, &#039;to&#039;, fallback=&#039;&#039;)\n    \n    return cfg\n\n# Global config variable\nCONFIG = None\n\n# Setup logging\ndef setup_logging():\n    log_dir = Path(CONFIG[&#039;log_file&#039;]).parent\n    log_dir.mkdir(parents=True, exist_ok=True)\n    \n    # Set log level based on CONFIG[&#039;debug_mode&#039;]\n    log_level = logging.DEBUG if CONFIG[&#039;debug_mode&#039;] else logging.INFO\n    \n    logging.basicConfig(\n        level=log_level,\n        format=&#039;[%(asctime)s] %(levelname)s: %(message)s&#039;,\n        datefmt=&#039;%Y-%m-%d %H:%M:%S&#039;,\n        handlers=[\n            logging.FileHandler(CONFIG[&#039;log_file&#039;]),\n            logging.StreamHandler()\n        ]\n    )\n\nclass USSDSession:\n    &quot;&quot;&quot;Manages USSD communication with the modem&quot;&quot;&quot;\n    \n    def __init__(self, modem_index):\n        self.modem_index = modem_index\n    \n    def _run_mmcli(self, args, timeout=10):\n        &quot;&quot;&quot;Run mmcli command and return output&quot;&quot;&quot;\n        cmd = [&#039;mmcli&#039;, &#039;-m&#039;, str(self.modem_index)] + args\n        logging.debug(f&quot;Executing mmcli command: {&#039; &#039;.join(cmd)}&quot;)\n        try:\n            result = subprocess.run(\n                cmd,\n                capture_output=True,\n                text=True,\n                timeout=timeout\n            )\n            return result.stdout, result.stderr, result.returncode\n        except subprocess.TimeoutExpired:\n            logging.error(f&quot;Command timeout: {&#039; &#039;.join(cmd)}&quot;)\n            return None, &quot;Timeout&quot;, 1\n    \n    def cancel(self):\n        &quot;&quot;&quot;Cancel any active USSD session&quot;&quot;&quot;\n        logging.info(&quot;Attempting to cancel any active USSD session.&quot;)\n        self._run_mmcli([&#039;--3gpp-ussd-cancel&#039;], timeout=10) # Shorter timeout for cancel\n    \n    def _get_ussd_status(self, output):\n        &quot;&quot;&quot;Extract USSD status from mmcli output.&quot;&quot;&quot;\n        # Fixed regex to capture hyphens in status (e.g., &#039;user-response&#039;)\n        match = re.search(r&#039;status:\\s*([\\w-]+)&#039;, output)\n        if match:\n            return match.group(1).strip()\n        return None\n    \n    def _poll_for_response(self, timeout=10):\n        &quot;&quot;&quot;Polls mmcli --3gpp-ussd-status until status is user-response\/terminated or timeout.&quot;&quot;&quot;\n        start_time = time.time()\n        \n        while time.time() - start_time &lt; timeout:\n            logging.debug(&quot;Polling USSD status...&quot;)\n            stdout, stderr, rc = self._run_mmcli([&#039;--3gpp-ussd-status&#039;], timeout=10)\n            \n            if rc != 0:\n                # Check for critical modem error\n                if &#039;invalid modem index&#039; in stderr.lower() or &#039;modem not found&#039; in stderr.lower():\n                    logging.critical(f&quot;CRITICAL ERROR during polling: Modem not found or invalid index: {stderr.strip()}&quot;)\n                    return &#039;MODEM_NOT_FOUND&#039;\n                \n                # Treat other rc != 0 as non-critical but stop polling\n                logging.warning(f&quot;USSD status check failed (non-critical, rc={rc}): {stderr.strip()}. Stopping poll.&quot;)\n                return None\n            \n            # Check the session status\n            status = self._get_ussd_status(stdout)\n            \n            # Stop polling when the status changes to a terminal\/response state\n            if status in [&#039;user-response&#039;, &#039;terminated&#039;]:\n                logging.info(f&quot;Poll stopping: Status changed to {status}. Attempting to extract response...&quot;)\n                \n                # Extract the response based on new priority logic\n                response = self._extract_response(stdout)\n                \n                if not response and status == &#039;user-response&#039;:\n                    logging.warning(&quot;Status is &#039;user-response&#039; but no network request text was found.&quot;)\n\n                return response # Returns the response or None if extraction failed\n            \n            logging.debug(f&quot;Status is &#039;{status}&#039;. Polling again in 1 second...&quot;)\n            time.sleep(1)\n            \n        logging.warning(&quot;USSD polling timed out after 10 seconds.&quot;)\n        return None\n\n    def initiate(self, code):\n        &quot;&quot;&quot;Initiate USSD session&quot;&quot;&quot;\n        logging.info(f&quot;Initiating USSD: {code}&quot;)\n        stdout, stderr, rc = self._run_mmcli([&#039;--3gpp-ussd-initiate&#039;, code])\n        \n        # Log full output for debugging\n        if stdout:\n            logging.debug(f&quot;STDOUT: {stdout}&quot;)\n        if stderr:\n            logging.debug(f&quot;STDERR: {stderr}&quot;)\n        \n        if rc != 0:\n            if &#039;invalid modem index&#039; in stderr.lower() or &#039;modem not found&#039; in stderr.lower():\n                logging.critical(f&quot;CRITICAL ERROR: Modem not found or invalid index: {stderr}&quot;)\n                return &#039;MODEM_NOT_FOUND&#039;\n            else:\n                logging.error(f&quot;USSD initiate failed (non-critical): {stderr}&quot;)\n                return None\n        \n        # --- NEW LOGIC: Check for immediate reply in initiate STDOUT, overriding the poll ---\n        immediate_reply_match = re.search(r&quot;new reply from network:\\s*&#039;(.*?)&#039;&quot;, stdout, re.DOTALL)\n        if immediate_reply_match:\n            reply = immediate_reply_match.group(1).strip()\n            # Clean up the response\n            cleaned_reply = &#039; &#039;.join(reply.split())\n            if cleaned_reply:\n                logging.info(f&quot;Immediate Reply Found from Initiate (new reply from network): {cleaned_reply[:50]}...&quot;)\n                return cleaned_reply\n        # --- END NEW LOGIC ---\n        \n        # Add 1-second delay before first poll\n        logging.debug(&quot;Waiting 1 second for network response before starting poll.&quot;)\n        time.sleep(1) \n        \n        # Start status polling immediately\n        logging.info(&quot;Starting USSD status polling...&quot;)\n        poll_result = self._poll_for_response()\n        \n        return poll_result\n    \n    def respond(self, response_code):\n        &quot;&quot;&quot;Respond to USSD prompt&quot;&quot;&quot;\n        logging.info(f&quot;Responding: {response_code}&quot;)\n        stdout, stderr, rc = self._run_mmcli([&#039;--3gpp-ussd-respond&#039;, response_code])\n        \n        # Log full output for debugging\n        if stdout:\n            logging.debug(f&quot;STDOUT: {stdout}&quot;)\n        if stderr:\n            logging.debug(f&quot;STDERR: {stderr}&quot;)\n        \n        if rc != 0:\n            if &#039;invalid modem index&#039; in stderr.lower() or &#039;modem not found&#039; in stderr.lower():\n                logging.critical(f&quot;CRITICAL ERROR: Modem not found or invalid index: {stderr}&quot;)\n                return &#039;MODEM_NOT_FOUND&#039;\n            else:\n                logging.error(f&quot;USSD respond failed (non-critical): {stderr}&quot;)\n                return None\n        \n        # Increased wait time to 1 second before starting to poll\n        logging.debug(&quot;Waiting 1 second for network response before starting poll.&quot;)\n        time.sleep(1) \n        \n        # Start status polling immediately\n        logging.info(&quot;Starting USSD status polling...&quot;)\n        poll_result = self._poll_for_response()\n        \n        return poll_result\n    \n    def _extract_response(self, output):\n        &quot;&quot;&quot;\n        Extract response text, prioritizing &#039;new reply from network&#039; and falling back to \n        &#039;network request&#039; (user-response field) from the mmcli --3gpp-ussd-status output.\n        &quot;&quot;&quot;\n        if not output:\n            logging.warning(&quot;No output from mmcli command&quot;)\n            return None\n        \n        logging.debug(f&quot;Raw mmcli output: {output.strip()}&quot;)\n\n        # Define fields to check in priority order: 1. &#039;new reply from network:&#039;, 2. &#039;network request:&#039;\n        fields_to_check = [\n            (&#039;new reply from network:&#039;, &quot;New Reply&quot;),\n            (&#039;network request:&#039;, &quot;Network Request\/User-Response&quot;)\n        ]\n        \n        lines = output.split(&#039;\\n&#039;)\n        \n        for field_name, log_name in fields_to_check:\n            if field_name not in output:\n                continue # Skip if the field isn&#039;t even in the output\n            \n            response_lines = []\n            capturing_request = False\n            \n            logging.debug(f&quot;Attempting to parse multi-line &#039;{field_name}&#039; output.&quot;)\n            \n            for line in lines:\n                line_strip = line.strip()\n                \n                if field_name in line and not capturing_request:\n                    # Found the start of the field\n                    capturing_request = True\n                    # Extract text after the field name on the initial line\n                    parts = line.split(field_name, 1)\n                    if len(parts) &gt; 1 and parts[1].strip():\n                        response_lines.append(parts[1].strip())\n                \n                elif capturing_request:\n                    # Check for continuation lines (indented and usually starting with &#039;|&#039;)\n                    if line.strip().startswith(&#039;|&#039;):\n                        # The text starts after the pipe and any preceding whitespace\n                        text = line.split(&#039;|&#039;, 1)\n                        if len(text) &gt; 1:\n                            response_lines.append(text[1].strip())\n                    elif not line_strip and response_lines:\n                         # Stop capturing on a blank line if we&#039;ve already found content\n                         break\n                    elif not line.startswith(&#039; &#039;):\n                        # Stop capturing if indentation breaks (e.g., hit another section header)\n                        # This covers the case where a new section (like &#039;Properties:&#039; or &#039;status:&#039;) begins\n                        break\n\n            if response_lines:\n                response = &#039; &#039;.join(response_lines)\n                # Clean up the response (remove excessive whitespace, newlines)\n                response = &#039; &#039;.join(response.split()) \n                \n                # Check for emptiness after cleanup (e.g., if it only contained whitespace\/newlines)\n                if response:\n                    logging.info(f&quot;Extracted response ({log_name}): {response[:100]}...&quot;)\n                    return response\n\n        # If neither field contained non-empty content\n        logging.warning(&quot;Could not extract USSD response from &#039;new reply from network&#039; or &#039;network request&#039; fields.&quot;)\n        return None\n\nclass BalanceChecker:\n    &quot;&quot;&quot;Handles balance checking and data storage&quot;&quot;&quot;\n    \n    def __init__(self):\n        self.data_file = Path(CONFIG[&#039;data_file&#039;])\n        self.data_file.parent.mkdir(parents=True, exist_ok=True)\n        self.ussd = USSDSession(CONFIG[&#039;modem_index&#039;])\n    \n    def check_data_balance(self):\n        &quot;&quot;&quot;Perform USSD sequence to check data balance&quot;&quot;&quot;\n        logging.info(&quot;Starting data balance check&quot;)\n        \n        # Step 1: Cancel any existing session\n        self.ussd.cancel()\n        time.sleep(2)\n        \n        # Step 2: Initial USSD\n        response = self.ussd.initiate(CONFIG[&#039;ussd_init&#039;])\n        if response == &#039;MODEM_NOT_FOUND&#039;:\n            return &#039;MODEM_NOT_FOUND&#039;\n        if not response:\n            logging.error(&quot;No response from initial USSD (non-critical failure)&quot;)\n            self.ussd.cancel()\n            return None\n        logging.info(f&quot;Data Response 1: {response[:50]}...&quot;)\n        \n        # Step 3: Follow-up responses\n        for i, resp_code in enumerate(CONFIG[&#039;ussd_responses&#039;], 2):\n            time.sleep(3)\n            response = self.ussd.respond(resp_code)\n            if response == &#039;MODEM_NOT_FOUND&#039;:\n                return &#039;MODEM_NOT_FOUND&#039;\n            if not response:\n                logging.error(f&quot;No response from data step {i} (non-critical failure)&quot;)\n                self.ussd.cancel()\n                return None\n            logging.info(f&quot;Data Response {i}: {response[:50]}...&quot;)\n        \n        # Step 4: Final cancellation\n        self.ussd.cancel()\n        return response\n    \n    def check_sms_balance(self):\n        &quot;&quot;&quot;Perform USSD sequence to check SMS balance&quot;&quot;&quot;\n        logging.info(&quot;Starting SMS balance check&quot;)\n        \n        # Step 1: Cancel any existing session\n        self.ussd.cancel()\n        time.sleep(2)\n        \n        # Step 2: Initial USSD\n        response = self.ussd.initiate(CONFIG[&#039;sms_ussd_init&#039;])\n        if response == &#039;MODEM_NOT_FOUND&#039;:\n            return &#039;MODEM_NOT_FOUND&#039;\n        if not response:\n            logging.error(&quot;No response from initial SMS USSD (non-critical failure)&quot;)\n            self.ussd.cancel()\n            return None\n        logging.info(f&quot;SMS Response 1: {response[:50]}...&quot;)\n        \n        # Step 3: Follow-up responses\n        for i, resp_code in enumerate(CONFIG[&#039;sms_ussd_responses&#039;], 2):\n            time.sleep(3)\n            response = self.ussd.respond(resp_code)\n            if response == &#039;MODEM_NOT_FOUND&#039;:\n                return &#039;MODEM_NOT_FOUND&#039;\n            if not response:\n                logging.error(f&quot;No response from SMS step {i} (non-critical failure)&quot;)\n                self.ussd.cancel()\n                return None\n            logging.info(f&quot;SMS Response {i}: {response[:50]}...&quot;)\n        \n        # Step 4: Final cancellation\n        self.ussd.cancel()\n        return response\n    \n    def parse_data_balance(self, response):\n        &quot;&quot;&quot;\n        Parse data balance and expiry from response using multiple patterns for robustness.\n        We attempt to convert all balances to MB for storage consistency.\n        &quot;&quot;&quot;\n        \n        # --- Pattern 1: Specific (Mo\/Go\/MB\/GB + jusqu&#039;au + time) ---\n        # e.g., &quot;6676 Mo valables jusqu&#039;au 10\/11\/2025 \u00e0 17:51:29&quot;\n        pattern_1 = r&#039;(\\d+\\.?\\d*)\\s*(Mo|Go|MB|GB).*?jusqu\\&#039;au\\s*(\\d{2}\/\\d{2}\/\\d{2,4})\\s*\u00e0\\s*(\\d{2}:\\d{2}:\\d{2})&#039;\n        match = re.search(pattern_1, response, re.IGNORECASE | re.MULTILINE)\n        \n        if match:\n            balance_val = float(match.group(1))\n            balance_unit = match.group(2).upper()\n            expiry_date = match.group(3)\n            expiry_time = match.group(4)\n            \n            # Convert balance to MB\/GB\n            balance_mb = int(balance_val * 1024) if balance_unit.startswith(&#039;G&#039;) else int(balance_val)\n            balance_gb = round(balance_mb \/ 1024, 2)\n            \n            logging.info(f&quot;Data Balance found (P1 - jusqu&#039;au): {balance_mb} MB ({balance_gb} GB), Expires: {expiry_date} {expiry_time}&quot;)\n            return {\n                &#039;balance_mb&#039;: balance_mb,\n                &#039;balance_gb&#039;: balance_gb,\n                &#039;expiry_date&#039;: expiry_date,\n                &#039;expiry_time&#039;: expiry_time\n            }\n\n        # --- Pattern 2: Generic (MB\/GB\/Mo\/Go + expire le\/valable jusqu&#039;au) ---\n        # e.g., &quot;10.5 GB data bundle which expire le 05\/03\/2026&quot;\n        pattern_2 = r&#039;(\\d+\\.?\\d*)\\s*(MB|GB|Mo|Go).*?(expire\\s*le|valable\\s*jusqu\\&#039;au|valables\\s*jusqu\\&#039;au)\\s*(\\d{2}\/\\d{2}\/\\d{2,4})&#039;\n        match = re.search(pattern_2, response, re.IGNORECASE | re.MULTILINE)\n        \n        if match:\n            balance_val = float(match.group(1))\n            balance_unit = match.group(2).upper()\n            expiry_date = match.group(4)\n            expiry_time = &#039;N\/A&#039; # Time not guaranteed by this pattern\n            \n            # Convert balance to MB\/GB\n            balance_mb = int(balance_val * 1024) if balance_unit.startswith(&#039;G&#039;) else int(balance_val)\n            balance_gb = round(balance_mb \/ 1024, 2)\n            \n            logging.info(f&quot;Data Balance found (P2 - expire le\/jusqu&#039;au): {balance_mb} MB ({balance_gb} GB), Expires: {expiry_date} {expiry_time}&quot;)\n            return {\n                &#039;balance_mb&#039;: balance_mb,\n                &#039;balance_gb&#039;: balance_gb,\n                &#039;expiry_date&#039;: expiry_date,\n                &#039;expiry_time&#039;: &#039;N\/A&#039;\n            }\n        \n        # --- Pattern 3: Fallback (Just the Balance Value - No Expiry) ---\n        # e.g., &quot;Your remaining balance is 4096 MB.&quot;\n        pattern_3 = r&#039;(\\d+\\.?\\d*)\\s*(MB|GB|Mo|Go)&#039;\n        match = re.search(pattern_3, response, re.IGNORECASE)\n\n        if match:\n            balance_val = float(match.group(1))\n            balance_unit = match.group(2).upper()\n            \n            # Convert balance to MB\/GB\n            balance_mb = int(balance_val * 1024) if balance_unit.startswith(&#039;G&#039;) else int(balance_val)\n            balance_gb = round(balance_mb \/ 1024, 2)\n\n            logging.warning(f&quot;Data Balance found (P3 - Fallback): {balance_mb} MB ({balance_gb} GB). NO EXPIRY DATE FOUND.&quot;)\n            return {\n                &#039;balance_mb&#039;: balance_mb,\n                &#039;balance_gb&#039;: balance_gb,\n                &#039;expiry_date&#039;: &#039;N\/A&#039;,\n                &#039;expiry_time&#039;: &#039;N\/A&#039;\n            }\n        \n        logging.error(f&quot;Could not parse data balance from: {response}&quot;)\n        return None\n    \n    def parse_sms_balance(self, response):\n        &quot;&quot;&quot;Parse SMS balance and expiry from response&quot;&quot;&quot;\n        # Pattern: &quot;935 SMS, valable jusqu&#039;au 10\/11\/2025 17:51:29&quot;\n        pattern = r&#039;(\\d+)\\s*SMS.*?valable\\s*jusqu\\&#039;au\\s*(\\d{2}\/\\d{2}\/\\d{4})&#039;\n        match = re.search(pattern, response, re.IGNORECASE)\n        \n        if match:\n            sms_count = int(match.group(1))\n            expiry_date = match.group(2)\n            \n            logging.info(f&quot;SMS Balance: {sms_count} SMS, Expires: {expiry_date}&quot;)\n            return {\n                &#039;sms_count&#039;: sms_count,\n                &#039;expiry_date&#039;: expiry_date,\n                &#039;expiry_time&#039;: &#039;N\/A&#039; # Not reliably available in this pattern\n            }\n        \n        # Fallback: Just the SMS count\n        pattern_fallback = r&#039;(\\d+)\\s*SMS&#039;\n        match = re.search(pattern_fallback, response, re.IGNORECASE)\n        \n        if match:\n            sms_count = int(match.group(1))\n            logging.warning(f&quot;SMS Balance found (Fallback): {sms_count} SMS. NO EXPIRY DATE FOUND.&quot;)\n            return {\n                &#039;sms_count&#039;: sms_count,\n                &#039;expiry_date&#039;: &#039;N\/A&#039;,\n                &#039;expiry_time&#039;: &#039;N\/A&#039;\n            }\n        \n        logging.error(f&quot;Could not parse SMS balance from: {response}&quot;)\n        return None\n    \n    def save_balance(self, data_info, sms_info):\n        &quot;&quot;&quot;Save balance data to CSV&quot;&quot;&quot;\n        timestamp = datetime.now().strftime(&#039;%Y-%m-%d %H:%M:%S&#039;)\n        \n        # Ensure &#039;ERROR&#039; or &#039;N\/A&#039; is used for missing data fields\n        def get_value(info, key, default=&#039;ERROR&#039;):\n            return str(info.get(key, default)) if info else default\n\n        data_mb = get_value(data_info, &#039;balance_mb&#039;)\n        data_gb = get_value(data_info, &#039;balance_gb&#039;)\n        data_expiry_date = get_value(data_info, &#039;expiry_date&#039;)\n        data_expiry_time = get_value(data_info, &#039;expiry_time&#039;)\n        \n        sms_count = get_value(sms_info, &#039;sms_count&#039;)\n        sms_expiry_date = get_value(sms_info, &#039;expiry_date&#039;)\n        sms_expiry_time = get_value(sms_info, &#039;expiry_time&#039;)\n        \n        # Only save if at least one check succeeded\n        if data_mb != &#039;ERROR&#039; or sms_count != &#039;ERROR&#039;:\n            try:\n                with open(self.data_file, &#039;a&#039;, newline=&#039;&#039;) as f:\n                    writer = csv.writer(f)\n                    writer.writerow([\n                        timestamp,\n                        data_mb,\n                        data_gb,\n                        data_expiry_date,\n                        data_expiry_time,\n                        sms_count,\n                        sms_expiry_date,\n                        sms_expiry_time\n                    ])\n                logging.info(f&quot;Balance data saved to {self.data_file}&quot;)\n            except Exception as e:\n                logging.error(f&quot;Failed to write to data file: {e}&quot;)\n        else:\n            logging.warning(&quot;No valid data or SMS info to save.&quot;)\n\n    \n    def run(self):\n        &quot;&quot;&quot;\n        Run complete balance check.\n        Returns True if a CRITICAL &#039;MODEM_NOT_FOUND&#039; error occurred, False otherwise.\n        &quot;&quot;&quot;\n        \n        # --- Data Balance Check ---\n        data_response = self.check_data_balance()\n        if data_response == &#039;MODEM_NOT_FOUND&#039;:\n            return True # Signal Critical Failure\n            \n        data_info = self.parse_data_balance(data_response) if data_response else None\n        \n        # Wait a bit between checks\n        time.sleep(5)\n        \n        # --- SMS Balance Check ---\n        sms_response = self.check_sms_balance()\n        if sms_response == &#039;MODEM_NOT_FOUND&#039;:\n            return True # Signal Critical Failure\n            \n        sms_info = self.parse_sms_balance(sms_response) if sms_response else None\n        \n        # --- Save Results ---\n        self.save_balance(data_info, sms_info)\n        \n        # If we reached here, there was NO CRITICAL modem error.\n        return False # Signal Not Critical Failure\n        \n\nclass ReportGenerator:\n    &quot;&quot;&quot;Generates reports from balance data&quot;&quot;&quot;\n    \n    def __init__(self):\n        self.data_file = Path(CONFIG[&#039;data_file&#039;])\n    \n    def get_weekly_data(self):\n        &quot;&quot;&quot;Get balance data from last 7 days&quot;&quot;&quot;\n        if not self.data_file.exists():\n            return []\n        \n        cutoff = datetime.now() - timedelta(days=7)\n        cutoff_str = cutoff.strftime(&#039;%Y-%m-%d&#039;)\n        \n        data = []\n        with open(self.data_file, &#039;r&#039;) as f:\n            reader = csv.reader(f)\n            for row in reader:\n                # Assuming the first column is the timestamp\n                if row and row[0] &gt;= cutoff_str:\n                    data.append(row)\n        \n        return data\n    \n    def calculate_daily_usage(self, data):\n        &quot;&quot;&quot;Calculate average daily data usage&quot;&quot;&quot;\n        prev_balance = None\n        total_usage = 0\n        usage_days = 0\n        \n        for row in data:\n            # Data MB is column 1 (index 1)\n            if len(row) &gt; 1 and row[1] not in [&#039;ERROR&#039;, &#039;N\/A&#039;]:\n                try:\n                    balance = float(row[1]) # Use float for MB balance\n                    if prev_balance is not None:\n                        daily_usage = prev_balance - balance\n                        # Only count positive usage\n                        if daily_usage &gt;= 0:\n                            total_usage += daily_usage\n                            usage_days += 1\n                    prev_balance = balance\n                except ValueError:\n                    logging.debug(f&quot;Skipping non-numeric data balance value: {row[1]}&quot;)\n                    continue\n        \n        return int(total_usage \/ usage_days) if usage_days &gt; 0 else 0\n    \n    def calculate_daily_sms_usage(self, data):\n        &quot;&quot;&quot;Calculate average daily SMS usage&quot;&quot;&quot;\n        prev_balance = None\n        total_usage = 0\n        usage_days = 0\n        \n        for row in data:\n            # SMS count is column 5 (index 5)\n            if len(row) &gt; 5 and row[5] not in [&#039;ERROR&#039;, &#039;N\/A&#039;]:\n                try:\n                    balance = int(row[5])\n                    if prev_balance is not None:\n                        daily_usage = prev_balance - balance\n                        if daily_usage &gt;= 0:\n                            total_usage += daily_usage\n                            usage_days += 1\n                    prev_balance = balance\n                except ValueError:\n                    logging.debug(f&quot;Skipping non-integer SMS balance value: {row[5]}&quot;)\n                    continue\n\n        \n        return total_usage \/\/ usage_days if usage_days &gt; 0 else 0\n    \n    def generate_email_report(self, data):\n        &quot;&quot;&quot;Generate detailed email report&quot;&quot;&quot;\n        report = &quot;SIM Card Data Balance Report\\n&quot;\n        report += &quot;=&quot; * 80 + &quot;\\n\\n&quot;\n        report += &quot;Report Period: Last 7 Days\\n&quot;\n        report += f&quot;Generated: {datetime.now().strftime(&#039;%Y-%m-%d %H:%M:%S&#039;)}\\n\\n&quot;\n        \n        if not data:\n            report += &quot;No data collected during this period.\\n&quot;\n            return report\n        \n        report += &quot;Daily Balance History:\\n&quot;\n        report += &quot;-&quot; * 80 + &quot;\\n&quot;\n        report += f&quot;{&#039;Date&#039;:&lt;19} | {&#039;Data Balance (MB)&#039;:&lt;19} | {&#039;SMS Count&#039;:&lt;12} | {&#039;Expiry Date&#039;}\\n&quot;\n        report += &quot;-&quot; * 80 + &quot;\\n&quot;\n        \n        for row in data:\n            # CSV structure: [0]Timestamp, [1]MB, [2]GB, [3]D_Date, [4]D_Time, [5]SMS_Cnt, [6]S_Date, [7]S_Time\n            if len(row) &gt;= 8:\n                timestamp = row[0]\n                balance_mb = row[1]\n                balance_gb = row[2]\n                data_expiry_date = row[3]\n                data_expiry_time = row[4]\n                sms_count = row[5]\n            else:\n                # Handle incomplete rows gracefully (shouldn&#039;t happen with the new save logic)\n                continue\n            \n            data_str = f&quot;{balance_mb} ({balance_gb}GB)&quot; if balance_mb not in [&#039;ERROR&#039;, &#039;N\/A&#039;] else &#039;ERROR&#039;\n            sms_str = f&quot;{sms_count}&quot; if sms_count not in [&#039;ERROR&#039;, &#039;N\/A&#039;] else &#039;N\/A&#039;\n            expiry_str = f&quot;{data_expiry_date} {data_expiry_time}&quot; if data_expiry_date != &#039;ERROR&#039; else &#039;ERROR&#039;\n            \n            report += f&quot;{timestamp:&lt;19} | {data_str:&lt;19} | {sms_str:&lt;12} | {expiry_str}\\n&quot;\n        \n        report += &quot;-&quot; * 80 + &quot;\\n\\n&quot;\n        \n        # Statistics\n        valid_data_balances = [float(row[1]) for row in data if len(row) &gt; 1 and row[1] not in [&#039;ERROR&#039;, &#039;N\/A&#039;]]\n        valid_sms_balances = [int(row[5]) for row in data if len(row) &gt; 5 and row[5] not in [&#039;ERROR&#039;, &#039;N\/A&#039;]]\n        \n        if valid_data_balances or valid_sms_balances:\n            report += &quot;Summary Statistics:\\n&quot;\n            \n            if valid_data_balances:\n                avg_data = sum(valid_data_balances) \/ len(valid_data_balances)\n                min_data = min(valid_data_balances)\n                \n                # Round to 2 decimal places for better readability\n                latest_data = round(valid_data_balances[-1], 2)\n                min_data = round(min_data, 2)\n                avg_usage = self.calculate_daily_usage(data)\n                \n                report += f&quot;  Data - Latest Balance: {latest_data} MB\\n&quot;\n                report += f&quot;  Data - Minimum Balance: {min_data} MB\\n&quot;\n                report += f&quot;  Data - Average Daily Usage: {avg_usage} MB\/day\\n&quot;\n            \n            if valid_sms_balances:\n                min_sms = min(valid_sms_balances)\n                \n                report += f&quot;  SMS - Latest Balance: {valid_sms_balances[-1]} SMS\\n&quot;\n                report += f&quot;  SMS - Minimum Balance: {min_sms} SMS\\n&quot;\n                report += f&quot;  SMS - Average Daily Usage: {self.calculate_daily_sms_usage(data)} SMS\/day\\n&quot;\n        \n        return report\n    \n    def generate_sms_report(self, data):\n        &quot;&quot;&quot;Generate compact SMS report&quot;&quot;&quot;\n        if not data:\n            return None\n        \n        # Get latest data point\n        latest_row = data[-1]\n        \n        if len(latest_row) &lt; 8:\n            return None # Data is too incomplete\n            \n        latest_data_mb = latest_row[1]\n        latest_sms_count = latest_row[5]\n        data_expiry_date = latest_row[3]\n        \n        sms = f&quot;SIM Wk Sum: &quot;\n        \n        if latest_data_mb not in [&#039;ERROR&#039;, &#039;N\/A&#039;]:\n            avg_usage = self.calculate_daily_usage(data)\n            \n            # Format MB to KB for shorter SMS if value is high\n            data_val = f&quot;{int(float(latest_data_mb))}MB&quot;\n            if float(latest_data_mb) &gt; 10000: # ~10GB\n                data_val = f&quot;{float(latest_data_mb)\/1024:.1f}GB&quot;\n                \n            sms += f&quot;Data: {data_val} ({avg_usage}M\/d)&quot;\n            \n            if data_expiry_date not in [&#039;ERROR&#039;, &#039;N\/A&#039;]:\n                expiry_compact = &#039;\/&#039;.join(data_expiry_date.split(&#039;\/&#039;)[:2])\n                sms += f&quot; Exp:{expiry_compact}&quot;\n        \n        if latest_sms_count not in [&#039;ERROR&#039;, &#039;N\/A&#039;]:\n            sms += f&quot; SMS: {latest_sms_count}&quot;\n        \n        if len(sms) &gt; 160:\n            sms = sms[:157] + &#039;...&#039; # Truncate if necessary\n            \n        return sms.strip()\n\nclass EmailSender:\n    &quot;&quot;&quot;Handles email sending via SMTP&quot;&quot;&quot;\n    \n    def check_network(self):\n        &quot;&quot;&quot;Check if network is available&quot;&quot;&quot;\n        # Checks connection to Google and Cloudflare DNS servers\n        for host in [&#039;8.8.8.8&#039;, &#039;1.1.1.1&#039;]:\n            try:\n                socket.create_connection((host, 53), timeout=5)\n                return True\n            except OSError:\n                continue\n        return False\n    \n    def send_email(self, subject, body):\n        &quot;&quot;&quot;Send email using SMTP&quot;&quot;&quot;\n        if not CONFIG[&#039;email_to&#039;]:\n            logging.warning(&quot;Email recipient not configured. Skipping email report.&quot;)\n            return False\n            \n        if not self.check_network():\n            logging.warning(&quot;No network connectivity for email. Report pending.&quot;)\n            return False\n        \n        try:\n            msg = MIMEMultipart()\n            msg[&#039;From&#039;] = CONFIG[&#039;email_from&#039;]\n            msg[&#039;To&#039;] = CONFIG[&#039;email_to&#039;]\n            msg[&#039;Subject&#039;] = subject\n            msg.attach(MIMEText(body, &#039;plain&#039;))\n            \n            with smtplib.SMTP(CONFIG[&#039;smtp_host&#039;], CONFIG[&#039;smtp_port&#039;], timeout=30) as server:\n                server.starttls()\n                server.login(CONFIG[&#039;smtp_user&#039;], CONFIG[&#039;smtp_password&#039;])\n                server.send_message(msg)\n            \n            logging.info(&quot;Email sent successfully&quot;)\n            return True\n        except Exception as e:\n            logging.error(f&quot;Failed to send email: {e}&quot;)\n            return False\n\nclass SMSSender:\n    &quot;&quot;&quot;Handles SMS sending via mmcli&quot;&quot;&quot;\n    \n    def __init__(self):\n        self.modem_index = CONFIG[&#039;modem_index&#039;]\n    \n    def send_sms(self, message):\n        &quot;&quot;&quot;Send SMS using mmcli&quot;&quot;&quot;\n        if not CONFIG[&#039;sms_to&#039;]:\n            logging.warning(&quot;SMS recipient not configured. Skipping SMS report.&quot;)\n            return False\n            \n        logging.info(f&quot;Sending SMS ({len(message)} chars) to {CONFIG[&#039;sms_to&#039;]}&quot;)\n        \n        # Step 1: Create the SMS\n        # Note: Use proper quoting for text with spaces\n        create_cmd = [\n            &#039;mmcli&#039;, &#039;-m&#039;, str(self.modem_index),\n            &#039;--messaging-create-sms&#039;,\n            f&#039;text=&quot;{message}&quot;,number=&quot;{CONFIG[&quot;sms_to&quot;]}&quot;&#039;\n        ]\n        \n        try:\n            result = subprocess.run(\n                create_cmd,\n                capture_output=True,\n                text=True,\n                timeout=30\n            )\n            \n            if result.returncode != 0:\n                logging.error(f&quot;Failed to create SMS: {result.stderr.strip()}&quot;)\n                return False\n            \n            # Extract SMS index from output\n            match = re.search(r&#039;\/SMS\/(\\d+)&#039;, result.stdout)\n            if not match:\n                logging.error(f&quot;Could not extract SMS index from output: {result.stdout.strip()}&quot;)\n                return False\n            \n            sms_index = match.group(1)\n            logging.debug(f&quot;Created SMS with index: {sms_index}&quot;)\n            \n            # Step 2: Send the SMS\n            send_cmd = [\n                &#039;mmcli&#039;, &#039;-s&#039;, sms_index,\n                &#039;--send&#039;\n            ]\n            \n            result = subprocess.run(\n                send_cmd,\n                capture_output=True,\n                text=True,\n                timeout=30\n            )\n            \n            if result.returncode == 0:\n                logging.info(f&quot;SMS sent successfully&quot;)\n                return True\n            else:\n                logging.error(f&quot;Failed to send SMS: {result.stderr.strip()}&quot;)\n                return False\n                \n        except Exception as e:\n            logging.error(f&quot;SMS send error: {e}&quot;)\n            return False\n\nclass WeeklyReporter:\n    &quot;&quot;&quot;Manages weekly email and SMS reports&quot;&quot;&quot;\n    \n    def __init__(self):\n        self.report_gen = ReportGenerator()\n        self.email_sender = EmailSender()\n        self.sms_sender = SMSSender()\n        self.pending_file = Path(CONFIG[&#039;pending_file&#039;])\n    \n    def send_reports(self):\n        &quot;&quot;&quot;Send both email and SMS reports&quot;&quot;&quot;\n        logging.info(&quot;Starting weekly reports&quot;)\n        \n        # Get current week data\n        current_data = self.report_gen.get_weekly_data()\n        \n        # Load pending data if exists\n        pending_data = []\n        if self.pending_file.exists():\n            logging.info(&quot;Found pending data from previous week(s)&quot;)\n            try:\n                with open(self.pending_file, &#039;r&#039;) as f:\n                    reader = csv.reader(f)\n                    pending_data = list(reader)\n            except Exception as e:\n                logging.error(f&quot;Failed to load pending file: {e}&quot;)\n        \n        # Combine data\n        all_data = pending_data + current_data\n        \n        if not all_data:\n            logging.info(&quot;No data to report&quot;)\n            return\n\n        # Get hostname\n        hostname = socket.gethostname()\n\n        # Generate reports\n        email_body = self.report_gen.generate_email_report(all_data)\n        sms_text = self.report_gen.generate_sms_report(current_data)\n        \n        # Send email and check for success\n        subject = f&quot;Weekly SIM Balance Report - {hostname} {datetime.now().strftime(&#039;%Y-%m-%d&#039;)}&quot;\n        email_success = self.email_sender.send_email(subject, email_body)\n        \n        # Send SMS (fire-and-forget, but log success)\n        sms_success = True\n        if sms_text:\n            sms_success = self.sms_sender.send_sms(sms_text)\n            \n        if email_success:\n            # Clear pending data on email success\n            if self.pending_file.exists():\n                self.pending_file.unlink()\n                logging.debug(&quot;Cleared pending data file.&quot;)\n            \n            # Archive old data (keep 60 days)\n            self.archive_old_data()\n        else:\n            # Save all data (current + pending) for next week if email failed\n            logging.info(&quot;Email report failed. Saving all aggregated data for next week.&quot;)\n            try:\n                with open(self.pending_file, &#039;w&#039;, newline=&#039;&#039;) as f:\n                    writer = csv.writer(f)\n                    writer.writerows(all_data)\n                logging.info(f&quot;Saved {len(all_data)} entries to pending file.&quot;)\n            except Exception as e:\n                logging.error(f&quot;Failed to save pending file: {e}&quot;)\n    \n    def archive_old_data(self):\n        &quot;&quot;&quot;Keep only last 60 days of data&quot;&quot;&quot;\n        data_file = Path(CONFIG[&#039;data_file&#039;])\n        if not data_file.exists():\n            return\n        \n        cutoff = datetime.now() - timedelta(days=60)\n        cutoff_str = cutoff.strftime(&#039;%Y-%m-%d&#039;)\n        \n        temp_file = data_file.with_suffix(&#039;.tmp&#039;)\n        try:\n            with open(data_file, &#039;r&#039;) as fin, open(temp_file, &#039;w&#039;, newline=&#039;&#039;) as fout:\n                reader = csv.reader(fin)\n                writer = csv.writer(fout)\n                rows_kept = 0\n                for row in reader:\n                    if row and row[0] &gt;= cutoff_str:\n                        writer.writerow(row)\n                        rows_kept += 1\n            \n            temp_file.replace(data_file)\n            logging.info(f&quot;Archived old data. {rows_kept} rows kept.&quot;)\n        except Exception as e:\n            logging.error(f&quot;Archiving failed: {e}&quot;)\n\ndef main():\n    global CONFIG\n    \n    parser = argparse.ArgumentParser(description=&#039;SIM Balance Monitor&#039;)\n    parser.add_argument(&#039;action&#039;, choices=[&#039;check&#039;, &#039;report&#039;], \n                       help=&#039;Action to perform: check (daily balance) or report (weekly summary)&#039;)\n    parser.add_argument(&#039;-c&#039;, &#039;--config&#039;, default=DEFAULT_CONFIG_FILE,\n                       help=f&#039;Configuration file path (default: {DEFAULT_CONFIG_FILE})&#039;)\n    args = parser.parse_args()\n    \n    # Load configuration FIRST\n    CONFIG = load_config(args.config)\n    \n    # THEN setup logging (needs CONFIG to be loaded)\n    setup_logging()\n    \n    logging.info(f&quot;--- Script started: Action &#039;{args.action}&#039; ---&quot;)\n    \n    try:\n        if args.action == &#039;check&#039;:\n            checker = BalanceChecker()\n            # is_critical_failure is True only if &#039;MODEM_NOT_FOUND&#039; was detected\n            is_critical_failure = checker.run() \n            \n            if is_critical_failure:\n                logging.critical(&quot;--- Check completed with CRITICAL Modem Error. Exiting 1. ---&quot;)\n                sys.exit(1)\n            else:\n                # All other results (success, USSD failure, timeout, parsing error) exit 0 (fail silently)\n                logging.info(&quot;--- Check completed (Modem OK). Exiting 0. ---&quot;)\n                sys.exit(0)\n        \n        elif args.action == &#039;report&#039;:\n            reporter = WeeklyReporter()\n            reporter.send_reports()\n            logging.info(&quot;--- Report completed ---&quot;)\n            sys.exit(0)\n    \n    except Exception as e:\n        logging.critical(f&quot;Unexpected CRITICAL system error: {e}&quot;, exc_info=True)\n        sys.exit(1)\n\nif __name__ == &#039;__main__&#039;:\n    main()<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo chmod 755 \/usr\/local\/bin\/sim-balance-monitor.py<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo chown root:root \/usr\/local\/bin\/sim-balance-monitor.py<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/sim-balance-monitor.conf<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">[General]\n# Modem index (check with: mmcli -L)\nmodem_index = 0\ndebug_mode = false\n\n# Data file paths\ndata_file = \/var\/log\/sim-balance\/balance-data.csv\npending_file = \/var\/log\/sim-balance\/pending-report.txt\nlog_file = \/var\/log\/sim-balance\/monitor.log\n\n[USSD]\n# Data balance check USSD codes\ndata_init = #1234#\ndata_responses = 2,3,1\n\n# SMS balance check USSD codes\nsms_init = #123#\nsms_responses = 0,3\n\n[Email]\n# Email settings for reports\nto = your-email@example.com\nfrom = sim-monitor@your-server.com\nsmtp_host = smtp.gmail.com\nsmtp_port = 587\nsmtp_user = your-email@gmail.com\nsmtp_password = your-app-password-here\n\n[SMS]\n# Phone number for SMS reports (with country code)\nto = +221XXXXXXXXX\n\n[Prometheus]\n# Enable\/disable Prometheus metrics (default: true)\nenabled = true\n\n# VictoriaMetrics or Prometheus Pushgateway URL\npushgateway = http:\/\/192.168.100.1:8428\/api\/v1\/import\/prometheus\n\n# Job name for metrics\njob_name = sim_balance_monitor<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo chmod 600 \/etc\/sim-balance-monitor.conf<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo chown root:root \/etc\/sim-balance-monitor.conf<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo mkdir -p \/var\/log\/sim-balance<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo chmod 755 \/var\/log\/sim-balance<\/code><\/mark><\/p>\n\n\n\n<p>Need a wrapper script to retry the script a couple times, and then reboot if no modem found (which happens sometimes). If after a couple reboots, still no modem found, then disable the script.<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/usr\/local\/bin\/sim-balance-wrapper.sh<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">#!\/bin\/bash\n\n# Configuration\nSCRIPT_PATH=&quot;\/usr\/local\/bin\/sim-balance-monitor.py&quot; # &lt;-- UPDATE THIS PATH\nCOUNTER_FILE=&quot;\/home\/user\/fail_count&quot;          # Persistent failure counter path\nSERVICE_NAME=&quot;sim-balance-check.service&quot;\n\n# --- Setup Persistent Counter Directory ---\n# Note: For \/home\/user\/fail_count, ensure the user running the service (root or user) has permissions.\nmkdir -p &quot;$(dirname &quot;$COUNTER_FILE&quot;)&quot;\nif [ ! -f &quot;$COUNTER_FILE&quot; ]; then\n    echo 0 &gt; &quot;$COUNTER_FILE&quot;\nfi\n\n# --- Function to run the script and handle the immediate retry ---\nrun_check() {\n    echo &quot;Running balance check attempt 1...&quot;\n    &quot;$SCRIPT_PATH&quot; check\n    local EXIT_CODE=$?\n    \n    if [ $EXIT_CODE -ne 0 ]; then\n        echo &quot;Check failed (exit $EXIT_CODE). Retrying immediately in 5 minutes...&quot;\n        # 5 minutes = 300 seconds\n        sleep 300 # &lt;-- CHANGED TO 5 MINUTES (300 seconds)\n        &quot;$SCRIPT_PATH&quot; check\n        EXIT_CODE=$?\n        echo &quot;Retry finished with exit $EXIT_CODE.&quot;\n    fi\n    \n    return $EXIT_CODE\n}\n\n# --- Execute the check with retry logic ---\nrun_check\nFINAL_EXIT_CODE=$?\n\n# --- Handle Final Exit Code ---\n\nif [ $FINAL_EXIT_CODE -eq 0 ]; then\n    # SUCCESS: Reset counter\n    echo &quot;Check successful. Resetting failure counter.&quot;\n    echo 0 &gt; &quot;$COUNTER_FILE&quot;\n    exit 0\nelse\n    # CRITICAL FAILURE (exit code 1): Increment and check counter\n    CURRENT_COUNT=$(cat &quot;$COUNTER_FILE&quot;)\n    NEXT_COUNT=$((CURRENT_COUNT + 1))\n    \n    echo &quot;Critical failure detected (exit $FINAL_EXIT_CODE). Current count: $CURRENT_COUNT. New count:&gt;\n    echo &quot;$NEXT_COUNT&quot; &gt; &quot;$COUNTER_FILE&quot;\n    \n    if [ $NEXT_COUNT -eq 2 ]; then\n        # Second consecutive final failure: REBOOT\n        echo &quot;Second consecutive critical failure. Rebooting system now...&quot;\n        \/sbin\/reboot\n    elif [ $NEXT_COUNT -ge 3 ]; then\n        # Third consecutive final failure: DISABLE SERVICE\n        echo &quot;Third consecutive critical failure. Disabling service and preventing future runs.&quot;\n        \/usr\/bin\/systemctl disable --now &quot;$SERVICE_NAME&quot;\n        echo &quot;Service $SERVICE_NAME is now disabled. Manual intervention is required.&quot;\n        # Keep the process running long enough for systemd to log the disable command\n        sleep 2 \n    fi\n    \n    exit $FINAL_EXIT_CODE\nfi\n<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo chmod +x \/usr\/local\/bin\/sim-balance-wrapper.sh<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/systemd\/system\/sim-balance-check.service<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">[Unit]\nDescription=Daily SIM Balance Check and Critical Failure Handler\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nUser=root\n# Set the working directory for the script to find its config\/data files if needed\nWorkingDirectory=\/usr\/local\/bin\nExecStart=\/usr\/local\/bin\/sim-balance-wrapper.sh\n\n# Do NOT use Restart=, as the wrapper handles all retry\/reboot logic.\n\n[Install]\nWantedBy=multi-user.target<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/systemd\/system\/sim-balance-check.timer<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">[Unit]\nDescription=Run SIM Balance Check Daily\n\n[Timer]\n# Run the service daily at 6:00 AM\nOnCalendar=*-*-* 06:00:00\n# Persistent is set to false, meaning it won&#039;t run a missed job on reboot.\nPersistent=false\n\n[Install]\nWantedBy=timers.target<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/systemd\/system\/sim-balance-report.service<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">[Unit]\nDescription=SIM Balance Weekly Report\nAfter=network.target ModemManager.service\nRequires=ModemManager.service\n\n[Service]\nType=oneshot\nExecStart=\/usr\/local\/bin\/sim-balance-monitor.py report\nUser=root\nStandardOutput=journal\nStandardError=journal\n\n[Install]\nWantedBy=multi-user.target<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/systemd\/system\/sim-balance-report.timer<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">[Unit]\nDescription=SIM Balance Weekly Report Timer\nRequires=sim-balance-report.service\n\n[Timer]\n# Run every Monday at 10:00 AM\nOnCalendar=Mon *-*-* 06:02:00\nPersistent=false\n\n[Install]\nWantedBy=timers.target<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl daemon-reload<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl enable sim-balance-check.timer<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl enable sim-balance-report.timer<\/code><\/mark><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>For testing, it&#8217;s nice to be able to send USSD commands. This is possible using mmcli, but a little cumbersome. I asked Claude.ai to make me a python script to make it interactive, and it came up with the following, which works pretty well after a bit of debugging.<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo apt install python3-dbus<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano ~\/ussd_modem.py<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-python\">#!\/usr\/bin\/env python3\n&quot;&quot;&quot;\nImproved USSD ModemManager Script\n- Separates Logic (USSDClient) from UI (main)\n- Validates Modem Capabilities\n- Uses Type Hinting for clarity\n- Robust Error Handling\n- FIXED: Added explicit DBus type casting and pre-response state validation to ensure replies are correctly sent and received by the network.\n- FIXED: Introduced a mandatory 0.5s pause after the DBus command if no immediate response is returned, to prevent race conditions and ensure the modem state updates correctly before polling begins.\n- FIXED: The state stabilization poll now defaults to strictly waiting for STATE_USER_RESPONSE (3) to reduce ambiguity. STATE_IDLE (1) is caught immediately as a termination signal.\n- FIXED: Removed redundant cancellation call inside the main loop to prevent double cancellation errors. The cleanup is now handled exclusively by the &#039;finally&#039; block.\n- UPDATED: The immediate exit on STATE_IDLE within _wait_for_stable_state has been removed, allowing the session to wait up to 30 seconds for a target state even if IDLE is seen, as requested. Note: This will slow down termination if the network closes the session.\n- NEW: Added verbose debugging print statements to trace function calls and modem data exchange.\n&quot;&quot;&quot;\n\nimport sys\nimport time\nimport dbus\nfrom typing import Optional, Tuple, Dict, Any\n\n# DBUS Constants\nMM_BUS_NAME = &#039;org.freedesktop.ModemManager1&#039;\nMM_OBJ_PATH = &#039;\/org\/freedesktop\/ModemManager1&#039;\nMM_IFACE_OBJ_MANAGER = &#039;org.freedesktop.DBus.ObjectManager&#039;\nMM_IFACE_MODEM = &#039;org.freedesktop.ModemManager1.Modem&#039;\nMM_IFACE_3GPP_USSD = &#039;org.freedesktop.ModemManager1.Modem.Modem3gpp.Ussd&#039;\nDBUS_PROPS_IFACE = &#039;org.freedesktop.DBus.Properties&#039;\n\n# USSD State Codes\nSTATE_UNKNOWN = 0\nSTATE_IDLE = 1\nSTATE_ACTIVE = 2\nSTATE_USER_RESPONSE = 3\n\nclass USSDClient:\n    &quot;&quot;&quot;\n    Handles the low-level DBus communication with ModemManager for USSD.\n    &quot;&quot;&quot;\n    def __init__(self):\n        self.bus = dbus.SystemBus()\n        self.modem_path: Optional[str] = None\n        self.modem_3gpp_ussd = None\n        self.modem_props = None\n        self.debug_enabled = True # Control debugging output\n\n    def _debug(self, message: str):\n        &quot;&quot;&quot;Helper for conditional debugging output.&quot;&quot;&quot;\n        if self.debug_enabled:\n            print(f&quot;[DEBUG] {message}&quot;, file=sys.stderr)\n\n\n    def connect(self) -&gt; bool:\n        &quot;&quot;&quot;\n        Finds a modem that supports 3GPP USSD and connects to its interfaces.\n        Returns True if successful.\n        &quot;&quot;&quot;\n        self._debug(&quot;Attempting to connect to ModemManager...&quot;)\n        try:\n            manager = self.bus.get_object(MM_BUS_NAME, MM_OBJ_PATH)\n            manager_iface = dbus.Interface(manager, MM_IFACE_OBJ_MANAGER)\n            objects = manager_iface.GetManagedObjects()\n\n            for path, interfaces in objects.items():\n                # Check if this object is a Modem AND supports USSD\n                if MM_IFACE_MODEM in interfaces and MM_IFACE_3GPP_USSD in interfaces:\n                    self.modem_path = path\n                    self._debug(f&quot;Modem found at path: {path}&quot;)\n                    \n                    modem_obj = self.bus.get_object(MM_BUS_NAME, self.modem_path)\n                    self.modem_3gpp_ussd = dbus.Interface(modem_obj, MM_IFACE_3GPP_USSD)\n                    self.modem_props = dbus.Interface(modem_obj, DBUS_PROPS_IFACE)\n                    return True\n            \n            self._debug(&quot;No USSD-capable modem found.&quot;)\n            return False\n\n        except dbus.exceptions.DBusException as e:\n            self._debug(f&quot;DBus Error during connection: {e}&quot;)\n            raise ConnectionError(f&quot;DBus Error during connection: {e}&quot;)\n\n    def get_state(self) -&gt; Tuple[int, str]:\n        &quot;&quot;&quot;\n        Get current USSD session state.\n        Returns: (state_code, state_name)\n        &quot;&quot;&quot;\n        try:\n            state = self.modem_props.Get(MM_IFACE_3GPP_USSD, &#039;State&#039;)\n            states = {\n                STATE_UNKNOWN: &#039;Unknown&#039;,\n                STATE_IDLE: &#039;Idle&#039;,\n                STATE_ACTIVE: &#039;Active&#039;,\n                STATE_USER_RESPONSE: &#039;User-Response&#039;\n            }\n            state_name = states.get(state, &#039;Unknown&#039;)\n            self._debug(f&quot;Current modem state: {state_name} ({state})&quot;)\n            return state, state_name\n        except dbus.exceptions.DBusException:\n            self._debug(&quot;Error reading modem state. Likely disconnected or interface unavailable.&quot;)\n            return STATE_UNKNOWN, &#039;Unknown&#039;\n\n    def _read_network_request(self) -&gt; Optional[str]:\n        &quot;&quot;&quot;Reads the NetworkRequest property (the message from the network).&quot;&quot;&quot;\n        try:\n            request = self.modem_props.Get(MM_IFACE_3GPP_USSD, &#039;NetworkRequest&#039;)\n            self._debug(f&quot;Reading NetworkRequest property: &#039;{request}&#039;&quot;)\n            return request\n        except dbus.exceptions.DBusException:\n            self._debug(&quot;Error reading NetworkRequest property.&quot;)\n            return None\n\n    def _wait_for_stable_state(self, timeout: float = 30.0, target_states: Tuple[int, ...] = (STATE_USER_RESPONSE,)) -&gt; int:\n        &quot;&quot;&quot;\n        Polls until the state settles to one of the target states.\n        \n        Note: STATE_IDLE is now treated like any other non-target state during the poll.\n        If the session terminates and goes to IDLE, the poll will now run for the full timeout.\n        &quot;&quot;&quot;\n        target_names = [s for c, s in {3: &#039;User-Response&#039;}.items() if c in target_states]\n        self._debug(f&quot;Starting state stabilization poll (Timeout: {timeout}s, Target: {target_names})...&quot;)\n        start = time.time()\n        \n        last_known_state = STATE_UNKNOWN\n\n        while (time.time() - start) &lt; timeout:\n            state, state_name = self.get_state()\n            last_known_state = state\n            \n            # The check for immediate IDLE exit has been removed as requested.\n            \n            if state in target_states: \n                self._debug(f&quot;State stabilized to: {state_name} ({state}) in {time.time() - start:.2f}s.&quot;)\n                return state\n            \n            time.sleep(0.1) \n        \n        # If the loop times out, raise an error, but if the last state was IDLE, \n        # return it to signal session closure gracefully.\n        if last_known_state == STATE_IDLE:\n             self._debug(&quot;Timed out, but last observed state was IDLE (Session ended).&quot;)\n             return STATE_IDLE\n        \n        raise TimeoutError(&quot;Timed out waiting for network response.&quot;)\n\n    def initiate_session(self, code: str) -&gt; Tuple[str, int]:\n        &quot;&quot;&quot;\n        Starts a USSD session.\n        Returns the network response string and the final state code.\n        &quot;&quot;&quot;\n        if not self.modem_3gpp_ussd:\n            raise RuntimeError(&quot;Client not connected to a modem.&quot;)\n\n        # Ensure code is a DBus String\n        dbus_code = dbus.String(code)\n        self._debug(f&quot;Calling Initiate() with USSD code: {dbus_code} (DBus type: {type(dbus_code)})&quot;)\n        \n        # NOTE: ModemManager will try to return the response string if it&#039;s immediately available.\n        response = self.modem_3gpp_ussd.Initiate(dbus_code, timeout=60)\n        final_state = STATE_UNKNOWN\n        self._debug(f&quot;Initiate() returned immediately with: &#039;{response}&#039;&quot;)\n\n        if not response:\n            # Mandatory delay before polling begins (reverted to 0.5s)\n            self._debug(&quot;No immediate response. Introducing 0.5s state stabilization delay...&quot;)\n            time.sleep(0.5) \n\n            # Wait for state change and read message from property. Uses default target (STATE_USER_RESPONSE)\n            final_state = self._wait_for_stable_state()\n            response = self._read_network_request()\n        else:\n            # If response is returned directly, get the resulting state immediately\n            final_state, _ = self.get_state()\n            \n        return response if response else &quot;&quot;, final_state\n\n    def respond(self, reply_text: str) -&gt; Tuple[str, int]:\n        &quot;&quot;&quot;\n        Sends a reply to an active menu.\n        Returns the next network response string and the final state code.\n        &quot;&quot;&quot;\n        self._debug(&quot;Starting Respond sequence...&quot;)\n        \n        # 1. Pre-Check for stability: Ensure we are still in USER_RESPONSE state before sending.\n        # We explicitly poll for both IDLE and USER_RESPONSE here to distinguish between\n        # &#039;Ready to respond&#039; (USER_RESPONSE) and &#039;Session terminated&#039; (IDLE).\n        current_state = self._wait_for_stable_state(timeout=5.0, target_states=(STATE_USER_RESPONSE, STATE_IDLE))\n        \n        if current_state == STATE_IDLE:\n            raise BrokenPipeError(&quot;Session unexpectedly closed by network before response could be sent.&quot;)\n        if current_state != STATE_USER_RESPONSE:\n            raise BrokenPipeError(f&quot;Modem not ready for response. State: {current_state}&quot;)\n\n\n        # 2. Execute Response\n        # Ensure reply_text is a DBus String\n        dbus_reply = dbus.String(reply_text)\n        self._debug(f&quot;Calling Respond() with reply: &#039;{dbus_reply}&#039; (DBus type: {type(dbus_reply)})&quot;)\n        \n        # NOTE: ModemManager will try to return the response string if it&#039;s immediately available.\n        response = self.modem_3gpp_ussd.Respond(dbus_reply, timeout=60)\n        final_state = STATE_UNKNOWN\n        self._debug(f&quot;Respond() returned immediately with: &#039;{response}&#039;&quot;)\n        \n        if not response:\n            # Mandatory delay before polling begins (reverted to 0.5s)\n            self._debug(&quot;No immediate response. Introducing 0.5s state stabilization delay...&quot;)\n            time.sleep(0.5)\n            \n            # Wait for state change and read message from property. Uses default target (STATE_USER_RESPONSE)\n            final_state = self._wait_for_stable_state()\n            response = self._read_network_request()\n        else:\n            # If response is returned directly, get the resulting state immediately\n            final_state, _ = self.get_state()\n            \n        return response if response else &quot;&quot;, final_state\n\n    def cancel(self):\n        &quot;&quot;&quot;Cancels any active session.&quot;&quot;&quot;\n        self._debug(&quot;Attempting to cancel active USSD session...&quot;)\n        if self.modem_3gpp_ussd:\n            try:\n                self.modem_3gpp_ussd.Cancel()\n                self._debug(&quot;USSD session cancelled successfully.&quot;)\n            except dbus.exceptions.DBusException as e:\n                # The ModemManager API returns an error if you try to cancel when it&#039;s already idle.\n                # We ignore this expected error, but log it for debugging transparency.\n                self._debug(f&quot;Cancel failed (likely already idle or internal protocol error): {e}&quot;)\n                pass \n\ndef main():\n    client = USSDClient()\n    \n    # 1. Connection Check (Exits on failure)\n    print(&quot;--- Initializing Modem ---&quot;)\n    try:\n        if not client.connect():\n            print(&quot;Error: No modem found that supports USSD.&quot;)\n            sys.exit(1)\n        print(f&quot;Connected to modem: {client.modem_path}&quot;)\n    except Exception as e:\n        print(f&quot;Connection failed: {e}&quot;)\n        sys.exit(1)\n\n    # 2. Main Interactive Session (Handles all exceptions\/interrupts for cleanup)\n    try:\n        # Initial Request\n        code = input(&quot;\\nEnter USSD code (e.g. *123#): &quot;).strip()\n        if not code:\n            return # Exit cleanly\n\n        print(f&quot;Sending &#039;{code}&#039;...&quot;)\n        response, current_state = client.initiate_session(code)\n        \n        print(&quot;\\n&quot; + &quot;=&quot;*40)\n        print(f&quot;NETWORK RESPONSE:\\n{response}&quot;)\n        print(&quot;=&quot;*40 + &quot;\\n&quot;)\n\n        # Interaction Loop\n        while True:\n            # Check the state immediately after the last action\n            if current_state == STATE_IDLE: \n                print(&quot;Session ended.&quot;)\n                break\n            \n            if current_state != STATE_USER_RESPONSE: \n                print(f&quot;Warning: Modem state is {current_state} (not &#039;User-Response&#039;). Exiting loop.&quot;)\n                # No client.cancel() here! Let the finally block handle cleanup.\n                break\n                \n            # State is STATE_USER_RESPONSE (3), prompt for reply\n            user_input = input(&quot;Reply (or &#039;q&#039; to quit): &quot;).strip()\n            \n            if user_input.lower() in [&#039;q&#039;, &#039;quit&#039;, &#039;exit&#039;]:\n                # FIXED: Removed explicit client.cancel() here.\n                print(&quot;Cancelled.&quot;)\n                break\n                \n            if not user_input:\n                continue\n\n            print(&quot;Sending reply...&quot;)\n            try:\n                # Respond and get the new response and resulting state\n                response, current_state = client.respond(user_input)\n                print(&quot;\\n&quot; + &quot;=&quot;*40)\n                print(f&quot;NETWORK RESPONSE:\\n{response}&quot;)\n                print(&quot;=&quot;*40 + &quot;\\n&quot;)\n            except BrokenPipeError as e:\n                print(f&quot;Error: {e}&quot;)\n                current_state = STATE_IDLE # Force break\n                continue # Go to top of loop to hit the break condition\n            except TimeoutError as e:\n                print(f&quot;Error: {e}&quot;)\n                # The _wait_for_stable_state is designed to return STATE_IDLE on timeout if \n                # that was the last observed state, which handles termination gracefully.\n                current_state = STATE_IDLE \n                continue # Go to top of loop to hit the break condition\n\n    except KeyboardInterrupt:\n        print(&quot;\\nInterrupted by user.&quot;)\n    except Exception as e:\n        # Catches DBus errors and others\n        print(f&quot;\\nFatal Error: {e}&quot;)\n    finally:\n        # 3. Cleanup: This is the single, guaranteed place to call cancel()\n        client.cancel()\n\nif __name__ == &quot;__main__&quot;:\n    main()<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>Nice to have cell signal strength metrics also.<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/usr\/local\/bin\/modem-to-vmetrics.sh<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">#!\/bin\/bash\n# ModemManager to Victoria Metrics polling script\n# Polls mmcli every 5 seconds and sends metrics to Victoria Metrics\n\nVMETRICS_URL=&quot;http:\/\/localhost:8428\/api\/v1\/import\/prometheus&quot;\nINTERVAL=5\n\n# Find first available modem\nget_modem_id() {\n    mmcli -L 2&gt;\/dev\/null | grep -oP &#039;\/Modem\/\\K\\d+&#039; | head -1\n}\n\n# Wait for ModemManager to be ready and modem to be available\necho &quot;Waiting for modem to be available...&quot;\nwhile true; do\n    MODEM_ID=$(get_modem_id)\n    if [ -n &quot;$MODEM_ID&quot; ]; then\n        echo &quot;Found modem at index: $MODEM_ID&quot;\n        break\n    fi\n    sleep 2\ndone\n\n# Main polling loop\necho &quot;Starting modem signal monitoring (every ${INTERVAL}s)...&quot;\nwhile true; do\n    # Get all modem info at once\n    MODEM_INFO=$(mmcli -m &quot;$MODEM_ID&quot; --output-keyvalue 2&gt;\/dev\/null)\n    \n    # Get signal quality\n    SIGNAL=$(echo &quot;$MODEM_INFO&quot; | \\\n             grep &#039;modem.generic.signal-quality.value&#039; | \\\n             cut -d: -f2 | \\\n             tr -d &#039; &#039;)\n    \n    # Get access technology\n    TECH=$(echo &quot;$MODEM_INFO&quot; | \\\n           grep &#039;modem.generic.access-technologies.value\\[1\\]&#039; | \\\n           cut -d: -f2 | \\\n           tr -d &#039; &#039;)\n    \n    if [ -n &quot;$SIGNAL&quot; ]; then\n        # Prepare metrics\n        METRICS=&quot;modem_signal_quality{modem_id=\\&quot;$MODEM_ID\\&quot;} $SIGNAL&quot;\n        \n        # Add technology as a separate metric with technology label\n        if [ -n &quot;$TECH&quot; ]; then\n            METRICS=&quot;$METRICS\nmodem_access_technology{modem_id=\\&quot;$MODEM_ID\\&quot;,technology=\\&quot;$TECH\\&quot;} 1&quot;\n            TECH_DISPLAY=&quot; ($TECH)&quot;\n        else\n            TECH_DISPLAY=&quot;&quot;\n        fi\n        \n        # Send to Victoria Metrics in Prometheus format\n        echo &quot;$METRICS&quot; | curl -s -X POST &quot;$VMETRICS_URL&quot; --data-binary @- &gt; \/dev\/null 2&gt;&amp;1\n        \n        echo &quot;$(date &#039;+%Y-%m-%d %H:%M:%S&#039;) - Modem $MODEM_ID: Signal quality = $SIGNAL%$TECH_DISPLAY&quot;\n    else\n        echo &quot;$(date &#039;+%Y-%m-%d %H:%M:%S&#039;) - Warning: Could not read signal quality&quot;\n    fi\n    \n    sleep &quot;$INTERVAL&quot;\ndone<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo chmod +x \/usr\/local\/bin\/modem-to-vmetrics.sh<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/systemd\/system\/modem-vmetrics.service<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-\">[Unit]\nDescription=ModemManager to Victoria Metrics Exporter\nAfter=ModemManager.service network.target\nRequires=ModemManager.service\nStartLimitIntervalSec=0\n\n[Service]\nType=simple\nExecStart=\/usr\/local\/bin\/modem-to-vmetrics.sh\nRestart=always\nRestartSec=10\nStandardOutput=journal\nStandardError=journal\n\n[Install]\nWantedBy=multi-user.target<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl daemon-reload<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl enable modem-vmetrics.service<\/code><\/mark><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>A script to have the stick perform certain actions when a SMS is received. Four actions at the moment: lte up, lte down, reboot, and add a wifi network via &#8220;wifi &lt;ssid&gt; &lt;password&gt;&#8221; instead of having to log in and add via nmtui.<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano sms_lte_control.py<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-python\">#!\/usr\/bin\/env python3\n&quot;&quot;&quot;\nSMS-based LTE Connection Controller\nPolls GSM modem for SMS messages and controls NetworkManager LTE connection\n&quot;&quot;&quot;\n\nimport subprocess\nimport time\nimport re\nimport logging\nfrom datetime import datetime\n\n# Configure logging\nlogging.basicConfig(\n    level=logging.INFO,\n    format=&#039;%(asctime)s - %(levelname)s - %(message)s&#039;\n)\nlogger = logging.getLogger(__name__)\n\n# Configuration\nPOLL_INTERVAL = 60  # seconds\nLTE_CONNECTION_NAME = &quot;lte&quot;\n\n\ndef run_command(cmd, check=True):\n    &quot;&quot;&quot;Execute a shell command and return output&quot;&quot;&quot;\n    try:\n        result = subprocess.run(\n            cmd,\n            shell=True,\n            capture_output=True,\n            text=True,\n            check=check\n        )\n        return result.stdout.strip(), result.stderr.strip(), result.returncode\n    except subprocess.CalledProcessError as e:\n        logger.error(f&quot;Command failed: {cmd}&quot;)\n        logger.error(f&quot;Error: {e.stderr}&quot;)\n        return None, e.stderr, e.returncode\n\n\ndef get_modem_path():\n    &quot;&quot;&quot;Get the ModemManager modem path&quot;&quot;&quot;\n    output, stderr, rc = run_command(&quot;mmcli -L&quot;)\n    if rc != 0 or not output:\n        return None\n    \n    # Extract modem path (e.g., \/org\/freedesktop\/ModemManager1\/Modem\/0)\n    match = re.search(r&#039;\/org\/freedesktop\/ModemManager1\/Modem\/\\d+&#039;, output)\n    if match:\n        return match.group(0)\n    return None\n\n\ndef list_sms_messages(modem_path):\n    &quot;&quot;&quot;List all SMS messages on the modem&quot;&quot;&quot;\n    output, stderr, rc = run_command(f&quot;mmcli -m {modem_path} --messaging-list-sms&quot;)\n    if rc != 0 or not output:\n        return []\n    \n    # Extract SMS paths\n    sms_paths = re.findall(r&#039;\/org\/freedesktop\/ModemManager1\/SMS\/\\d+&#039;, output)\n    return sms_paths\n\n\ndef get_sms_info(sms_path):\n    &quot;&quot;&quot;Get SMS message info including PDU type and text&quot;&quot;&quot;\n    output, stderr, rc = run_command(f&quot;mmcli -s {sms_path} --output-keyvalue&quot;)\n    if rc != 0 or not output:\n        return None, None\n    \n    # Parse key-value output\n    pdu_type = None\n    text = None\n    \n    for line in output.split(&#039;\\n&#039;):\n        if line.startswith(&#039;sms.properties.pdu-type&#039;):\n            pdu_type = line.split(&#039;:&#039;, 1)[1].strip()\n        elif line.startswith(&#039;sms.content.text&#039;):\n            text = line.split(&#039;:&#039;, 1)[1].strip()\n    \n    logger.debug(f&quot;Extracted PDU type: {pdu_type}, Text: {text}&quot;)\n    \n    return pdu_type, text\n\n\ndef delete_sms(modem_path, sms_path):\n    &quot;&quot;&quot;Delete an SMS message - try twice as ModemManager sometimes needs this&quot;&quot;&quot;\n    # Extract SMS index from path (e.g., \/org\/freedesktop\/ModemManager1\/SMS\/1 -&gt; 1)\n    sms_index = sms_path.split(&#039;\/&#039;)[-1]\n    cmd = f&quot;sudo mmcli -m {modem_path} --messaging-delete-sms={sms_index}&quot;\n    \n    # First attempt\n    output, stderr, rc = run_command(cmd, check=False)\n    if rc == 0:\n        logger.info(f&quot;Deleted SMS: {sms_path} (index: {sms_index})&quot;)\n        return\n    \n    logger.debug(f&quot;First delete attempt failed: {stderr}&quot;)\n    \n    # Wait 1 second before second attempt\n    logger.debug(f&quot;Waiting 1 second before retry...&quot;)\n    time.sleep(1)\n    \n    # Second attempt\n    output, stderr, rc = run_command(cmd, check=False)\n    if rc == 0:\n        logger.info(f&quot;Deleted SMS on second attempt: {sms_path} (index: {sms_index})&quot;)\n    else:\n        logger.warning(f&quot;Failed to delete SMS after two attempts: {sms_path} (index: {sms_index})&quot;)\n        logger.warning(f&quot;Error: {stderr}&quot;)\n\n\ndef control_lte_connection(action):\n    &quot;&quot;&quot;Control LTE connection via NetworkManager&quot;&quot;&quot;\n    if action not in [&quot;up&quot;, &quot;down&quot;]:\n        logger.error(f&quot;Invalid action: {action}&quot;)\n        return False\n    \n    cmd = f&quot;sudo nmcli con {action} {LTE_CONNECTION_NAME}&quot;\n    logger.info(f&quot;Executing: {cmd}&quot;)\n    output, stderr, rc = run_command(cmd, check=False)\n    \n    if rc == 0:\n        logger.info(f&quot;LTE connection {action} successful&quot;)\n        return True\n    else:\n        logger.error(f&quot;Failed to {action} LTE connection&quot;)\n        return False\n\n\ndef reboot_system():\n    &quot;&quot;&quot;Reboot the system&quot;&quot;&quot;\n    logger.warning(&quot;REBOOT command received - system will reboot in 5 seconds&quot;)\n    time.sleep(5)  # Give time for SMS deletion and logging\n    run_command(&quot;sudo reboot&quot;, check=False)\n\n\ndef add_wifi_profile(ssid, password):\n    &quot;&quot;&quot;Add a WiFi profile to NetworkManager&quot;&quot;&quot;\n    logger.info(f&quot;Adding WiFi profile for SSID: {ssid}&quot;)\n    \n    # Create WiFi connection with NetworkManager\n    cmd = f&quot;sudo nmcli dev wifi connect &#039;{ssid}&#039; password &#039;{password}&#039;&quot;\n    output, stderr, rc = run_command(cmd, check=False)\n    \n    if rc == 0:\n        logger.info(f&quot;Successfully added WiFi profile: {ssid}&quot;)\n        return True\n    else:\n        logger.error(f&quot;Failed to add WiFi profile: {ssid}&quot;)\n        if stderr:\n            logger.error(f&quot;Error: {stderr}&quot;)\n        return False\n\n\ndef process_sms_messages(modem_path):\n    &quot;&quot;&quot;Check for and process SMS messages&quot;&quot;&quot;\n    sms_list = list_sms_messages(modem_path)\n    \n    if not sms_list:\n        logger.debug(&quot;No SMS messages found&quot;)\n        return\n    \n    logger.info(f&quot;Found {len(sms_list)} SMS message(s)&quot;)\n    \n    for sms_path in sms_list:\n        pdu_type, text = get_sms_info(sms_path)\n        \n        if not pdu_type:\n            logger.warning(f&quot;Could not determine PDU type for {sms_path}&quot;)\n            continue\n        \n        logger.debug(f&quot;SMS PDU type: {pdu_type}&quot;)\n        \n        # Only process &#039;deliver&#039; type messages (received messages)\n        if pdu_type.lower() != &quot;deliver&quot;:\n            logger.info(f&quot;Skipping non-deliver SMS (type: {pdu_type})&quot;)\n            continue\n        \n        if text:\n            logger.info(f&quot;SMS content: {text}&quot;)\n            text_lower = text.lower()\n            \n            if &quot;lte up&quot; in text_lower:\n                logger.info(&quot;Command detected: LTE UP&quot;)\n                control_lte_connection(&quot;up&quot;)\n            elif &quot;lte down&quot; in text_lower:\n                logger.info(&quot;Command detected: LTE DOWN&quot;)\n                control_lte_connection(&quot;down&quot;)\n            elif &quot;reboot&quot; in text_lower:\n                logger.info(&quot;Command detected: REBOOT&quot;)\n                # Delete message before rebooting\n                delete_sms(modem_path, sms_path)\n                reboot_system()\n                return  # Won&#039;t reach here, but for clarity\n            elif text_lower.startswith(&quot;wifi &quot;):\n                logger.info(&quot;Command detected: WIFI&quot;)\n                # Parse: wifi &lt;ssid&gt; &lt;password&gt;\n                parts = text.split(None, 2)  # Split on whitespace, max 3 parts\n                if len(parts) == 3:\n                    _, ssid, password = parts\n                    add_wifi_profile(ssid, password)\n                else:\n                    logger.warning(&quot;Invalid wifi command format. Expected: wifi &lt;ssid&gt; &lt;password&gt;&quot;)\n            else:\n                logger.info(&quot;No valid command in SMS&quot;)\n        \n        # Delete the processed message\n        delete_sms(modem_path, sms_path)\n\n\ndef main():\n    &quot;&quot;&quot;Main loop&quot;&quot;&quot;\n    logger.info(&quot;SMS-based LTE Controller started&quot;)\n    logger.info(f&quot;Polling interval: {POLL_INTERVAL} seconds&quot;)\n    logger.info(f&quot;LTE connection name: {LTE_CONNECTION_NAME}&quot;)\n    \n    while True:\n        try:\n            # Get modem path\n            modem_path = get_modem_path()\n            \n            if not modem_path:\n                logger.warning(&quot;No modem found, retrying...&quot;)\n                time.sleep(POLL_INTERVAL)\n                continue\n            \n            logger.debug(f&quot;Modem path: {modem_path}&quot;)\n            \n            # Process SMS messages\n            process_sms_messages(modem_path)\n            \n        except KeyboardInterrupt:\n            logger.info(&quot;Shutting down...&quot;)\n            break\n        except Exception as e:\n            logger.error(f&quot;Unexpected error: {e}&quot;)\n        \n        # Wait before next poll\n        time.sleep(POLL_INTERVAL)\n\n\nif __name__ == &quot;__main__&quot;:\n    main()<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo chmod +x \/home\/user\/sms_lte_control.py<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/systemd\/system\/sms-lte-control.service<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-\">[Unit]\nDescription=SMS-based LTE Connection Controller\nAfter=network.target ModemManager.service NetworkManager.service\nWants=ModemManager.service NetworkManager.service\n\n[Service]\nType=simple\nUser=user\nExecStart=\/usr\/bin\/python3 \/home\/user\/sms_lte_control.py\nRestart=always\nRestartSec=10\nStandardOutput=journal\nStandardError=journal\n\n[Install]\nWantedBy=multi-user.target<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl daemon-reload<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl enable sms-lte-control<\/code><\/mark><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>Need to re-program the LEDs on the stick to indicate system power, wlan connection, and wan connection. Kernel doesn&#8217;t have any triggers for wan, so small bash script can do what we want. Green LED flashes slowly on 4G network search, quickly on obtaining an IP address, and solid when internet connectivity. Blue LED flashes when advertising a hotspot, and solid when connected to a wifi network as a client. Red LED is solid on boot (kernel default) and changes to heartbeat once rc.local loads.<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo apt install iw<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/usr\/local\/bin\/led-monitor.sh<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">#!\/bin\/bash\n\nWAN_LED_PATH=&quot;\/sys\/devices\/platform\/leds\/leds\/blue:wan\/brightness&quot;\nWLAN_LED_PATH=&quot;\/sys\/devices\/platform\/leds\/leds\/green:wlan\/brightness&quot;\nWAN_INTERFACE=&quot;wwan0&quot;\nWLAN_INTERFACE=&quot;wlan0&quot;\n\n# Counters for different check intervals\nwan_check_counter=0\nwan_ping_counter=0\nwlan_check_counter=0\nwlan_blink_counter=0\nwan_slow_blink_counter=0\n\n# Cached states\nwan_state=&quot;&quot;\nwlan_state=&quot;&quot;\nhas_internet=0\nping_in_progress=0\n\necho &quot;LED Monitor started&quot;\necho &quot;WAN LED Path: $WAN_LED_PATH&quot;\necho &quot;WLAN LED Path: $WLAN_LED_PATH&quot;\n\n# Ping result file in \/run (always tmpfs\/RAM)\nPING_RESULT=&quot;\/run\/led_ping_result&quot;\n\nwhile true; do\n    # === WAN LED Logic ===\n    \n    # Start ping check every 20 iterations (5 seconds) if not already in progress\n    if [ $wan_ping_counter -eq 0 ] &amp;&amp; [ $ping_in_progress -eq 0 ]; then\n        echo &quot;[WAN] Starting background ping to 8.8.8.8...&quot;\n        ping_in_progress=1\n        (\n            if ping -c 1 -W 1 8.8.8.8 &amp;&gt;\/dev\/null; then\n                echo &quot;1&quot; &gt; &quot;$PING_RESULT&quot;\n            else\n                echo &quot;0&quot; &gt; &quot;$PING_RESULT&quot;\n            fi\n        ) &amp;\n        wan_ping_counter=20  # Next ping in 5 seconds\n    fi\n    \n    # Check if background ping completed\n    if [ $ping_in_progress -eq 1 ] &amp;&amp; [ -f &quot;$PING_RESULT&quot; ]; then\n        has_internet=$(cat &quot;$PING_RESULT&quot;)\n        rm -f &quot;$PING_RESULT&quot;\n        ping_in_progress=0\n        if [ $has_internet -eq 1 ]; then\n            echo &quot;[WAN] Ping successful - has internet&quot;\n        else\n            echo &quot;[WAN] Ping failed - no internet&quot;\n        fi\n    fi\n    \n    # Check interface state every 4 iterations (1 second)\n    if [ $wan_check_counter -eq 0 ]; then\n        if [ $has_internet -eq 1 ]; then\n            wan_state=&quot;solid&quot;\n            echo &quot;[WAN] State: SOLID (internet connected)&quot;\n        elif ip addr show &quot;$WAN_INTERFACE&quot; 2&gt;\/dev\/null | grep -q &quot;inet &quot;; then\n            wan_state=&quot;fast_blink&quot;\n            echo &quot;[WAN] State: FAST_BLINK (has IP, no internet)&quot;\n        elif ip link show &quot;$WAN_INTERFACE&quot; 2&gt;\/dev\/null | grep -q &quot;UP&quot;; then\n            wan_state=&quot;slow_blink&quot;\n            echo &quot;[WAN] State: SLOW_BLINK (modem connecting)&quot;\n        else\n            wan_state=&quot;off&quot;\n            echo &quot;[WAN] State: OFF (no modem detected)&quot;\n        fi\n        wan_check_counter=4  # Check interface every 1 second\n    fi\n    \n    # Handle WAN LED based on state\n    case &quot;$wan_state&quot; in\n        solid)\n            echo 1 &gt; &quot;$WAN_LED_PATH&quot;\n            ;;\n        fast_blink)\n            # Toggle every 250ms (every iteration)\n            current_wan=$(cat $WAN_LED_PATH)\n            if [ &quot;$current_wan&quot; = &quot;1&quot; ]; then\n                echo 0 &gt; &quot;$WAN_LED_PATH&quot;\n            else\n                echo 1 &gt; &quot;$WAN_LED_PATH&quot;\n            fi\n            ;;\n        slow_blink)\n            # Toggle every 750ms (every 3 iterations at 250ms = 750ms)\n            wan_slow_blink_counter=$((wan_slow_blink_counter + 1))\n            if [ $wan_slow_blink_counter -ge 3 ]; then\n                current_wan=$(cat $WAN_LED_PATH)\n                if [ &quot;$current_wan&quot; = &quot;1&quot; ]; then\n                    echo 0 &gt; &quot;$WAN_LED_PATH&quot;\n                else\n                    echo 1 &gt; &quot;$WAN_LED_PATH&quot;\n                fi\n                wan_slow_blink_counter=0\n            fi\n            ;;\n        off)\n            echo 0 &gt; &quot;$WAN_LED_PATH&quot;\n            ;;\n    esac\n    \n    # === WLAN LED Logic ===\n    # Check WLAN state every 4 iterations (1 second)\n    if [ $wlan_check_counter -eq 0 ]; then\n        if iw dev &quot;$WLAN_INTERFACE&quot; info 2&gt;\/dev\/null | grep -q &quot;type AP&quot;; then\n            wlan_state=&quot;blink&quot;\n            echo &quot;[WLAN] State: BLINK (AP\/hotspot mode)&quot;\n        elif ip link show &quot;$WLAN_INTERFACE&quot; 2&gt;\/dev\/null | grep -q &quot;UP,LOWER_UP&quot; &amp;&amp; \\\n             ip addr show &quot;$WLAN_INTERFACE&quot; 2&gt;\/dev\/null | grep -q &quot;inet &quot;; then\n            wlan_state=&quot;solid&quot;\n            echo &quot;[WLAN] State: SOLID (client connected)&quot;\n        else\n            wlan_state=&quot;off&quot;\n            echo &quot;[WLAN] State: OFF (not connected)&quot;\n        fi\n        wlan_check_counter=4  # Check every 1 second (4 x 250ms)\n    fi\n    \n    # Handle WLAN LED based on state\n    case &quot;$wlan_state&quot; in\n        blink)\n            # Toggle every 1 second (every 4 iterations at 250ms = 1 second)\n            wlan_blink_counter=$((wlan_blink_counter + 1))\n            if [ $wlan_blink_counter -ge 4 ]; then\n                current_wlan=$(cat $WLAN_LED_PATH)\n                if [ &quot;$current_wlan&quot; = &quot;1&quot; ]; then\n                    echo 0 &gt; &quot;$WLAN_LED_PATH&quot;\n                else\n                    echo 1 &gt; &quot;$WLAN_LED_PATH&quot;\n                fi\n                wlan_blink_counter=0\n            fi\n            ;;\n        solid)\n            echo 1 &gt; &quot;$WLAN_LED_PATH&quot;\n            wlan_blink_counter=0\n            ;;\n        off)\n            echo 0 &gt; &quot;$WLAN_LED_PATH&quot;\n            wlan_blink_counter=0\n            ;;\n    esac\n    \n    # Decrement counters\n    [ $wan_check_counter -gt 0 ] &amp;&amp; ((wan_check_counter--))\n    [ $wan_ping_counter -gt 0 ] &amp;&amp; ((wan_ping_counter--))\n    [ $wlan_check_counter -gt 0 ] &amp;&amp; ((wlan_check_counter--))\n    \n    # Sleep 250ms (base interval)\n    sleep 0.25\ndone<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo chmod +x \/usr\/local\/bin\/led-monitor.sh<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/systemd\/system\/led-monitor.service<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-bash\">[Unit]\nDescription=WAN and WLAN LED Monitor\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=\/usr\/local\/bin\/led-monitor.sh\nRestart=always\n\n[Install]\nWantedBy=multi-user.target<\/code><\/pre>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl daemon-reload<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo systemctl enable led-monitor<\/code><\/mark><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>ModemManager on Debian 12 is pretty old and has bugs that are fixed in later versions. <a href=\"http:\/\/blog.ltzs.us\/wp-content\/uploads\/2026\/03\/modem_stack_msm8916.tgz\" data-type=\"link\" data-id=\"http:\/\/blog.ltzs.us\/wp-content\/uploads\/2026\/03\/modem_stack_msm8916.tgz\">This<\/a> is a zipped version of a recompiled version of ModemManager 1.24.2 (vs 1.20) and the associated dependencies. In my testing it&#8217;s more stable. To install, download the file onto the stick and do the following command: <\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo tar -xvzf modem_stack_msm8916.tgz -C \/ --keep-directory-symlink<\/code><\/mark><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>Quick guide to using an existing binary on a new stick:<\/p>\n\n\n\n<p>High-level steps:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Pull full image binary for recovery purposes\n<ul class=\"wp-block-list\">\n<li>See above<\/li>\n<\/ul>\n<\/li>\n\n\n\n<li>Pull partition binaries and put them back in after flashing\n<ul class=\"wp-block-list\">\n<li>get into fastboot mode by holding down the button and plugging in<\/li>\n\n\n\n<li><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>fastboot oem reboot-edl<\/code><\/mark><\/li>\n\n\n\n<li><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>for n in fsc fsg modem modemst1 modemst2 persist sec; do edl r ${n} ${n}.bin done<\/code><\/mark><\/li>\n\n\n\n<li>flash the full binary of the good image<\/li>\n\n\n\n<li>get back into fastboot mode<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-\">for n in fsc fsg modem modemst1 modemst2 persist sec; do\n    fastboot flash ${n} ${n}.bin\ndone<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>fastboot reboot<\/code><\/mark><\/li>\n\n\n\n<li>Change hostname\n<ul class=\"wp-block-list\">\n<li><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo hostnamectl set-hostname stick8<\/code><\/mark><\/li>\n\n\n\n<li><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/hosts<\/code><\/mark><\/li>\n\n\n\n<li>Change 127.0.1.1 line to new hostname<\/li>\n<\/ul>\n<\/li>\n\n\n\n<li>Delete and re-create ssh keys for user and root\n<ul class=\"wp-block-list\">\n<li><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo rm ~\/.ssh\/id_rsa*<\/code><\/mark><\/li>\n\n\n\n<li><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>ssh-keygen<\/code><\/mark><\/li>\n\n\n\n<li><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo su<\/code><\/mark><\/li>\n\n\n\n<li><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>rm ~\/.ssh\/id_rsa*<\/code><\/mark><\/li>\n\n\n\n<li><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>ssh-keygen<\/code><\/mark><\/li>\n<\/ul>\n<\/li>\n\n\n\n<li>Copy ssh keys to VPS\n<ul class=\"wp-block-list\">\n<li><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>scp ~\/.ssh\/id_rsa.pub pi@192.168.100.98:~\/Documents\/root_stick21_id_rsa.pub<\/code><\/mark><\/li>\n\n\n\n<li><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>exit<\/code><\/mark><\/li>\n\n\n\n<li><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>scp ~\/.ssh\/id_rsa.pub pi@192.168.100.98:~\/Documents\/user_stick21_id_rsa.pub<\/code><\/mark><\/li>\n<\/ul>\n<\/li>\n\n\n\n<li>Delete, recreate and copy OVPN profile\n<ul class=\"wp-block-list\">\n<li><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo rm ~\/ovpns\/stick5.ovpn<\/code><\/mark><\/li>\n\n\n\n<li><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>pivpn add<\/code><\/mark><\/li>\n\n\n\n<li><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>scp ~\/ovpns\/stick21.ovpn pi@192.168.100.98:~\/Documents\/<\/code><\/mark><\/li>\n<\/ul>\n<\/li>\n\n\n\n<li>Adjust port in ssh-reverse.service\n<ul class=\"wp-block-list\">\n<li><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo nano \/etc\/systemd\/system\/ssh-reverse.service<\/code><\/mark><\/li>\n<\/ul>\n<\/li>\n<\/ul>\n\n\n\n<p>Misc commands:<\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>scp \/home\/pi\/Documents\/stick13_files\/root\/* user@192.168.100.1:~\/sshcopy<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>scp \/home\/pi\/Documents\/stick13_files\/user\/* user@192.168.100.1:~\/.ssh<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>ssh user@192.168.100.1<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>sudo su<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>mv \/home\/user\/sshcopy\/* ~\/.ssh<\/code><\/mark><\/p>\n\n\n\n<p><mark style=\"background-color:#fcb900\" class=\"has-inline-color\"><code>chown root:root \/root\/.ssh\/*<\/code><\/mark><\/p>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-\">for n in fsc fsg modem modemst1 modemst2 persist sec; do\n    edl r ${n} ${n}.bin\ndone<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-\">for n in fsc fsg modem modemst1 modemst2 persist sec; do\n    edl w ${n} ${n}.bin\ndone<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-prismatic-blocks\"><code class=\"language-\">for n in fsc fsg modem modemst1 modemst2 persist sec; do\n    fastboot flash ${n} ${n}.bin\ndone<\/code><\/pre>\n","protected":false},"excerpt":{"rendered":"<p>Plug in stick, log in. http:\/\/192.168.100.1\/usbdebug.html Reboot (unplug, replug) adb shell setprop service.adb.root 1; busybox killall adbdadb reboot edledl rf uz801-stock.bin Then download individual partitions. edl rl uz801_stock &#8211;genxml Reboot (unplug, replug) adb reboot bootloader cd OpenStick\/flash\/.\/flash.sh sudo mount -o loop ~\/firmwares\/uz801_stock\/modem.bin \/mnt\/test to mount the modem.bin file then copy all the files over to&#8230;<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[],"class_list":["post-106","post","type-post","status-publish","format-standard","hentry","category-uncategorized"],"_links":{"self":[{"href":"http:\/\/blog.ltzs.us\/index.php?rest_route=\/wp\/v2\/posts\/106","targetHints":{"allow":["GET"]}}],"collection":[{"href":"http:\/\/blog.ltzs.us\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"http:\/\/blog.ltzs.us\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"http:\/\/blog.ltzs.us\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"http:\/\/blog.ltzs.us\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=106"}],"version-history":[{"count":102,"href":"http:\/\/blog.ltzs.us\/index.php?rest_route=\/wp\/v2\/posts\/106\/revisions"}],"predecessor-version":[{"id":460,"href":"http:\/\/blog.ltzs.us\/index.php?rest_route=\/wp\/v2\/posts\/106\/revisions\/460"}],"wp:attachment":[{"href":"http:\/\/blog.ltzs.us\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=106"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"http:\/\/blog.ltzs.us\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=106"},{"taxonomy":"post_tag","embeddable":true,"href":"http:\/\/blog.ltzs.us\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=106"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}