From 093cfd721b9b5ef194d204f232d1b41994e55353 Mon Sep 17 00:00:00 2001 From: Jeremy Maddox Date: Mon, 10 Aug 2026 23:00:26 -0400 Subject: [PATCH] Add vl_link_status package --- Makefile | 2 +- res_all.qrc | 1 + vl_link_status/LICENSE | 17 + vl_link_status/Makefile | 21 + vl_link_status/README-in.md | 265 +++++ vl_link_status/README.md | 270 +++++ vl_link_status/code.lbm | 2096 +++++++++++++++++++++++++++++++++++ vl_link_status/pkgdesc.qml | 31 + vl_link_status/ui.qml | 1372 +++++++++++++++++++++++ vl_link_status/version | 1 + 10 files changed, 4075 insertions(+), 1 deletion(-) create mode 100644 vl_link_status/LICENSE create mode 100644 vl_link_status/Makefile create mode 100644 vl_link_status/README-in.md create mode 100644 vl_link_status/README.md create mode 100644 vl_link_status/code.lbm create mode 100644 vl_link_status/pkgdesc.qml create mode 100644 vl_link_status/ui.qml create mode 100644 vl_link_status/version diff --git a/Makefile b/Makefile index af9a9a2d..8e48f68a 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ PKGS += lib_ws2812 logui lib_code_server lib_midi lib_disp_ui PKGS += vdisp lib_tca9535 vbms_harmony32 vbms_harmony16 PKGS += dash35b vl_bike_39p lib_bq27441 boosted_doctor dash16 PKGS += lib_tca9534 UnleashedCreativityLights wheelie_limiter -PKGS += mt6701_config dash_esc vesc_scooter_support lib_esp_led_strip +PKGS += mt6701_config dash_esc vesc_scooter_support lib_esp_led_strip vl_link_status TEST_PKGS = blacktip_dpv diff --git a/res_all.qrc b/res_all.qrc index 096197be..2538e3d2 100644 --- a/res_all.qrc +++ b/res_all.qrc @@ -35,6 +35,7 @@ dash_esc/dash_esc.vescpkg vesc_scooter_support/vesc_scooter_support.vescpkg lib_esp_led_strip/esp_led_strip.vescpkg + vl_link_status/vl_link_status.vescpkg diff --git a/vl_link_status/LICENSE b/vl_link_status/LICENSE new file mode 100644 index 00000000..ae60d0a6 --- /dev/null +++ b/vl_link_status/LICENSE @@ -0,0 +1,17 @@ +Copyright 2026 Jeremy Maddox + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +The full text of the GNU General Public License version 3 is available at +https://www.gnu.org/licenses/gpl-3.0.txt diff --git a/vl_link_status/Makefile b/vl_link_status/Makefile new file mode 100644 index 00000000..3659c750 --- /dev/null +++ b/vl_link_status/Makefile @@ -0,0 +1,21 @@ +VESC_TOOL ?= vesc_tool + +all: vl_link_status.vescpkg + +vl_link_status.vescpkg: pkgdesc.qml README.md ui.qml code.lbm + $(VESC_TOOL) --buildPkgFromDesc pkgdesc.qml --testPkgDesc 'custom:vl link' + +VERSION=`cat version` + +README.md: README-in.md version + cp $< $@ + echo "" >> $@ + echo "### Build Info" >> $@ + echo "- Version: ${VERSION}" >> $@ + echo "- Build Date: `date '+%Y-%m-%d %H:%M:%S %z'`" >> $@ + echo "- Git Commit: #`git rev-parse --short HEAD 2>/dev/null || echo local`" >> $@ + +clean: + rm -f vl_link_status.vescpkg README.md + +.PHONY: all clean diff --git a/vl_link_status/README-in.md b/vl_link_status/README-in.md new file mode 100644 index 00000000..955af7bf --- /dev/null +++ b/vl_link_status/README-in.md @@ -0,0 +1,265 @@ +# VL Link Status + +Bring-up and status package for the **VL Link** board (ESP32-C3 + SIMCom SIM7070G). + +## What it does + +- **Enables the CAN transceiver.** `hw_init()` for this board is empty, so GPIO 6 + is left in standby at boot. Without a package driving it low, a CAN scan finds + only the local device and reports no bus errors to explain why. This package + drives it low on every start. +- **Shows link state on the two onboard LEDs.** One LED per subsystem: + + | LED | Colour | Meaning | + |---|---|---| + | 1 (CAN) | rainbow chase | no node on the bus | + | 1 (CAN) | solid green | node found | + | 2 (LTE) | blue breathe | modem booting or not responding | + | 2 (LTE) | solid red | modem up, no SIM | + | 2 (LTE) | amber breathe | SIM ok, searching for network | + | 2 (LTE) | **flashing green** | registered on the network | + + Registered flashes rather than sitting solid, so **solid green + flashing + green** reads at a glance as "both links up". Two solid greens would not. +- **Reports LTE state** — carrier name, modem power, SIM, network attach and a + 5-bar signal meter — in the VESC Tool panel. + + Verified against live hardware on T-Mobile: LTE Cat-M1, band 12, `+CSQ: 31` + (-51 dBm), network-provided APN `mobilenet`. The SIM7070G is Cat-M1 / NB-IoT + / GSM only — it has no plain LTE — so a SIM whose carrier has not provisioned + Cat-M will sit unattached indefinitely with no other symptom. +- **Publishes telemetry to an MQTT broker** over the LTE data connection, + when enabled. See below. +- **Reports pack voltage** of the first live CAN node. Shown only when a fresh + status frame 5 has arrived, so a node found by ping alone reads blank rather + than a misleading 0.0 V. + +- **Logs to the SD card** using the firmware's own log subsystem + (`log-config-field` / `log-start` / `log-send-f32`), so the output is VESC's + native format in `/sdcard/log_can/` and opens directly in VESC Tool's log + analysis page. 10 Hz, matching the stock vesc_pkg logger. +- **Exports on mobile.** The Logs tab lists what is on the card, pulls a file + over the link, and hands it to the log analysis view. Mobile VESC Tool has no + file browser, so without this there is no way off the device. + +## Logging notes + +The `can_id` argument to `log-start` decides where log packets go +(`log_comm.c:91`): `0`-`254` sends over CAN to that device, `-1` sends to the +connected VESC Tool, and anything else is processed locally. This package uses +`-2` so the Link writes to its own card. + +Fields are built per CAN node from `can-fields`, plus two LTE columns. Only +single-argument `canget-*` getters are used — note there is no `canget-ah` or +`canget-wh`, so amp/watt hours cannot be logged from a remote node. + +Logging needs nodes that broadcast status frames. Without them the field list +comes back empty and the getters would only ever return stale zeros, so +`log-begin` refuses to start rather than recording a file full of nothing. + +There is no shutdown hook: Express has no `event-shutdown` (it is ESC-only, and +`event-enable` rejects it). Stop logging from the UI before cutting power, or +the tail of the file may be lost. + +## Cloud telemetry (MQTT) + +Optional, off by default. Brings up a packet data context, connects to a +broker and publishes a JSON object on an interval. Configured in Settings; +persists in EEPROM. + +Defaults target ThingsBoard (`mqtt.thingsboard.cloud:1883`, topic +`v1/devices/me/telemetry`, access token in the username field, password +blank), but host, port, credentials, client id and topic are all fields, so +Mosquitto or a Home Assistant broker work the same way. + +```json +{"can":1,"rssi":-51,"voltage":84.23,"battery":87,"rpm":-3421, + "speed_kmh":27.4,"current_in":-12.3,"temp_fet":41.2,"temp_motor":33.8, + "trip_km":12.346,"trip_wh":456.8,"trip_ah":12.35, + "moving":1,"guard":1,"lock":-1,"logging":0} +``` + +Around 220 bytes. Keys are omitted rather than zeroed when the value is not +known -- the motor block disappears with no live CAN node, `voltage` until +a status frame 5 arrives, `battery` unless the controller reported it. +`battery` is the controller's own figure and is not inferred from pack +voltage here, because a voltage curve without a chemistry is worse than no +number. + +`AT+SMCONN` is a TCP handshake plus a CONNECT round trip and regularly +takes over ten seconds on Cat-M1, so the session is opened once and held +rather than reopened per sample, and reconnected only when `AT+SMSTATE?` +says it is gone. Publishing is QoS 1. Failed connects back off 5 s -> 300 s +for the same reason `can-scan` does: a wrong token never becomes right. +Expect 15-20 s of "Connecting" after a boot while the modem attaches. + +`AT+CNACT` alone will not do -- the context profile has to be configured +first with `AT+CNCFG`, and on a SIM whose carrier does not push an APN that +is the difference between a modem that attaches and one that also passes +traffic. Leave the APN blank to request the network's own. + +Plain TCP: the SIM7070G's TLS stack is not reachable through the `SM*` +command set, so use a token scoped to this device alone. + +### When it will not connect + +The Cloud row separates the failures that look identical from outside -- +no data context is an APN or plan problem, a refusal is credentials or the +wrong host. For anything past that, `(def mqtt-log true)` prints every +connect, refusal and inbound UI form with the raw AT reply attached. + +One error is worth knowing in advance: a CONNECT the **broker rejected** +comes back as `+CME ERROR: operation not allowed`, which reads like a +session conflict and is usually a bad or revoked token. + +```clj +(list mqtt-on modem-rdy net-att mqtt-state mqtt-last (str-len mqtt-user)) +``` + +`mqtt-state` 0 down, 1 context up, 2 connected. `mqtt-last` 0 idle, +1 published, 2 publish failed, 3 refused, 4 no data context. + +## SMS control + +Optional. When enabled and a number is set, the modem's mailbox is checked +every 30 s and one message handled per pass. + +| Command | Reply | +|---|---| +| `STATUS` | pack voltage, CAN state, signal, logging state | +| `LOG ON` | starts logging | +| `LOG OFF` | stops logging | +| `TRIP` | distance, Wh, Ah, max speed | +| `GUARD ON` / `GUARD OFF` | arm or disarm the movement alert | +| `LOCK` / `UNLOCK` | request the ESC lock package; confirmed by ack | +| `CLOUD ON` / `CLOUD OFF` | enable or disable MQTT publishing | +| `PING` | liveness check | + +Only approved numbers are obeyed, matched on the last 10 digits so formatting +differences do not matter. Messages are deleted after handling so the mailbox +cannot fill and stall reception. + +### Access roster + +An unknown number that texts the Link is not silently dropped. It appears in +the **Access** tab as a pending request, and gets one reply telling it the +owner has been notified. You allow or deny from VESC Tool; approved members can +be switched on and off individually without deleting them, which is what makes +lending the vehicle practical. + +Five slots. Member 0 is the owner, set in Settings. Pending requests live in +RAM only, so an unapproved number cannot fill EEPROM and does not survive a +restart. + +Approving happens over BLE or USB, which means physical proximity and your own +device -- a far better channel to authorise on than an incoming text. Note it +does **not** make SMS trustworthy: an approved member is still identified only +by caller ID, which is forgeable. + +**Nothing here can move the vehicle.** Sender numbers are trivially +spoofable and SMS is an unauthenticated public bearer, so the command set +is limited to reporting plus `LOCK`, `GUARD` and `LOG`. Those do change +vehicle state, and `LOCK` in particular is a real actuation -- but they can +only ever remove capability, never grant it. There is no command that +applies throttle, releases a brake or raises a limit. + +Requires SMS to be provisioned on the line. A data-only Cat-M plan will +register, attach and pass IP traffic while having no SMS bearer at all -- +check `AT+CSCA?` returns a service centre address. + +## Trip metering + +Distance, watt hours, amp hours and max speed since the vehicle last started +moving, plus Wh/km. Reset from the Status tab or query by SMS with `TRIP`. + +Amp and watt hours are **not** available through `canget-*`. They arrive in CAN +status frames 2 and 3, which the firmware decodes internally but never exposes +to lisp, so the package decodes them from the raw frame via `event-can-eid` +(int32 / 1e4, big-endian; extended id is `node_id | packet_type << 8`). + +## Movement guard + +When armed, the first movement sends one SMS to the owner. It reports, it does +not intervene -- knowing the vehicle is moving is most of the value and none of +the risk. + +## Remote lock + +`LOCK` / `UNLOCK` by SMS, or from the Status tab. Persists across reboots. + +Actuation is a **latch held on the ESC**, not a continuous assertion from +here. The Link sends `(lock)` or `(unlock)` over `can-cmd` and the ESC +package holds `app-disable-output -1`, so the request only has to arrive +once and the Link can then go quiet. + +The cost of a latch is that nothing reveals a lost command -- there is no +heartbeat whose absence would show up. So every request is confirmed: the +Link asks, the ESC package calls `(lock-ack 0|1)` back over CAN, and +nothing here reports success until that arrives. Requests are resent until +confirmed, and an ack older than 15 s is treated as unknown rather than +assuming the last state still holds. + +It removes drive rather than braking, so it is equivalent to letting off +the throttle. + +**This needs a package on the ESC**, providing: + +| Call | Does | +|---|---| +| `(lock)` | latch output disabled, persist across reboot | +| `(unlock)` | release, persist | +| `(lock-report id)` | `can-cmd` `"(lock-ack <0|1>)"` back to CAN node `id` | + +The same channel carries `(batt-report id)` -> `(batt-ack <0.0-1.0>)`, +which is where the `battery` figure in the telemetry comes from. + +## Settings + +Stored on the device in EEPROM and reloaded at boot. + +| Setting | Default | Notes | +|---|---|---| +| Log rate | 10 Hz | Matches the stock vesc_pkg logger | +| Log LTE columns | on | Adds `lte_rssi` and `lte_att` | +| Start logging automatically | off | Waits up to 60 s for a CAN node first | +| SMS control | off | Needs an owner number set | +| Owner number | unset | Member 0; numbers stored as three 5-digit EEPROM chunks | +| Cloud telemetry | off | MQTT publishing | +| Broker / port | thingsboard / 1883 | Plain TCP | +| Username / password | unset | ThingsBoard: access token in username, password blank | +| Client id / topic | `vl-link` / `v1/devices/me/telemetry` | | +| APN | unset | Blank asks the network for one | +| Publish interval | 60 s | Floor of 15 s; the modem cycle is 3 s plus command time | + +Strings pack three characters per EEPROM slot -- a fourth byte would +overflow LBM's 28-bit integer before reaching `eeprom-store-i`. Uses 127 of +the 512 slots. + +All threads run on a 200 word stack. The JSON builder appends one field at +a time rather than nesting `str-merge`, which would otherwise hold every +intermediate result live at once. + +## Requirements + +- VESC Express firmware with `HW_NAME "VL Link"` +- For a node to appear on the bus, the remote controller needs + **App Settings → General → CAN Status Message Mode** enabled. A controller + straight out of the box does not have this on, and every value will read as a + stale zero without it. +- A SIM is only needed for the LTE row. Everything else works without one. + +## Notes + +- The LEDs need both the power rail (GPIO 7) and data (GPIO 8). Powering the rail + alone leaves them dark, which looks like a hardware fault. +- The modem PWRKEY is a **toggle**, not an on switch. The package reads the status + pin before pulsing so it never turns a running modem off. +- A fresh SIM7070G reports `+IPR: 0` (autobaud) and only locks a rate once it sees + valid `AT` traffic. The package sends `AT` repeatedly before deciding the modem + is absent, then pins the rate with `AT+IPR`. +- `can-scan` pings all 254 ids and can take ~2.5 s on an empty bus, so it runs in + its own thread and is backed off. The LED animation is never blocked by it. + +## License + +GPLv3. See LICENSE. diff --git a/vl_link_status/README.md b/vl_link_status/README.md new file mode 100644 index 00000000..462be58e --- /dev/null +++ b/vl_link_status/README.md @@ -0,0 +1,270 @@ +# VL Link Status + +Bring-up and status package for the **VL Link** board (ESP32-C3 + SIMCom SIM7070G). + +## What it does + +- **Enables the CAN transceiver.** `hw_init()` for this board is empty, so GPIO 6 + is left in standby at boot. Without a package driving it low, a CAN scan finds + only the local device and reports no bus errors to explain why. This package + drives it low on every start. +- **Shows link state on the two onboard LEDs.** One LED per subsystem: + + | LED | Colour | Meaning | + |---|---|---| + | 1 (CAN) | rainbow chase | no node on the bus | + | 1 (CAN) | solid green | node found | + | 2 (LTE) | blue breathe | modem booting or not responding | + | 2 (LTE) | solid red | modem up, no SIM | + | 2 (LTE) | amber breathe | SIM ok, searching for network | + | 2 (LTE) | **flashing green** | registered on the network | + + Registered flashes rather than sitting solid, so **solid green + flashing + green** reads at a glance as "both links up". Two solid greens would not. +- **Reports LTE state** — carrier name, modem power, SIM, network attach and a + 5-bar signal meter — in the VESC Tool panel. + + Verified against live hardware on T-Mobile: LTE Cat-M1, band 12, `+CSQ: 31` + (-51 dBm), network-provided APN `mobilenet`. The SIM7070G is Cat-M1 / NB-IoT + / GSM only — it has no plain LTE — so a SIM whose carrier has not provisioned + Cat-M will sit unattached indefinitely with no other symptom. +- **Publishes telemetry to an MQTT broker** over the LTE data connection, + when enabled. See below. +- **Reports pack voltage** of the first live CAN node. Shown only when a fresh + status frame 5 has arrived, so a node found by ping alone reads blank rather + than a misleading 0.0 V. + +- **Logs to the SD card** using the firmware's own log subsystem + (`log-config-field` / `log-start` / `log-send-f32`), so the output is VESC's + native format in `/sdcard/log_can/` and opens directly in VESC Tool's log + analysis page. 10 Hz, matching the stock vesc_pkg logger. +- **Exports on mobile.** The Logs tab lists what is on the card, pulls a file + over the link, and hands it to the log analysis view. Mobile VESC Tool has no + file browser, so without this there is no way off the device. + +## Logging notes + +The `can_id` argument to `log-start` decides where log packets go +(`log_comm.c:91`): `0`-`254` sends over CAN to that device, `-1` sends to the +connected VESC Tool, and anything else is processed locally. This package uses +`-2` so the Link writes to its own card. + +Fields are built per CAN node from `can-fields`, plus two LTE columns. Only +single-argument `canget-*` getters are used — note there is no `canget-ah` or +`canget-wh`, so amp/watt hours cannot be logged from a remote node. + +Logging needs nodes that broadcast status frames. Without them the field list +comes back empty and the getters would only ever return stale zeros, so +`log-begin` refuses to start rather than recording a file full of nothing. + +There is no shutdown hook: Express has no `event-shutdown` (it is ESC-only, and +`event-enable` rejects it). Stop logging from the UI before cutting power, or +the tail of the file may be lost. + +## Cloud telemetry (MQTT) + +Optional, off by default. Brings up a packet data context, connects to a +broker and publishes a JSON object on an interval. Configured in Settings; +persists in EEPROM. + +Defaults target ThingsBoard (`mqtt.thingsboard.cloud:1883`, topic +`v1/devices/me/telemetry`, access token in the username field, password +blank), but host, port, credentials, client id and topic are all fields, so +Mosquitto or a Home Assistant broker work the same way. + +```json +{"can":1,"rssi":-51,"voltage":84.23,"battery":87,"rpm":-3421, + "speed_kmh":27.4,"current_in":-12.3,"temp_fet":41.2,"temp_motor":33.8, + "trip_km":12.346,"trip_wh":456.8,"trip_ah":12.35, + "moving":1,"guard":1,"lock":-1,"logging":0} +``` + +Around 220 bytes. Keys are omitted rather than zeroed when the value is not +known -- the motor block disappears with no live CAN node, `voltage` until +a status frame 5 arrives, `battery` unless the controller reported it. +`battery` is the controller's own figure and is not inferred from pack +voltage here, because a voltage curve without a chemistry is worse than no +number. + +`AT+SMCONN` is a TCP handshake plus a CONNECT round trip and regularly +takes over ten seconds on Cat-M1, so the session is opened once and held +rather than reopened per sample, and reconnected only when `AT+SMSTATE?` +says it is gone. Publishing is QoS 1. Failed connects back off 5 s -> 300 s +for the same reason `can-scan` does: a wrong token never becomes right. +Expect 15-20 s of "Connecting" after a boot while the modem attaches. + +`AT+CNACT` alone will not do -- the context profile has to be configured +first with `AT+CNCFG`, and on a SIM whose carrier does not push an APN that +is the difference between a modem that attaches and one that also passes +traffic. Leave the APN blank to request the network's own. + +Plain TCP: the SIM7070G's TLS stack is not reachable through the `SM*` +command set, so use a token scoped to this device alone. + +### When it will not connect + +The Cloud row separates the failures that look identical from outside -- +no data context is an APN or plan problem, a refusal is credentials or the +wrong host. For anything past that, `(def mqtt-log true)` prints every +connect, refusal and inbound UI form with the raw AT reply attached. + +One error is worth knowing in advance: a CONNECT the **broker rejected** +comes back as `+CME ERROR: operation not allowed`, which reads like a +session conflict and is usually a bad or revoked token. + +```clj +(list mqtt-on modem-rdy net-att mqtt-state mqtt-last (str-len mqtt-user)) +``` + +`mqtt-state` 0 down, 1 context up, 2 connected. `mqtt-last` 0 idle, +1 published, 2 publish failed, 3 refused, 4 no data context. + +## SMS control + +Optional. When enabled and a number is set, the modem's mailbox is checked +every 30 s and one message handled per pass. + +| Command | Reply | +|---|---| +| `STATUS` | pack voltage, CAN state, signal, logging state | +| `LOG ON` | starts logging | +| `LOG OFF` | stops logging | +| `TRIP` | distance, Wh, Ah, max speed | +| `GUARD ON` / `GUARD OFF` | arm or disarm the movement alert | +| `LOCK` / `UNLOCK` | request the ESC lock package; confirmed by ack | +| `CLOUD ON` / `CLOUD OFF` | enable or disable MQTT publishing | +| `PING` | liveness check | + +Only approved numbers are obeyed, matched on the last 10 digits so formatting +differences do not matter. Messages are deleted after handling so the mailbox +cannot fill and stall reception. + +### Access roster + +An unknown number that texts the Link is not silently dropped. It appears in +the **Access** tab as a pending request, and gets one reply telling it the +owner has been notified. You allow or deny from VESC Tool; approved members can +be switched on and off individually without deleting them, which is what makes +lending the vehicle practical. + +Five slots. Member 0 is the owner, set in Settings. Pending requests live in +RAM only, so an unapproved number cannot fill EEPROM and does not survive a +restart. + +Approving happens over BLE or USB, which means physical proximity and your own +device -- a far better channel to authorise on than an incoming text. Note it +does **not** make SMS trustworthy: an approved member is still identified only +by caller ID, which is forgeable. + +**Nothing here can move the vehicle.** Sender numbers are trivially +spoofable and SMS is an unauthenticated public bearer, so the command set +is limited to reporting plus `LOCK`, `GUARD` and `LOG`. Those do change +vehicle state, and `LOCK` in particular is a real actuation -- but they can +only ever remove capability, never grant it. There is no command that +applies throttle, releases a brake or raises a limit. + +Requires SMS to be provisioned on the line. A data-only Cat-M plan will +register, attach and pass IP traffic while having no SMS bearer at all -- +check `AT+CSCA?` returns a service centre address. + +## Trip metering + +Distance, watt hours, amp hours and max speed since the vehicle last started +moving, plus Wh/km. Reset from the Status tab or query by SMS with `TRIP`. + +Amp and watt hours are **not** available through `canget-*`. They arrive in CAN +status frames 2 and 3, which the firmware decodes internally but never exposes +to lisp, so the package decodes them from the raw frame via `event-can-eid` +(int32 / 1e4, big-endian; extended id is `node_id | packet_type << 8`). + +## Movement guard + +When armed, the first movement sends one SMS to the owner. It reports, it does +not intervene -- knowing the vehicle is moving is most of the value and none of +the risk. + +## Remote lock + +`LOCK` / `UNLOCK` by SMS, or from the Status tab. Persists across reboots. + +Actuation is a **latch held on the ESC**, not a continuous assertion from +here. The Link sends `(lock)` or `(unlock)` over `can-cmd` and the ESC +package holds `app-disable-output -1`, so the request only has to arrive +once and the Link can then go quiet. + +The cost of a latch is that nothing reveals a lost command -- there is no +heartbeat whose absence would show up. So every request is confirmed: the +Link asks, the ESC package calls `(lock-ack 0|1)` back over CAN, and +nothing here reports success until that arrives. Requests are resent until +confirmed, and an ack older than 15 s is treated as unknown rather than +assuming the last state still holds. + +It removes drive rather than braking, so it is equivalent to letting off +the throttle. + +**This needs a package on the ESC**, providing: + +| Call | Does | +|---|---| +| `(lock)` | latch output disabled, persist across reboot | +| `(unlock)` | release, persist | +| `(lock-report id)` | `can-cmd` `"(lock-ack <0|1>)"` back to CAN node `id` | + +The same channel carries `(batt-report id)` -> `(batt-ack <0.0-1.0>)`, +which is where the `battery` figure in the telemetry comes from. + +## Settings + +Stored on the device in EEPROM and reloaded at boot. + +| Setting | Default | Notes | +|---|---|---| +| Log rate | 10 Hz | Matches the stock vesc_pkg logger | +| Log LTE columns | on | Adds `lte_rssi` and `lte_att` | +| Start logging automatically | off | Waits up to 60 s for a CAN node first | +| SMS control | off | Needs an owner number set | +| Owner number | unset | Member 0; numbers stored as three 5-digit EEPROM chunks | +| Cloud telemetry | off | MQTT publishing | +| Broker / port | thingsboard / 1883 | Plain TCP | +| Username / password | unset | ThingsBoard: access token in username, password blank | +| Client id / topic | `vl-link` / `v1/devices/me/telemetry` | | +| APN | unset | Blank asks the network for one | +| Publish interval | 60 s | Floor of 15 s; the modem cycle is 3 s plus command time | + +Strings pack three characters per EEPROM slot -- a fourth byte would +overflow LBM's 28-bit integer before reaching `eeprom-store-i`. Uses 127 of +the 512 slots. + +All threads run on a 200 word stack. The JSON builder appends one field at +a time rather than nesting `str-merge`, which would otherwise hold every +intermediate result live at once. + +## Requirements + +- VESC Express firmware with `HW_NAME "VL Link"` +- For a node to appear on the bus, the remote controller needs + **App Settings → General → CAN Status Message Mode** enabled. A controller + straight out of the box does not have this on, and every value will read as a + stale zero without it. +- A SIM is only needed for the LTE row. Everything else works without one. + +## Notes + +- The LEDs need both the power rail (GPIO 7) and data (GPIO 8). Powering the rail + alone leaves them dark, which looks like a hardware fault. +- The modem PWRKEY is a **toggle**, not an on switch. The package reads the status + pin before pulsing so it never turns a running modem off. +- A fresh SIM7070G reports `+IPR: 0` (autobaud) and only locks a rate once it sees + valid `AT` traffic. The package sends `AT` repeatedly before deciding the modem + is absent, then pins the rate with `AT+IPR`. +- `can-scan` pings all 254 ids and can take ~2.5 s on an empty bus, so it runs in + its own thread and is backed off. The LED animation is never blocked by it. + +## License + +GPLv3. See LICENSE. + +### Build Info +- Version: 1.4.1 +- Build Date: 2026-08-11 10:44:00 -0400 +- Git Commit: #b55df07 diff --git a/vl_link_status/code.lbm b/vl_link_status/code.lbm new file mode 100644 index 00000000..c5965ce3 --- /dev/null +++ b/vl_link_status/code.lbm @@ -0,0 +1,2096 @@ +; Copyright 2026 Jeremy Maddox +; +; This file is part of the VESC Package VL Link Status. +; +; This program is free software: you can redistribute it and/or modify +; it under the terms of the GNU General Public License as published by +; the Free Software Foundation, either version 3 of the License, or +; (at your option) any later version. +; +; This program is distributed in the hope that it will be useful, +; but WITHOUT ANY WARRANTY; without even the implied warranty of +; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +; GNU General Public License for more details. +; +; You should have received a copy of the GNU General Public License +; along with this program. If not, see . + +;;;; VL Link Status +;;;; +;;;; Enables the CAN transceiver, reports link + LTE state to the UI, +;;;; optionally publishes telemetry to an MQTT broker over the LTE data +;;;; connection, and mirrors CAN state on the two onboard WS2812 LEDs: +;;;; +;;;; no CAN node found -> rainbow chase ("dance") +;;;; CAN node found -> solid green +;;;; +;;;; Board: VESC Express, HW_NAME "VL Link" (ESP32-C3 + SIM7070G). +;;;; Pins from vesc_express/main/hwconf/vesc/link/hw_link.h and hw_test.lisp. + + +;;; ------------------------------------------------------------------ +;;; Pins +;;; ------------------------------------------------------------------ + +; Definitions live in flash, not the cons heap. An ESP32-C3 with BLE on +; gets 2560 cons cells (lispif.c:119) and this program does not fit in +; that. Everything between @const-start and @const-end is moved to +; constant memory by the incremental reader. +; +; Globals declared here are still assignable: setq replaces the binding in +; the global environment, it does not write through to the flashed value. +; What must NOT be done is mutating a flashed structure in place, which is +; why members/pending/loglist are all built at runtime rather than being +; declared with contents. + +@const-start + +(def pin-pwrkey 2) ; modem PWRKEY, ACTIVE HIGH, pulse TOGGLES power +(def pin-mstat 3) ; modem status input, 1 = modem on +(def pin-canstb 6) ; CAN transceiver standby, ACTIVE LOW (0 = enabled) +(def pin-ledpwr 7) ; LED + GNSS power rail, active high +(def pin-leddat 8) ; WS2812 data, 2 LEDs + +(def modem-uart 0) ; HW_NO_UART is set, so uart 0 is free for lisp +(def modem-rx 20) ; (uart-start num rx tx baud) -- rx before tx +(def modem-tx 21) +(def modem-baud 57600) + + +;;; ------------------------------------------------------------------ +;;; Shared state (read by the LED thread and the UI thread) +;;; ------------------------------------------------------------------ + +(def can-ok false) ; true when at least one node is on the bus +(def can-devs nil) ; list of CAN ids +(def can-vin -1.0) ; pack voltage of the first live node, -1 = unknown +(def rescan-req false) ; set by the UI, cleared by the CAN thread +(def scan-tick 0) ; cycles since the last active can-scan +(def scan-wait 3) ; cycles to wait before scanning again +(def scan-wait-max 30) ; ceiling, so an idle bus is not hammered forever + +; Persistent settings. eeprom-read-i returns nil for a slot that has never +; been written, so the magic word must be nil-guarded before comparing. +; Bumped for the MQTT block. A magic mismatch rewrites defaults rather than +; reading the old layout back as garbage. +(def eeprom-magic 5001003) +(def ee-magic 0) +(def ee-rate 1) +(def ee-lte 2) +(def ee-at-boot 3) +(def log-at-boot false) + +; SMS control +(def ee-sms 4) +(def ee-members 10) ; 5 slots per member: len, 3 chunks, enabled +(def max-members 5) ; member 0 is the owner, 1..4 are friends +(def max-pending 3) +(def sms-on false) +(def members nil) ; list of (digits enabled) or nil per slot +(def pending nil) ; unapproved numbers seen this session, RAM only +(def sms-tick 0) ; modem cycles since the last mailbox check + +; Trip metering / guard / lock +(def bat-ah 0.0) ; from CAN status frame 2 +(def bat-wh 0.0) ; from CAN status frame 3 +(def trip-ah0 0.0) (def trip-wh0 0.0) (def trip-d0 0.0) +(def trip-ah 0.0) (def trip-wh 0.0) (def trip-d 0.0) +(def trip-vmax 0.0) +(def moving false) +(def rpm-move 300) ; ERPM above which the vehicle counts as moving + +(def ee-guard 9) +; Battery percentage comes from the ESC, which computes it from the +; configured cell count, battery type and cutoff voltages. Reported back +; over CAN by the lock package, same pattern as the lock ack. +(def batt-pct -1.0) ; -1 = not reported +(def batt-ack-t 0) +(def guard-on false) ; movement alert armed +(def guard-fired false) + +(def ee-lock 8) +(def lock-want false) ; what we have asked the ESC for +(def lock-state -1) ; last CONFIRMED state from the ESC: -1 unknown, 0, 1 +(def lock-ack-t 0) ; systime of the last ack +(def lock-on false) ; convenience: confirmed locked +(def sms-every 4) ; fallback poll every Nth cycle (4 x 3s = 12s) +(def sms-ready false) ; text mode configured on the modem +(def sms-urc false) ; +CMTI seen, a message is waiting + +; --- MQTT telemetry ---------------------------------------------------- +; +; Ints live in their own slots; strings are packed by str-store into a +; length slot followed by data slots, three characters each. The blocks are +; spaced so that lengthening one later does not shift the next. +; +; slot 5 enable 6 interval 7 port +; 40 apn 50 host 70 username +; 90 password 100 client id 110 topic +(def ee-mqtt-on 5) +(def ee-mqtt-int 6) +(def ee-mqtt-port 7) + +(def ee-apn 40) (def sl-apn 9) ; 24 chars +(def ee-host 50) (def sl-host 17) ; 48 +(def ee-user 70) (def sl-user 17) ; 48 -- ThingsBoard tokens are 20 +(def ee-pass 90) (def sl-pass 9) ; 24 +(def ee-client 100)(def sl-client 9) ; 24 +(def ee-topic 110)(def sl-topic 17) ; 48 + +(def mqtt-on false) +(def mqtt-int 60) ; seconds between publishes +(def mqtt-port 1883) +(def mqtt-apn "") ; empty = ask the network for one +(def mqtt-host "mqtt.thingsboard.cloud") +(def mqtt-user "") ; ThingsBoard: the device access token +(def mqtt-pass "") +(def mqtt-client "vl-link") +(def mqtt-topic "v1/devices/me/telemetry") + +; 0 = down, 1 = packet context up but no session, 2 = connected +(def mqtt-state 0) +(def mqtt-t0 0) ; systime of the last confirmed publish +(def mqtt-retry-t 0) ; systime of the last connect attempt +(def mqtt-wait 5.0) ; connect backoff, doubles to mqtt-wait-max +(def mqtt-wait-max 300.0) +(def mqtt-fails 0) +(def mqtt-redo false) ; set by the UI when settings change +; Last outcome, surfaced in the UI so a silent failure is visible: +; 0 idle 1 published 2 publish failed 3 connect failed 4 no data context +(def mqtt-last 0) + +(def modem-on false) ; GPIO 3 high +(def modem-rdy false) ; modem answers AT +(def sim-ok false) ; AT+CPIN? -> READY +(def net-att false) ; AT+CGATT? -> 1 +(def lte-rssi -999) ; dBm, -999 = unknown / no signal +(def op-name "") ; carrier, from AT+COPS? +(def modem-fails 0) ; consecutive AT sync failures, for the console + +; SD card / logging +(def pin-sd-mosi 5) +(def pin-sd-miso 10) +(def pin-sd-sck 4) +(def pin-sd-cs 9) + +(def sd-ok false) ; card mounted +(def log-on false) +(def log-rate 10.0) ; Hz. 10 is the stock rate used by the vesc_pkg logger. +(def log-lte true) ; append LTE signal / attach to each row +(def loglist nil) +(def log-thd-id nil) + +; The firmware routes log packets by this id: +; 0..254 -> over CAN to that device +; -1 -> to the connected VESC Tool +; anything else -> processed locally, i.e. written to our own SD card +; See log_comm.c:91. -2 is the local case. +(def log-id -2) + +; Per-CAN-device fields, mirroring loglist-can-template in the stock +; vesc_pkg logger. Format: (key name unit precision getter) +; +; Only single-argument canget-* getters, because the value form is built +; as (getter id). Note the CAN getter set is smaller than the local one -- +; there is no canget-ah or canget-wh, so amp/watt hours cannot be logged +; from a remote node. Verified against the extension table. +(def can-fields '( + ("in_voltage" "Input Voltage" "V" 2 canget-vin) + ("current_motor" "Current" "A" 2 canget-current) + ("current_in" "Current In" "A" 2 canget-current-in) + ("duty" "Duty" "" 3 canget-duty) + ("rpm" "RPM" "" 0 canget-rpm) + ("temp_mos" "Temp Fet" "degC" 1 canget-temp-fet) + ("temp_motor" "Temp Motor" "degC" 1 canget-temp-motor) + ("speed" "Speed" "m/s" 2 canget-speed) + ("dist" "Distance" "m" 1 canget-dist) +)) + +(def led-buf nil) +(def led-phase 0) +(def hue-n 60) ; rainbow table entries +(def hue-tab nil) +; Animation phase wraps at a common multiple of the rainbow (60 frames) and +; the flash (24 frames), so neither waveform stutters at the wrap point. +(def led-period 120) + +; Indicator colours. Gamma correction is on, so these are pre-gamma values. +(def col-green 0x00FF00) +(def col-amber 0xFF6000) +(def col-red 0xFF0000) +(def col-blue 0x0040FF) + +; LED 1 (LTE) is driven from these, recomputed by the modem thread rather +; than branched on inside the animation loop. Defaults read as "booting". +; lte-mode 0 = solid 1 = smooth breathe 2 = flash +(def lte-col col-blue) +(def lte-mode 1) + + +;;; ------------------------------------------------------------------ +;;; LED helpers +;;; ------------------------------------------------------------------ + +; Pack 0-255 components into the 0xRRGGBB int that rgbled-color expects. +; The buffer type handles the GRB wire order, so always build as RGB here. +(defun rgb-i (r g b) + (bitwise-or (shl (to-i r) 16) + (bitwise-or (shl (to-i g) 8) (to-i b)))) + +; Hue 0.0 .. 1.0 (exclusive) -> 0xRRGGBB. Full saturation, full value. +; +; This is only ever called from build-hue-tab, at init. Evaluating the later +; cond clauses costs more eval stack than the earlier ones, so calling it from +; a spawned animation thread crashes that thread a second or two in -- once the +; hue first reaches the bottom of the cond. Keep it out of the hot loop. +(defun hue-rgb (h) { + (var hv h) + (var seg (to-i (* hv 6.0))) + (var f (- (* hv 6.0) seg)) + (var up (* 255.0 f)) + (var dn (* 255.0 (- 1.0 f))) + (cond + ((= seg 0) (rgb-i 255 up 0)) + ((= seg 1) (rgb-i dn 255 0)) + ((= seg 2) (rgb-i 0 255 up)) + ((= seg 3) (rgb-i 0 dn 255)) + ((= seg 4) (rgb-i up 0 255)) + (t (rgb-i 255 0 dn))) +}) + +; Map modem state onto LED 1. Called from the modem thread so the 25 Hz +; animation loop stays branch-free -- a multi-clause cond evaluated inside a +; spawned thread is what killed the first version of the LED loop. +; +; Registered flashes rather than sitting solid, so that "CAN solid green + +; LTE flashing green" is readable at a glance as both links up. Solid and +; flashing are easy to tell apart from across a garage; two solids are not. +(defun update-lte-led () + (if net-att + { (setq lte-col col-green) (setq lte-mode 2) } + (if (and modem-rdy sim-ok) + { (setq lte-col col-amber) (setq lte-mode 1) } + (if modem-rdy + { (setq lte-col col-red) (setq lte-mode 0) } + { (setq lte-col col-blue) (setq lte-mode 1) })))) + +; Rainbow lookup table, built once. The LED thread then does a single +; bufget-u32 per pixel per frame -- no arithmetic, no branching. +(defun build-hue-tab () { + (setq hue-tab (bufcreate (* hue-n 4))) + (looprange i 0 hue-n + (bufset-u32 hue-tab (* i 4) (hue-rgb (/ i (* 1.0 hue-n))))) +}) + + +;;; ------------------------------------------------------------------ +;;; AT command layer +;;; ------------------------------------------------------------------ + +; One shared receive buffer. Only the modem thread calls at-cmd, so no +; mutex is needed -- but the caller must consume the result before the +; next call, because the buffer is reused. +; +; 384 rather than 256: a live AT+CPSI? reply overran a 256 byte buffer on +; this carrier. Nothing the package polls is that long, but the headroom +; costs nothing and a truncated reply fails silently. +(def at-max 511) ; usable bytes; uart-read rejects num > 512 + +; Declared nil here and allocated in init. A bufcreate evaluated inside the +; @const-start block would be moved to FLASH, and flash is not writable -- +; bufclear and uart-read would then silently fail on every AT command while +; leaving everything that does not touch this buffer working normally. +; +; The rule is: setq-ing a global is fine inside a const block, but anything +; mutated IN PLACE must be allocated at runtime. +(def at-rx nil) + +; uart-write sends array->size bytes, and for an LBM string that INCLUDES +; the trailing null terminator. Inside an SMS body that null becomes a +; stray character on the end of every message. All writes go through here. +; (bufcpy dst dst-start src src-start len) +(defun uart-put (str) { + (var sv str) + (var n (str-len sv)) + (if (> n 0) { + (var buf (bufcreate n)) + (bufcpy buf 0 sv 0 n) + (uart-write buf) + }) +}) + +; Send an AT command and collect the reply until OK / ERROR / timeout. +; Returns at-rx, which is null-padded and therefore usable as a string. +; +; NOTE: parameters are rebound with var at the top of the body. On this +; firmware a defun parameter does not always bind inside a nested block. +(defun at-cmd (cmd tmo) { + (var c cmd) + (var tout tmo) + + ; Drain stale bytes, but look for the new-message URC first. CNMI is + ; configured to emit +CMTI on arrival, and that notification would + ; otherwise be discarded here -- which is why replies waited for the + ; next scheduled poll instead of going out immediately. + (uart-read at-rx at-max) + (if (>= (str-find at-rx "+CMTI") 0) (setq sms-urc true)) + (bufclear at-rx) + (uart-put (str-merge c "\r\n")) + + (var t0 (systime)) + (var pos 0) + (var done false) + + (loopwhile (and (not done) (< (secs-since t0) tout)) { + (var n (uart-read at-rx (- at-max pos) pos -1 0.1)) + (if (> n 0) (setq pos (+ pos n))) + (if (or (>= (str-find at-rx "OK") 0) + (>= (str-find at-rx "ERROR") 0)) + (setq done true)) + (if (>= pos (- at-max 4)) (setq done true)) + }) + + at-rx +}) + +(defun at-ok (s) (>= (str-find s "OK") 0)) + +; Send a command without waiting for a terminator. Needed for the two +; commands whose reply is a ">" prompt rather than OK -- at-cmd would sit +; there until it timed out, because the prompt never terminates. +(defun at-raw (cmd) { + (var c cmd) + (uart-read at-rx at-max) + (if (>= (str-find at-rx "+CMTI") 0) (setq sms-urc true)) + (bufclear at-rx) + (uart-put (str-merge c "\r\n")) +}) + +; Collect until OK / ERROR / timeout. Same loop as at-cmd, split out so it +; can be used after a raw write. +(defun at-wait (tmo) { + (var tout tmo) + (var t0 (systime)) + (var pos 0) + (var done false) + (loopwhile (and (not done) (< (secs-since t0) tout)) { + (var n (uart-read at-rx (- at-max pos) pos -1 0.1)) + (if (> n 0) (setq pos (+ pos n))) + (if (or (>= (str-find at-rx "OK") 0) + (>= (str-find at-rx "ERROR") 0)) + (setq done true)) + (if (>= pos (- at-max 4)) (setq done true)) + }) + at-rx +}) + +; Wait for the payload prompt. Call at-raw first -- this reads into a +; buffer it assumes was cleared, and never clears one itself, because +; clearing here would race the prompt that is already on its way. +(defun at-prompt (tmo) { + (var tout tmo) + (var t0 (systime)) + (var pos 0) + (var got false) + (loopwhile (and (not got) (< (secs-since t0) tout)) { + (var n (uart-read at-rx (- at-max pos) pos -1 0.1)) + (if (> n 0) (setq pos (+ pos n))) + (if (or (>= (str-find at-rx ">") 0) + (>= (str-find at-rx "ERROR") 0)) + (setq got true)) + (if (>= pos (- at-max 4)) (setq got true)) + }) + (>= (str-find at-rx ">") 0) +}) + +; AT+CSQ reply -> dBm, or -999 when unknown. 99 means "not detectable". +(defun parse-csq (s) { + (var str s) + (var i (str-find str "+CSQ:")) + (if (< i 0) + -999 + { + (var v (str-to-i (str-part str (+ i 5)) 10)) + (if (or (< v 0) (> v 31)) -999 (- (* 2 v) 113)) + }) +}) + +; AT+COPS? reply -> carrier name. Verified shape on this hardware: +; +COPS: 0,0,"T-Mobile",7 +; The name is the only quoted field, so take what sits between the quotes. +(defun parse-cops (s) { + (var str s) + (var i (str-find str "\"")) + (if (< i 0) + "" + { + (var tail (str-part str (+ i 1))) + (var j (str-find tail "\"")) + (if (< j 0) "" (str-part tail 0 j)) + }) +}) + +; PWRKEY is a toggle, not an on switch. Read the status pin first and +; only pulse when the modem is actually off, or this turns it back off. +(defun modem-power-on () { + (if (= (gpio-read pin-mstat) 1) + true + { + (gpio-write pin-pwrkey 1) + (sleep 2.0) + (gpio-write pin-pwrkey 0) + (sleep 6.0) + (= (gpio-read pin-mstat) 1) + }) +}) + +; A virgin SIM7070G reports +IPR: 0 (autobaud) and only locks onto a rate +; after it sees valid AT traffic. Send AT a few times before deciding the +; modem is dead, then pin the rate so later boots are deterministic. +(defun modem-sync () { + (var ok false) + (var tries 0) + (loopwhile (and (not ok) (< tries 10)) { + (setq tries (+ tries 1)) + (if (>= (str-find (at-cmd "AT" 1.0) "OK") 0) (setq ok true)) + (if (not ok) (sleep 0.3)) + }) + (if ok { + (at-cmd "ATE0" 1.0) ; echo off + (at-cmd "AT+CMEE=2" 1.0) ; verbose errors + (at-cmd (str-merge "AT+IPR=" (str-from-n modem-baud "%d")) 1.0) + }) + ok +}) + + +;;; ------------------------------------------------------------------ +;;; UI link +;;; ------------------------------------------------------------------ + +; One space-separated line, kept well under the 100 byte send-data limit: +; st +; +; +; flags bits: 1 can 2 modemOn 4 modemRdy 8 sim 16 att 32 sd 64 logging +; 128 logLte 256 atBoot 512 sms 1024 guard +; +; The roster itself does not fit here and is sent separately by send-roster +; as "mb" and "pd" frames. The operator stays last because carrier names +; can contain spaces. +; +; The operator goes last on purpose. Carrier names can contain spaces +; ("Verizon Wireless"), which would otherwise split into extra fields -- +; the UI rejoins everything from index 10 onwards. +; Boolean state is packed into one integer rather than eleven separate +; string fragments. str-merge allocates for every argument, and this runs +; once a second against a 2560 cons cell heap. +(defun sflags () + (+ (if can-ok 1 0) (if modem-on 2 0) (if modem-rdy 4 0) + (if sim-ok 8 0) (if net-att 16 0) (if sd-ok 32 0) + (if log-on 64 0) (if log-lte 128 0) (if log-at-boot 256 0) + (if sms-on 512 0) (if guard-on 1024 0) + (if mqtt-on 2048 0) (if (= mqtt-state 2) 4096 0))) + +(defun send-status () + (send-data (str-merge + "st " + (str-from-n (sflags) "%d ") + (str-from-n (length can-devs) "%d ") + (str-from-n (if (eq can-devs nil) -1 (ix can-devs 0)) "%d ") + (str-from-n lte-rssi "%d ") + (str-from-n can-vin "%.1f ") + (str-from-n (length loglist) "%d ") + (str-from-n log-rate "%.1f ") + (str-from-n (length pending) "%d ") + (str-from-n lock-state "%d ") + (str-from-n (/ trip-d 1000.0) "%.2f ") + (str-from-n trip-wh "%.1f ") + (str-from-n batt-pct "%.0f ") + (str-from-n mqtt-last "%d ") + ; Seconds since the last confirmed publish, -1 for never. The + ; single most useful number when the cloud row says it is + ; connected but the dashboard is not moving. + (str-from-n (if (= mqtt-t0 0) -1 (to-i (secs-since mqtt-t0))) "%d ") + op-name +))) + +(defun rescan () (setq rescan-req true)) +(defun ui-sync () { (send-status) (send-roster) (send-cfg) }) + +(defun guard-set (en) { + (var e en) + (setq guard-on (= e 1)) + (if guard-on (setq guard-fired false)) + (settings-save) + true +}) + +(defun lock-set (en) { + (var e en) + (lock-set-wait (= e 1)) +}) + +(defun trip-reset () { (trip-start) (send-status) true }) + + +;;; ------------------------------------------------------------------ +;;; Persistent settings +;;; ------------------------------------------------------------------ + +; eeprom-read-i hands back nil for a slot that was never written, which is +; the state of a factory-fresh device. Comparing nil against the magic word +; would be a type error, so every read goes through this. +(defun ee-get (addr dflt) { + (var a addr) + (var d dflt) + (var v (eeprom-read-i a)) + (if (eq v nil) d v) +}) + +; Write only when the value actually changes. Storing six string blocks +; is ~80 slots, and every one of those is a flash commit -- skipping the +; unchanged ones turns a repeat save from eighty writes into zero. +(defun ee-put (addr v) + (if (not (= (ee-get addr 2147483647) v)) (eeprom-store-i addr v) true)) + +; --- arbitrary strings in EEPROM --------------------------------------- +; +; The number chunker below stores digits as decimal, which only works for +; digits. Broker hosts, tokens and topics are arbitrary ASCII, so they are +; packed as bytes: one length slot, then three characters per slot. +; +; Three and not four. eeprom-store-i takes an i32, but LBM's default +; integer is 28 bit, so a fourth byte shifted to bit 24 would overflow the +; encoding before it ever reached the extension. Three bytes tops out at +; 0xFFFFFF and always fits. The cost is one slot per twelve characters, +; against 512 slots available (flash_helper.h:47). +(defun str-store (base nslots sv) { + (var b base) + (var ns nslots) + (var s sv) + (var cap (* 3 (- ns 1))) + (var n (str-len s)) + (if (> n cap) (setq n cap)) + ; acc and i are declared ONCE, outside the loops. A var inside a + ; looprange body re-declares on every iteration, which grows the + ; context stack until the whole call dies -- and it dies inside the + ; trap in event-handler, so the setting simply does not stick and + ; nothing appears on the console. + (var acc 0) + (var i 0) + (ee-put b n) + (looprange w 0 (- ns 1) { + ; Bytes are folded in high-first so that character 0 lands + ; in the low byte and the word reads back in order. + (setq acc 0) + (looprange k 0 3 { + (setq i (+ (* w 3) (- 2 k))) + (setq acc (* acc 256)) + (if (< i n) (setq acc (+ acc (bufget-u8 s i)))) + }) + (ee-put (+ b 1 w) acc) + }) +}) + +(defun str-fetch (base nslots) { + (var b base) + (var ns nslots) + (var n (ee-get b 0)) + (var cap (* 3 (- ns 1))) + (if (or (< n 1) (> n cap)) + "" + { + ; One byte longer than the string, with the terminator set + ; by hand -- str-len and str-find walk to a null and would + ; run off the end of an exactly sized buffer. + (var out (bufcreate (+ n 1))) + (var acc 0) + (var i 0) + (bufset-u8 out n 0) + (looprange w 0 (- ns 1) { + (setq acc (ee-get (+ b 1 w) 0)) + (looprange k 0 3 { + (setq i (+ (* w 3) k)) + (if (< i n) (bufset-u8 out i (mod acc 256))) + (setq acc (/ acc 256)) + }) + }) + out + }) +}) + +; Stored value, or the compiled-in default when the slot is empty. Used +; only for fields that must not be blank; apn, username and password are +; taken verbatim because empty is a meaningful setting for all three. +(defun str-or (base nslots dflt) { + (var s (str-fetch base nslots)) + (if (= (str-len s) 0) dflt s) +}) + +(defun mqtt-strings-save () { + (str-store ee-apn sl-apn mqtt-apn) + (str-store ee-host sl-host mqtt-host) + (str-store ee-user sl-user mqtt-user) + (str-store ee-pass sl-pass mqtt-pass) + (str-store ee-client sl-client mqtt-client) + (str-store ee-topic sl-topic mqtt-topic) +}) + +(defun mqtt-strings-load () { + (setq mqtt-apn (str-fetch ee-apn sl-apn)) + (setq mqtt-user (str-fetch ee-user sl-user)) + (setq mqtt-pass (str-fetch ee-pass sl-pass)) + (setq mqtt-host (str-or ee-host sl-host "mqtt.thingsboard.cloud")) + (setq mqtt-client (str-or ee-client sl-client "vl-link")) + (setq mqtt-topic (str-or ee-topic sl-topic "v1/devices/me/telemetry")) +}) + +(defun settings-save () { + (eeprom-store-i ee-magic eeprom-magic) + (eeprom-store-i ee-rate (to-i (* log-rate 100))) + (eeprom-store-i ee-lte (if log-lte 1 0)) + (eeprom-store-i ee-at-boot (if log-at-boot 1 0)) + (eeprom-store-i ee-sms (if sms-on 1 0)) + (eeprom-store-i ee-guard (if guard-on 1 0)) + (eeprom-store-i ee-lock (if lock-want 1 0)) + ; Only the integers. The MQTT strings are written by their own + ; setter, because rewriting six string blocks on every settings + ; save would put ~80 flash writes behind an unrelated checkbox. + (eeprom-store-i ee-mqtt-on (if mqtt-on 1 0)) + (eeprom-store-i ee-mqtt-int mqtt-int) + (eeprom-store-i ee-mqtt-port mqtt-port) + (members-save) + (send-status) + true +}) + +(defun settings-load () + (if (= (ee-get ee-magic 0) eeprom-magic) + { + (setq log-rate (/ (ee-get ee-rate 1000) 100.0)) + (setq log-lte (= (ee-get ee-lte 1) 1)) + (setq log-at-boot (= (ee-get ee-at-boot 0) 1)) + (setq sms-on (= (ee-get ee-sms 0) 1)) + (setq guard-on (= (ee-get ee-guard 0) 1)) + (setq lock-want (= (ee-get ee-lock 0) 1)) + (setq mqtt-on (= (ee-get ee-mqtt-on 0) 1)) + (setq mqtt-int (ee-get ee-mqtt-int 60)) + (setq mqtt-port (ee-get ee-mqtt-port 1883)) + (mqtt-strings-load) + (members-load) + } + ; First boot on this device, or a layout change: build an empty + ; roster and write the defaults so the magic word is present. + { + (looprange i 0 max-members (setq members (append members (list nil)))) + (mqtt-strings-save) + (settings-save) + })) + +; Called from the UI. Parameters are rebound before the nested block. +(defun cfg (rate lte at-boot) { + (var r rate) + (var l lte) + (var b at-boot) + (setq log-rate (if (< r 1.0) 1.0 (if (> r 50.0) 50.0 r))) + (setq log-lte (= l 1)) + (setq log-at-boot (= b 1)) + (settings-save) +}) + + +;;; ------------------------------------------------------------------ +;;; SMS control +;;; +;;; Deliberately read-only plus log start/stop. Sender numbers are trivially +;;; spoofable and SMS is an unauthenticated public bearer, so nothing here +;;; can move the vehicle. +;;; +;;; Commands (case insensitive, replied to the sender): +;;; STATUS pack voltage, CAN state, signal, logging state +;;; LOG ON start logging +;;; LOG OFF stop logging +;;; PING liveness check +;;; ------------------------------------------------------------------ + +; Keep only 0-9 from a string. Incoming sender numbers arrive in assorted +; formats (+1..., 1..., bare 10 digit), so everything is normalised before +; comparison. +(defun digits-only (s) { + (var str s) + (var out "") + (looprange i 0 (str-len str) { + (var ch (bufget-u8 str i)) + (if (and (>= ch 48) (<= ch 57)) + (setq out (str-merge out (str-from-n (- ch 48) "%d")))) + }) + out +}) + +; Compare on the last 10 digits so +15551234567 and 5551234567 match. +(defun num-tail (s) { + (var d (digits-only s)) + (var n (str-len d)) + (if (> n 10) (str-part d (- n 10) 10) d) +}) + +; --- roster storage ---------------------------------------------------- +; +; eeprom-store-i holds an i32 and a 15 digit number does not fit in one, so +; each number is three 5-digit chunks. Layout per member, 5 slots each: +; +0 length +1..3 chunks +4 enabled +; Member 0 is the owner; 1..4 are approved friends. + +(defun num-store (base num) { + (var b base) + (var d (digits-only num)) + (var n (str-len d)) + (eeprom-store-i b n) + + ; Right-pad to 15 before chunking. Without this the final partial + ; chunk stores as a small integer -- "7" becomes 7 -- and %05d + ; restores it as "00007", left-shifting the last digits into + ; oblivion. The true length is stored separately and trimmed on load. + (loopwhile (< (str-len d) 15) (setq d (str-merge d "0"))) + (looprange k 0 3 + (eeprom-store-i (+ b 1 k) (str-to-i (str-part d (* k 5) 5) 10))) +}) + +(defun num-fetch (base) { + (var b base) + (var n (ee-get b 0)) + (if (or (< n 1) (> n 15)) + "" + { + (var acc "") + (looprange k 0 3 + (setq acc (str-merge acc (str-from-n (ee-get (+ b 1 k) 0) "%05d")))) + (str-part acc 0 n) + }) +}) + +(defun member-base (i) (+ ee-members (* i 5))) + +(defun members-save () + (looprange i 0 max-members { + (var m (ix members i)) + (num-store (member-base i) (if (eq m nil) "" (ix m 0))) + (eeprom-store-i (+ (member-base i) 4) + (if (and (not (eq m nil)) (ix m 1)) 1 0)) + })) + +(defun members-load () { + (setq members nil) + (looprange i 0 max-members { + (var num (num-fetch (member-base i))) + (setq members (append members + (list (if (= (str-len num) 0) + nil + (list num (= (ee-get (+ (member-base i) 4) 0) 1)))))) + }) +}) + +; Index of a number in the roster, or -1. Matched on the last 10 digits so +; +15551234567 and 5551234567 are the same person. +(defun member-find (num) { + (var tn (num-tail num)) + (var found -1) + (looprange i 0 max-members { + (var m (ix members i)) + (if (and (= found -1) (not (eq m nil)) + (= (str-cmp tn (num-tail (ix m 0))) 0)) + (setq found i)) + }) + found +}) + +(defun member-allowed (num) { + (var i (member-find num)) + (if (< i 0) false (ix (ix members i) 1)) +}) + +(defun member-free () { + (var slot -1) + (looprange i 0 max-members + (if (and (= slot -1) (eq (ix members i) nil)) (setq slot i))) + slot +}) + +; --- pending requests -------------------------------------------------- +; RAM only. A request that has not been approved by the time the board +; restarts is not worth persisting, and this keeps unknown numbers from +; filling EEPROM. + +(defun pending-add (num) { + (var d (digits-only num)) + (var dup false) + (loopforeach p pending + (if (= (str-cmp (num-tail p) (num-tail d)) 0) (setq dup true))) + (if (and (not dup) (< (length pending) max-pending)) { + (setq pending (append pending (list d))) + (send-roster) + true + } + false) +}) + +(defun pending-drop (num) { + (var d (num-tail num)) + (var out nil) + (loopforeach p pending + (if (not (= (str-cmp (num-tail p) d) 0)) + (setq out (append out (list p))))) + (setq pending out) +}) + +; --- UI actions -------------------------------------------------------- + +(defun member-approve (num) { + (var d (digits-only num)) + (var slot (member-free)) + (if (>= slot 0) { + (setix members slot (list d true)) + (members-save) + (pending-drop d) + (send-roster) + ; Tell them they are in, and send the current state with it so + ; the first message is useful rather than just an acknowledgement. + (if (and sms-on net-att) + (sms-send d (str-merge "VL Link: access granted. Send ? for commands. " + (sms-status-text)))) + true + } + false) +}) + +(defun member-deny (num) { + (pending-drop num) + (send-roster) + true +}) + +(defun member-set (idx en) { + (var i idx) + (var e en) + (var m (ix members i)) + (if (not (eq m nil)) { + (setix members i (list (ix m 0) (= e 1))) + (members-save) + (send-roster) + }) + true +}) + +(defun member-del (idx) { + (var i idx) + (setix members i nil) + (members-save) + (send-roster) + true +}) + +; Owner is member 0, set from the Settings tab. +(defun sms-cfg (num en) { + (var n num) + (var e en) + (setq sms-on (= e 1)) + (setix members 0 (if (= (str-len (digits-only n)) 0) + nil + (list (digits-only n) true))) + (members-save) + (eeprom-store-i ee-sms (if sms-on 1 0)) + (send-status) + (send-roster) + true +}) + +; Roster does not fit in the 100 byte status frame, so it goes as its own +; short frames, one per entry. The UI clears its list on "mb0". +(defun send-roster () { + (looprange i 0 max-members { + (var m (ix members i)) + (send-data (str-merge + "mb " (str-from-n i "%d ") + (if (eq m nil) "- " (str-merge (ix m 0) " ")) + (if (and (not (eq m nil)) (ix m 1)) "1" "0"))) + (sleep 0.05) + }) + (loopforeach p pending { + (send-data (str-merge "pd " p)) + (sleep 0.05) + }) + (send-data "rx") +}) + +; Put the modem into text mode. PDU mode is the default on some firmware +; and every string below assumes text mode, so this must succeed first. +(defun sms-init () { + (at-cmd "AT+CSMS=1" 5.0) + (at-cmd "AT+CMGF=1" 3.0) + (at-cmd "AT+CSCS=\"GSM\"" 3.0) + (at-cmd "AT+CNMI=2,1,0,0,0" 3.0) + (setq sms-ready true) +}) + +; Two-stage handshake: the modem answers AT+CMGS with a ">" prompt, then +; the body is terminated by a raw Ctrl-Z rather than CR/LF. +(defun sms-send (num body) { + (var n num) + (var b body) + + (uart-read at-rx at-max) + (bufclear at-rx) + (uart-put (str-merge "AT+CMGS=\"+" (digits-only n) "\"\r\n")) + + (var t0 (systime)) + (var pos 0) + (var got false) + (loopwhile (and (not got) (< (secs-since t0) 10.0)) { + (var c (uart-read at-rx (- at-max pos) pos -1 0.2)) + (if (> c 0) (setq pos (+ pos c))) + (if (or (>= (str-find at-rx ">") 0) + (>= (str-find at-rx "ERROR") 0)) + (setq got true)) + }) + + (if (>= (str-find at-rx ">") 0) { + ; Body plus the terminating Ctrl-Z in one exact-length + ; buffer, so no null terminator reaches the message. + (var bn (str-len b)) + (var obuf (bufcreate (+ bn 1))) + (bufcpy obuf 0 b 0 bn) + (bufset-u8 obuf bn 26) + (uart-write obuf) + + (uart-read at-rx at-max) + (bufclear at-rx) + (setq t0 (systime)) + (setq pos 0) + (var done false) + (loopwhile (and (not done) (< (secs-since t0) 60.0)) { + (var c2 (uart-read at-rx (- at-max pos) pos -1 0.5)) + (if (> c2 0) (setq pos (+ pos c2))) + (if (or (>= (str-find at-rx "+CMGS") 0) + (>= (str-find at-rx "ERROR") 0)) + (setq done true)) + }) + (>= (str-find at-rx "+CMGS") 0) + } + false) +}) + +(defun sms-help-text () + "VL Link: STATUS TRIP PING LOCK UNLOCK GUARD ON/OFF LOG ON/OFF CLOUD ON/OFF ?") + +(defun sms-admin-help () + "Admin also: ALLOW DENY WHO") + +(defun sms-who-text () { + (var out "VL Link members:") + (looprange i 0 max-members { + (var m (ix members i)) + (if (not (eq m nil)) + (setq out (str-merge out " " (ix m 0) + (if (ix m 1) "(on)" "(off)")))) + }) + out +}) + +(defun n-tfet () (if can-ok (canget-temp-fet (ix can-devs 0)) 0.0)) +(defun n-tmot () (if can-ok (canget-temp-motor (ix can-devs 0)) 0.0)) +(defun n-kmh () (if can-ok (* (fabs (canget-speed (ix can-devs 0))) 3.6) 0.0)) + +(defun sms-status-text () + (str-merge + "VL Link " + (str-from-n can-vin "%.1fV ") + (if (>= batt-pct 0.0) (str-from-n batt-pct "%.0f%% ") "") + (str-from-n (n-tfet) "%.0fC/") + (str-from-n (n-tmot) "%.0fC ") + (str-from-n (n-kmh) "%.0fkm/h ") + (if can-ok "CAN ok " "CAN DOWN ") + (if net-att (str-merge "LTE " (str-from-n lte-rssi "%ddBm ")) "LTE down ") + (if log-on "LOG on" "LOG off") + (cond ((= lock-state 1) " LOCKED") + ((= lock-state 0) "") + (t " lock:?")) + (if guard-on " GUARD" "") + (if mqtt-on (if (= mqtt-state 2) " cloud:up" " cloud:DOWN") ""))) + +; Act on one command. Returns the reply text, or "" for nothing recognised. +; Commands available to any approved member. +(defun sms-dispatch (body) { + (var b (str-to-upper (ltrim body))) + (cond + ((starts b "?") (sms-help-text)) + ((starts b "HELP") (sms-help-text)) + ((starts b "STATUS") (sms-status-text)) + ((starts b "TRIP") (trip-text)) + ((starts b "LOG ON") + (if (log-begin) "VL Link: logging started" "VL Link: could not start")) + ((starts b "LOG OFF") { (log-end) "VL Link: logging stopped" }) + ((starts b "GUARD ON") + { (setq guard-on true) (setq guard-fired false) (settings-save) + "VL Link: guard armed" }) + ((starts b "GUARD OFF") + { (setq guard-on false) (settings-save) "VL Link: guard off" }) + ((starts b "UNLOCK") + (if (lock-set-wait false) + "VL Link: unlocked (confirmed)" + "VL Link: unlock sent but NOT confirmed by the controller")) + ((starts b "LOCK") + (if (lock-set-wait true) + "VL Link: locked (confirmed)" + "VL Link: lock sent but NOT confirmed by the controller")) + ((starts b "CLOUD OFF") + { (mqtt-set 0 mqtt-int) "VL Link: cloud off" }) + ((starts b "CLOUD ON") + { (mqtt-set 1 mqtt-int) "VL Link: cloud on" }) + ((starts b "PING") "VL Link: alive") + (t "")) +}) + +; Read the mailbox and handle at most one message per call. Handling a +; single message keeps the parsing simple and bounded -- the next cycle +; picks up anything still queued. +; +; Text-mode listing looks like: +; +CMGL: 3,"REC UNREAD","+15551234567",,"26/08/05,12:00:00-20" +; STATUS +; Does this look like someone trying to issue a command? Carrier +; notifications, delivery reports and spam all arrive as ordinary SMS from +; unknown numbers, and replying to them is worse than useless: a bounce +; notification triggers a reply, which bounces, which triggers another. +; Strip leading whitespace so an anchored match is not defeated by a stray +; space or newline at the start of the body. +(defun ltrim (sv) { + (var st sv) + (var i 0) + (var n (str-len st)) + (loopwhile (and (< i n) + (or (= (bufget-u8 st i) 32) + (= (bufget-u8 st i) 10) + (= (bufget-u8 st i) 13))) + (setq i (+ i 1))) + (if (>= i n) "" (if (= i 0) st (str-part st i))) +}) + +; Commands must be at the START of the message, not merely present in it. +; "Message Blocking is active" contains LOCK, and "unlock the garage" would +; otherwise unlock the vehicle. +(defun starts (b w) (= (str-find b w) 0)) + +(defun cmd-like (body) { + (var b (str-to-upper (ltrim body))) + (or (starts b "?") (starts b "HELP") (starts b "STATUS") + (starts b "TRIP") (starts b "PING") (starts b "LOCK") + (starts b "UNLOCK")(starts b "GUARD") (starts b "LOG") + (starts b "CLOUD") (starts b "ALLOW") (starts b "DENY") + (starts b "WHO")) +}) + +; Admin (member 0) can manage the roster by text. Returns "" if the body is +; not an admin command, so it falls through to the member command set. +(defun sms-admin-dispatch (body) { + (var b (str-to-upper (ltrim body))) + (cond + ((starts b "WHO") (sms-who-text)) + ((starts b "ALLOW") { + (var n (digits-only body)) + (if (< (str-len n) 7) + "VL Link: send ALLOW followed by the number" + (if (member-approve n) + (str-merge "VL Link: " n " approved") + "VL Link: no free member slots")) + }) + ((starts b "DENY") { + (var n (digits-only body)) + (if (< (str-len n) 7) + "VL Link: send DENY followed by the number" + { (member-deny n) (str-merge "VL Link: " n " denied") }) + }) + (t "")) +}) + +(defun sms-poll () { + (if (not sms-ready) (sms-init)) + + (at-cmd "AT+CMGL=\"REC UNREAD\"" 10.0) + (var i (str-find at-rx "+CMGL:")) + + (if (>= i 0) { + (var msg (str-part at-rx (+ i 7))) + + ; Header line, up to the first CR: + ; ,"","",[],"" + ; + ; Parsed by comma position, NOT by walking quote pairs. + ; Whether the fields are quoted depends on the modem and on + ; AT+CSDH, and a quote walk silently lands on the timestamp + ; when they are not -- which then strips to digits and looks + ; like a plausible but completely wrong phone number. + (var he (str-find msg "\r")) + (var hdr (if (> he 0) (str-part msg 0 he) msg)) + (var parts (str-split hdr ",")) + + (var idx (if (>= (length parts) 1) (str-to-i (ix parts 0) 10) -1)) + (var sender (if (>= (length parts) 3) (digits-only (ix parts 2)) "")) + + ; Body is the line after the header. + (var nl (str-find msg "\n")) + (var body "") + (if (>= nl 0) { + (var after (str-part msg (+ nl 1))) + (var nl2 (str-find after "\r")) + (setq body (if (> nl2 0) (str-part after 0 nl2) after)) + }) + + (print (list "SMS from" sender "->" body)) + + (if (member-allowed sender) { + ; Admin gets the roster commands as well. + (var reply (if (= (member-find sender) 0) + (sms-admin-dispatch body) + "")) + (if (= (str-len reply) 0) (setq reply (sms-dispatch body))) + (if (> (str-len reply) 0) (sms-send sender reply)) + } + { + ; Unknown, or known but switched off. Surface it to + ; the owner rather than silently dropping it, and + ; tell the sender once so they are not left waiting. + (var known (>= (member-find sender) 0)) + (print (list "SMS from unapproved" sender "known:" known + "cmd-like:" (cmd-like body))) + (if (and (not known) (cmd-like body) (pending-add sender)) { + (sms-send sender "VL Link: access request sent to the owner") + ; Tell the admin, so approval does not depend + ; on happening to open the app. + (notify (str-merge "VL Link: access requested by " sender + ". Reply ALLOW " sender " or DENY " sender)) + }) + }) + + ; Delete so the mailbox cannot fill and stall reception. + (if (>= idx 0) + (at-cmd (str-merge "AT+CMGD=" (str-from-n idx "%d")) 10.0)) + }) +}) + +;;; ------------------------------------------------------------------ +;;; MQTT telemetry +;;; +;;; Runs inside the modem thread, which owns the UART, so it cannot collide +;;; with the status polling above it or with SMS. +;;; +;;; The session is opened once and held, not opened per sample. SMCONN is a +;;; TCP handshake plus a CONNECT/CONNACK round trip and regularly takes more +;;; than ten seconds on Cat-M1; doing that every interval would mean the +;;; radio never idles and any interval under a minute would be fiction. +;;; +;;; Every failure path lands somewhere visible. mqtt-last is reported to the +;;; UI, because a telemetry feature that stops working silently is worse +;;; than one that was never enabled. +;;; ------------------------------------------------------------------ + +; --- logging ----------------------------------------------------------- +; Off by default. MQTT fails in ways that look identical from the outside +; -- a rejected token, a dead data context and an unreachable broker all +; just sit there not publishing -- and only the modem's own reply +; distinguishes them. Set this true from the REPL and every connect, +; refusal and inbound UI form is printed, with the raw AT reply attached. +; +; Worth knowing before you turn it on: the SIM7070G answers a CONNECT that +; the broker REJECTED with "+CME ERROR: operation not allowed", which +; reads like a session conflict and is very often just a bad token. +(def mqtt-log false) + +(defun mlog (tag) + (if mqtt-log (print (str-merge "[mqtt] " tag)) nil)) + +; Prints the tag followed by whatever the modem last said. at-rx is a +; null-terminated byte buffer, so it prints as a string; CR and LF show as +; escapes, which is what you want when the interesting part is an error +; code buried in a multi-line reply. +(defun mlog-at (tag) + (if mqtt-log (print (str-merge "[mqtt] " tag " <- " at-rx)) nil)) + +; Bring up the packet data context. AT+CNACT on its own is not enough: the +; context profile has to be configured first, and on a SIM whose carrier +; does not push an APN this is the difference between a modem that attaches +; and one that also passes traffic. An empty APN asks the network for one, +; which is what the T-Mobile SIM this was developed against wants. +; +; Returns quickly when the context is already up, which is the common case. +(defun pdp-up () + (if (>= (str-find (at-cmd "AT+CNACT?" 3.0) "+CNACT: 0,1") 0) + true + { + (at-cmd (str-merge "AT+CNCFG=0,1,\"" mqtt-apn "\"") 3.0) + (at-cmd "AT+CNACT=0,1" 5.0) + + ; Activation is asynchronous. OK comes back long before + ; +APP PDP: 0,ACTIVE does, so poll the state instead of + ; sleeping a guessed amount of time and hoping. + (var t0 (systime)) + (var up false) + (loopwhile (and (not up) (< (secs-since t0) 20.0)) { + (sleep 1.0) + (setq up (>= (str-find (at-cmd "AT+CNACT?" 3.0) "+CNACT: 0,1") 0)) + }) + (if up + (mlog "data context up") + (mlog-at "data context FAILED")) + (if up (setq mqtt-state 1)) + up + })) + +(defun mqtt-conf () { + ; Port UNQUOTED. SIMCom's spec is + ; AT+SMCONF="URL","", + ; and this firmware rejects the quoted form outright. The rejection + ; is silent in the sense that matters: SMCONF answers, but the URL + ; never takes, and the failure surfaces later as + ; "+CME ERROR: operation not allowed" on SMCONN -- which reads like + ; a session conflict and is actually the modem saying it has no + ; usable broker address to connect to. + (at-cmd (str-merge "AT+SMCONF=\"URL\",\"" mqtt-host "\"," + (str-from-n mqtt-port "%d")) 3.0) + (at-cmd (str-merge "AT+SMCONF=\"CLIENTID\",\"" mqtt-client "\"") 3.0) + (at-cmd (str-merge "AT+SMCONF=\"USERNAME\",\"" mqtt-user "\"") 3.0) + (at-cmd (str-merge "AT+SMCONF=\"PASSWORD\",\"" mqtt-pass "\"") 3.0) + ; 120 s, not 600. It has to outlast the publish interval so an + ; idle session is not dropped between samples, but a long keepalive + ; also means the broker holds a DEAD session that long after the + ; Link vanishes -- which is what makes a quick power cycle look + ; like the network is refusing to let you back on. + (at-cmd "AT+SMCONF=\"KEEPTIME\",120" 3.0) + (at-cmd "AT+SMCONF=\"CLEANSS\",1" 3.0) +}) + +(defun mqtt-connected () + (>= (str-find (at-cmd "AT+SMSTATE?" 3.0) "+SMSTATE: 1") 0)) + +(defun mqtt-connect () + (if (not (pdp-up)) + { (setq mqtt-last 4) false } + { + ; Unconditional, and deliberately not clever about it. + ; + ; The modem owns the session and outlives this script, an ESP + ; reset and any credential change made in the UI. An earlier + ; version asked SMSTATE and adopted whatever it found, to skip + ; a redundant handshake after a restart; that turned out to + ; adopt sessions opened with a token the user had since + ; revoked, and to disagree with the modem about whether a + ; session existed at all -- SMSTATE reporting 0 while SMCONN + ; answered "+CME ERROR: operation not allowed", which is the + ; modem saying it is already connected. + ; + ; SMDISC on a modem with no session is harmless. Guessing is + ; not. One wasted handshake per connect is the right price. + (at-cmd "AT+SMDISC" 5.0) + (sleep 0.5) + (mqtt-conf) + (mlog (str-merge "connecting to " mqtt-host ":" + (str-from-n mqtt-port "%d") + " user=" (if (= (str-len mqtt-user) 0) + "(EMPTY -- broker will refuse)" mqtt-user) + " client=" mqtt-client)) + (var r (at-cmd "AT+SMCONN" 30.0)) + (if (and (at-ok r) (mqtt-connected)) + { + (setq mqtt-state 2) + (setq mqtt-fails 0) + (setq mqtt-last 1) + true + } + { + (mlog-at "SMCONN refused") + (setq mqtt-last 3) false + }) + })) + +; Build the telemetry object. +; +; Values the package cannot vouch for are left out rather than sent as +; zero, the same rule the UI uses for pack voltage: a missing key on the +; dashboard is honest, a zero is a lie that plots. Battery percentage comes +; from the controller, which knows the cell count and cutoffs -- it is not +; guessed from pack voltage here, because a voltage-to-percent curve +; without a chemistry is worse than no number at all. +; +; Built in three parts. str-merge allocates per argument and this runs on a +; 2560 cons cell heap. +(defun mqtt-json () { + (var id (if can-ok (ix can-devs 0) -1)) + + ; Appended one field at a time into a growing string rather than + ; built from nested str-merge. Nesting three levels of str-merge + ; over a dozen str-from-n calls holds every intermediate result + ; live at once, which is what made this thread want an unusual + ; stack; appending keeps exactly two strings alive per step. + (var j "{\"can\":") + (setq j (str-merge j (if can-ok "1" "0"))) + (setq j (str-merge j (str-from-n lte-rssi ",\"rssi\":%d"))) + (if (> can-vin 0.0) + (setq j (str-merge j (str-from-n can-vin ",\"voltage\":%.2f")))) + (if (>= batt-pct 0.0) + (setq j (str-merge j (str-from-n batt-pct ",\"battery\":%.0f")))) + + (if can-ok { + ; to-i, not "%d" on the float canget-rpm returns. + ; str-from-n dispatches on the runtime type of its + ; argument rather than on the format string, so a float + ; reaching a %d conversion prints garbage. + (setq j (str-merge j (str-from-n (to-i (canget-rpm id)) ",\"rpm\":%d"))) + (setq j (str-merge j (str-from-n (* (fabs (canget-speed id)) 3.6) ",\"speed_kmh\":%.1f"))) + (setq j (str-merge j (str-from-n (canget-current-in id) ",\"current_in\":%.1f"))) + (setq j (str-merge j (str-from-n (canget-temp-fet id) ",\"temp_fet\":%.1f"))) + (setq j (str-merge j (str-from-n (canget-temp-motor id) ",\"temp_motor\":%.1f"))) + }) + + (setq j (str-merge j (str-from-n (/ trip-d 1000.0) ",\"trip_km\":%.3f"))) + (setq j (str-merge j (str-from-n trip-wh ",\"trip_wh\":%.1f"))) + (setq j (str-merge j (str-from-n trip-ah ",\"trip_ah\":%.2f"))) + (setq j (str-merge j ",\"moving\":" (if moving "1" "0"))) + (setq j (str-merge j ",\"guard\":" (if guard-on "1" "0"))) + (setq j (str-merge j (str-from-n lock-state ",\"lock\":%d"))) + (setq j (str-merge j ",\"logging\":" (if log-on "1" "0") "}")) + j +}) + +; One publish. QoS 1, so the modem waits for the PUBACK and OK actually +; means the broker took it -- at QoS 0 the reply only says the bytes left +; the module. +(defun mqtt-publish () { + (var json (mqtt-json)) + (var n (str-len json)) + + (at-raw (str-merge "AT+SMPUB=\"" mqtt-topic "\"," + (str-from-n n "%d") ",1,0")) + + (if (not (at-prompt 5.0)) + { + (mlog-at "no payload prompt after SMPUB") + (setq mqtt-last 2) + (setq mqtt-state 1) + false + } + { + ; Exactly n bytes. uart-write sends array->size, which for + ; an LBM string includes the null terminator -- the modem + ; counts out the length it was promised and the extra byte + ; becomes the first character of the next command. + (var buf (bufcreate n)) + (bufcpy buf 0 json 0 n) + (uart-write buf) + + (uart-read at-rx at-max) + (bufclear at-rx) + + (if (at-ok (at-wait 20.0)) + { + (setq mqtt-t0 (systime)) + (setq mqtt-fails 0) + (setq mqtt-last 1) + true + } + { + (mlog-at "publish not acknowledged") + (setq mqtt-fails (+ mqtt-fails 1)) + (setq mqtt-last 2) + ; One lost publish is a bad sample; two in a row is + ; a dead session. Drop to reconnect rather than + ; publishing into a socket nobody is reading. + (if (>= mqtt-fails 2) (setq mqtt-state 1)) + false + }) + }) +}) + +; Called once per modem cycle. At most one connect attempt or one publish +; per call, so a broker that is refusing connections cannot monopolise the +; thread and starve the LTE panel or SMS. +(defun mqtt-tick () { + (if mqtt-redo { + (setq mqtt-redo false) + ; Unconditional, not gated on mqtt-state. The modem holds + ; its own session and outlives both this script's idea of + ; the state and any credential change made in the UI -- + ; so after a settings change there can be a live session + ; on the OLD token while mqtt-state reads 0. Leave it + ; there and mqtt-connect adopts it, and the new token + ; never gets used. + (mlog "settings changed; dropping any existing session") + (at-cmd "AT+SMDISC" 5.0) + (setq mqtt-state 0) + (setq mqtt-t0 0) + (setq mqtt-retry-t 0) + (setq mqtt-wait 5.0) + (setq mqtt-fails 0) + }) + + (if (not (and mqtt-on modem-rdy net-att)) + ; The context does not survive losing the network, so do not + ; pretend it did. Reset the backoff too: it exists to stop + ; hammering a broker that keeps saying no, and a network that + ; just came back is a genuinely new situation, not another no. + (if (> mqtt-state 0) { + (setq mqtt-state 0) + (setq mqtt-last 0) + (setq mqtt-wait 5.0) + (setq mqtt-retry-t 0) + }) + + (if (< mqtt-state 2) + ; Backed off, for the same reason can-scan is: a broker + ; with the wrong credentials will never succeed, and + ; retrying it every three seconds burns data and battery + ; for as long as the vehicle is parked. + (if (>= (secs-since mqtt-retry-t) mqtt-wait) { + ; Clear the previous verdict before trying again. + ; Without this the row keeps reporting the first + ; failure after boot -- which is normal, the modem + ; has not attached yet -- long after the link is + ; up, and the whole startup looks broken when it is + ; just slow. + (setq mqtt-last 0) + (setq mqtt-retry-t (systime)) + (if (mqtt-connect) + (setq mqtt-wait 5.0) + (setq mqtt-wait (if (> (* mqtt-wait 2.0) mqtt-wait-max) + mqtt-wait-max + (* mqtt-wait 2.0)))) + }) + (if (>= (secs-since mqtt-t0) (* 1.0 mqtt-int)) + ; Checked, not assumed. SMPUB against a session the + ; modem has already dropped answers OK on some firmware + ; revisions, which would report success forever. + (if (mqtt-connected) + (mqtt-publish) + (setq mqtt-state 1))))) +}) + +; --- called from the UI ------------------------------------------------ +; +; These run in the event handler thread, which does NOT own the UART, so +; none of them talk to the modem. They write EEPROM, update the mirrors and +; raise mqtt-redo; the modem thread picks the change up on its next cycle. + +(defun mqtt-set (en interval) { + (var e en) + (var i interval) + (setq mqtt-on (= e 1)) + ; Floor of 15 s. The modem cycle is 3 s plus however long the + ; status commands take, so anything under that is a request the + ; hardware cannot honour and would only look broken. + (setq mqtt-int (if (< i 15) 15 (if (> i 3600) 3600 i))) + (settings-save) + (setq mqtt-redo true) + true +}) + +(defun mqtt-broker (host port user pass) { + (var h host) (var p port) (var u user) (var w pass) + (setq mqtt-host (if (= (str-len h) 0) "mqtt.thingsboard.cloud" h)) + (setq mqtt-port (if (or (< p 1) (> p 65535)) 1883 p)) + ; An empty username is never sent on purpose from the UI -- it + ; means the field had not been populated from the device yet when + ; Save was pressed. Overwriting a good token with nothing turns a + ; working link into "broker refused" with no clue why, so keep + ; what is stored and say so. Clearing it deliberately is done from + ; the REPL, where the intent is unambiguous. + (if (and (= (str-len u) 0) (> (str-len mqtt-user) 0)) + (mlog "ignored an empty username; keeping the stored one") + (setq mqtt-user u)) + (setq mqtt-pass w) + (str-store ee-host sl-host mqtt-host) + (str-store ee-user sl-user mqtt-user) + (mlog (str-merge "broker set: " mqtt-host " user len " + (str-from-n (str-len mqtt-user) "%d"))) + (str-store ee-pass sl-pass mqtt-pass) + (eeprom-store-i ee-mqtt-port mqtt-port) + (setq mqtt-redo true) + true +}) + +(defun mqtt-ident (client topic apn) { + ; tp, not t. `t` is the built-in true and shadowing it raises + ; "Cannot redefine built-in" -- which, inside the trap in + ; event-handler, means this function silently did nothing at all. + (var c client) (var tp topic) (var a apn) + (setq mqtt-client (if (= (str-len c) 0) "vl-link" c)) + (setq mqtt-topic (if (= (str-len tp) 0) "v1/devices/me/telemetry" tp)) + (setq mqtt-apn a) + (str-store ee-client sl-client mqtt-client) + (str-store ee-topic sl-topic mqtt-topic) + (str-store ee-apn sl-apn mqtt-apn) + (setq mqtt-redo true) + (send-cfg) + true +}) + +; Config strings do not fit in the status frame and can contain characters +; that would split it, so each goes out as its own frame -- the same shape +; the roster uses. "-" stands for empty. +(defun cf-frame (i s) + (send-data (str-merge "cf " (str-from-n i "%d ") + (if (= (str-len s) 0) "-" s)))) + +(defun send-cfg () { + (cf-frame 0 mqtt-apn) (sleep 0.05) + (cf-frame 1 mqtt-host) (sleep 0.05) + (cf-frame 2 (str-from-n mqtt-port "%d")) (sleep 0.05) + (cf-frame 3 mqtt-user) (sleep 0.05) + (cf-frame 4 mqtt-pass) (sleep 0.05) + (cf-frame 5 mqtt-client) (sleep 0.05) + (cf-frame 6 mqtt-topic) (sleep 0.05) + (cf-frame 7 (str-from-n mqtt-int "%d")) +}) + + +;;; ------------------------------------------------------------------ +;;; Trip metering, movement guard, remote lock +;;; ------------------------------------------------------------------ + +; Amp/watt hours are NOT available through canget-*. They arrive in their +; own CAN status frames, which the firmware decodes internally but never +; exposes to lisp, so they are decoded here from the raw frame. +; +; CAN_PACKET_STATUS_2 = 14 amp hours, amp hours charged +; CAN_PACKET_STATUS_3 = 15 watt hours, watt hours charged +; +; Both int32 / 1e4, big-endian, which is bufget-i32's default. +; Extended id is node_id | (packet_type << 8). +(defun can-frame (cid cdata) { + (var i cid) + (var d cdata) + (var pkt (shr i 8)) + (if (= pkt 14) (setq bat-ah (/ (bufget-i32 d 0) 10000.0))) + (if (= pkt 15) (setq bat-wh (/ (bufget-i32 d 0) 10000.0))) +}) + +(defun fabs (x) { (var v x) (if (< v 0) (- 0 v) v) }) + +(defun owner-num () { + (var m (ix members 0)) + (if (eq m nil) "" (ix m 0)) +}) + +(defun notify (txt) + (if (and sms-on net-att (> (str-len (owner-num)) 0)) + (sms-send (owner-num) txt))) + +(defun trip-start () { + (setq trip-ah0 bat-ah) + (setq trip-wh0 bat-wh) + (setq trip-d0 (if can-ok (canget-dist (ix can-devs 0)) 0.0)) + (setq trip-vmax 0.0) + (setq trip-ah 0.0) + (setq trip-wh 0.0) + (setq trip-d 0.0) +}) + +(defun trip-update () { + (setq trip-ah (- bat-ah trip-ah0)) + (setq trip-wh (- bat-wh trip-wh0)) + (if can-ok { + (setq trip-d (- (canget-dist (ix can-devs 0)) trip-d0)) + (var v (fabs (canget-speed (ix can-devs 0)))) + (if (> v trip-vmax) (setq trip-vmax v)) + }) +}) + +(defun trip-text () + (str-merge + "VL Link trip: " + (str-from-n (/ trip-d 1000.0) "%.2fkm ") + (str-from-n trip-wh "%.1fWh ") + (str-from-n trip-ah "%.2fAh ") + (str-from-n (* trip-vmax 3.6) "max %.1fkm/h"))) + +; Wh per km over this trip, extrapolated against remaining pack energy is +; not knowable without a capacity figure, so this reports efficiency only +; and leaves range to the rider. +(defun trip-eff () + (if (> trip-d 50.0) (/ trip-wh (/ trip-d 1000.0)) 0.0)) + +; --- remote lock ------------------------------------------------------- +; +; Actuation is a LATCH held by a package on the ESC: app-disable-output -1 +; disables output indefinitely and the ESC keeps it that way, so the Link +; only has to send the request once and can then go quiet. +; +; The cost of a latch is that nothing reveals a lost command -- there is no +; heartbeat whose absence would show up. So every request is confirmed: +; the Link asks, the ESC package calls (lock-ack n) back over CAN, and +; nothing here claims success until that arrives. +; +; The ESC package must provide: +; (lock) latch locked, persist across reboot +; (unlock) release, persist +; (lock-report id) can-cmd "(lock-ack <0|1>)" back to CAN node id + +; Called BY the ESC package. This is the only source of truth for lock state. +(defun lock-ack (n) { + (var v n) + (setq lock-state v) + (setq lock-on (= v 1)) + (setq lock-ack-t (systime)) + (send-status) +}) + +(defun lock-send () + (if can-ok (can-cmd (ix can-devs 0) (if lock-want "(lock)" "(unlock)")))) + +; Called BY the ESC package with a 0.0-1.0 level. +(defun batt-ack (v) { + (var x v) + (setq batt-pct (* 100.0 x)) + (setq batt-ack-t (systime)) +}) + +(defun batt-query () + (if can-ok + (can-cmd (ix can-devs 0) + (str-merge "(batt-report " (str-from-n (can-local-id) "%d") ")")))) + +(defun lock-query () + (if can-ok + (can-cmd (ix can-devs 0) + (str-merge "(lock-report " (str-from-n (can-local-id) "%d") ")")))) + +; Resend until confirmed, and treat a stale ack as unknown rather than +; assuming the last thing we saw is still true. +(defun lock-thd () + (loopwhile true { + (if can-ok { + (if (not (= lock-state (if lock-want 1 0))) (lock-send)) + (lock-query) + (batt-query) + (if (> (secs-since batt-ack-t) 15.0) (setq batt-pct -1.0)) + (if (> (secs-since lock-ack-t) 15.0) { + (setq lock-state -1) + (setq lock-on false) + }) + }) + (sleep 3.0) +})) + +; Request a state and wait briefly for confirmation. Returns true only if +; the ESC actually confirmed it. +(defun lock-set-wait (want) { + (var w want) + (setq lock-want w) + (eeprom-store-i ee-lock (if w 1 0)) + (lock-send) + (var t0 (systime)) + (loopwhile (and (not (= lock-state (if w 1 0))) (< (secs-since t0) 6.0)) { + (lock-query) + (sleep 0.5) + }) + (send-status) + (= lock-state (if w 1 0)) +}) + +; Called once a second from the CAN thread. +(defun motion-tick () { + (var r (if can-ok (fabs (canget-rpm (ix can-devs 0))) 0.0)) + (var mv (> r rpm-move)) + + (if (and mv (not moving)) { + (setq moving true) + (trip-start) + + ; The guard reports, it does not intervene. Knowing the bike + ; is moving is most of the value and none of the risk. + (if (and guard-on (not guard-fired)) { + (setq guard-fired true) + (notify "VL Link: movement detected while guarded") + }) + }) + + (if mv (trip-update)) + + (if (and (not mv) moving) (setq moving false)) +}) + + + +;;; +;;; This uses the firmware's own log subsystem rather than writing CSV by +;;; hand: log-config-field declares each column, log-start opens the file, +;;; log-send-f32 pushes a row. The firmware writes /sdcard/log_can/log_NNN.csv +;;; in VESC's native format, which VESC Tool's log analysis page reads +;;; directly. Nothing here needs to know the file layout. +;;; ------------------------------------------------------------------ + +(defun sd-mount () { + (var r (trap (f-connect pin-sd-mosi pin-sd-miso pin-sd-sck pin-sd-cs))) + (setq sd-ok (eq (ix r 0) 'exit-ok)) + sd-ok +}) + +; One entry per field: (key name unit precision value-form). +; The value form is built as a list and evaluated per row, so the CAN id +; is baked in at build time rather than looked up every sample. +(defun build-loglist () { + (var res nil) + (var devs (live-devs)) + + (loopforeach d devs + (loopforeach fl can-fields { + (var k (str-merge (str-from-n d "V%d_") (ix fl 0))) + (var nm (str-merge (str-from-n d "V%d ") (ix fl 1))) + (setq res (append res (list + (list k nm (ix fl 2) (ix fl 3) (list (ix fl 4) d))))) + })) + + ; LTE columns are appended once, not per device. + (if log-lte + (setq res (append res (list + (list "lte_rssi" "LTE Signal" "dBm" 0 '(+ lte-rssi 0)) + (list "lte_att" "LTE Attached" "" 0 '(if net-att 1 0)))))) + res +}) + +(defun log-configure () { + (var i 0) + (loopforeach fl loglist { + (log-config-field log-id i + (ix fl 0) ; key + (ix fl 1) ; name + (ix fl 2) ; unit + (ix fl 3) ; precision + false ; is-relative + false) ; is-timestamp + (setq i (+ i 1)) + }) +}) + +(defun log-thd () + (loopwhile log-on { + (log-send-f32 log-id 0 + (map (fn (x) (eval (ix x -1))) loglist)) + (sleep (/ 1.0 log-rate)) +})) + +(defun log-end () { + (log-stop log-id) + (if log-on { + (setq log-on false) + (wait log-thd-id) + }) + (send-status) +}) + +(defun log-begin () { + (if log-on (log-end)) + (if (not sd-ok) (sd-mount)) + + (setq loglist (build-loglist)) + + ; An empty list means nothing on the bus is broadcasting status + ; frames -- the canget-* getters would all return stale zeros. + (if (or (not sd-ok) (eq loglist nil)) + false + { + (log-configure) + (log-start log-id (length loglist) log-rate true false) + (setq log-on true) + (setq log-thd-id (spawn 200 log-thd)) + (send-status) + true + }) +}) + +; Pack voltage of the first node in the list. Vin arrives in CAN status +; frame 5, so a node found only by ping has nothing to read -- return -1 +; rather than the stale zero canget-vin would hand back. +(defun node-vin (lst) { + (var l lst) + (if (eq l nil) + -1.0 + { + (var id (ix l 0)) + (var a (can-msg-age id 5)) + (if (or (eq a nil) (> a 2.0)) -1.0 (canget-vin id)) + }) +}) + +; can-list-devs returns every id ever seen, with no expiry. Keep only the +; ones whose status-1 frame is fresh. Returns nil if none are. +(defun live-devs () + (filter (fn (id) { + (var a (can-msg-age id 1)) + (and (not (eq a nil)) (< a 2.0)) + }) + (can-list-devs))) + +; Evaluate one form from the UI. +; +; The trap is here so a malformed message cannot kill this thread -- but a +; bare (trap ...) DISCARDS the error, and that is how a "Cannot redefine +; built-in" hid for an entire debugging session while the only visible +; symptom was a setting that quietly refused to stick. Keep the trap, print +; the error. +(defun ui-eval (data) { + (var d data) + (if mqtt-log (print (str-merge "[rx] " d)) nil) + (var r (trap (eval (read d)))) + (if (eq (car r) 'exit-error) + (print (list "[rx] ERROR" (ix r 1) d)) + nil) +}) + +(defun event-handler () + (loopwhile true + (recv + ((event-data-rx . (? data)) (ui-eval data)) + ((event-can-eid . ((? cid) . (? cdata))) (trap (can-frame cid cdata))) + (_ nil) +))) + + +;;; ------------------------------------------------------------------ +;;; Init +;;; ------------------------------------------------------------------ + +(defun init () { + ; Modem pins first. hw_init() on this board is empty, so nothing + ; is driven at boot and the status pin needs a settle before it + ; reads truthfully. + (gpio-configure pin-pwrkey 'pin-mode-out) + (gpio-write pin-pwrkey 0) + (gpio-configure pin-mstat 'pin-mode-in) + (sleep 0.5) + + ; CAN transceiver. This is the one line every VL Link package must + ; have: without it the transceiver stays in standby, can-scan sees + ; only the local device, and there are no bus errors to explain it. + (gpio-configure pin-canstb 'pin-mode-out) + (gpio-write pin-canstb 0) + + ; LED + GNSS power rail. The rail alone does not light the LEDs; + ; they also need data on pin-leddat. + (gpio-configure pin-ledpwr 'pin-mode-out) + (gpio-write pin-ledpwr 1) + + (rgbled-init pin-leddat 1) ; 1 = WS2812B timing + (setq led-buf (rgbled-buffer 2 0 1)) ; 2 LEDs, GRB, gamma on + (build-hue-tab) + + ; Allocate the AT buffer on the heap. See the note at its def. + (setq at-rx (bufcreate 512)) + + (settings-load) + + ; Mount the SD card. This is also what makes VESC Tool's file + ; browser work -- COMM_FILE_LIST reads from file_basepath, which + ; stays unset until something calls f-connect. + (sd-mount) + + (uart-start modem-uart modem-rx modem-tx modem-baud) + + (event-register-handler (spawn 200 event-handler)) + ; Only event-data-rx. Express has no event-shutdown -- that is an ESC-only + ; event, and enabling it throws, which aborts init before any thread + ; spawns and takes CAN and LTE down with it. + (event-enable 'event-data-rx) + (event-enable 'event-can-eid) +}) + + +;;; ------------------------------------------------------------------ +;;; Threads +;;; ------------------------------------------------------------------ + +; Optional auto-start. Waits for a node to appear rather than firing blind, +; because log-begin refuses to start with an empty field list. +(defun log-boot-thd () + (if log-at-boot { + (var waited 0) + (loopwhile (and (not can-ok) (< waited 60)) { + (sleep 1.0) + (setq waited (+ waited 1)) + }) + (if can-ok { + (sleep 2.0) + (trap (log-begin)) + }) +})) + +(defun main () { + (init) + + ; --- LEDs, 25 Hz ------------------------------------------------- + ; LED 0 = CAN solid green = node present, rainbow = searching + ; LED 1 = LTE flashing green = registered, amber breathe = searching, + ; solid red = no SIM, blue breathe = modem booting + ; + ; Both waveforms are computed every frame and one is selected, so no + ; code path is reached for the first time only after a mode change. + (loopwhile-thd ("leds" 200) true { + (setq led-phase (mod (+ led-phase 1) led-period)) + (var hue-ix (mod led-phase hue-n)) + (var pulse (+ 0.06 (* 0.11 (+ 1.0 (sin (* 6.2832 (/ hue-ix (* 1.0 hue-n)))))))) + (var flash (if (< (mod led-phase 24) 8) 0.30 0.015)) + + (if can-ok + (rgbled-color led-buf 0 col-green 0.25) + (rgbled-color led-buf 0 (bufget-u32 hue-tab (* hue-ix 4)) pulse)) + + (rgbled-color led-buf 1 lte-col + (if (= lte-mode 0) 0.22 (if (= lte-mode 1) pulse flash))) + + (rgbled-update led-buf) + (sleep 0.04) + }) + + ; --- CAN presence, 1 Hz ------------------------------------------ + ; Two sources, because neither alone is correct: + ; + ; can-list-devs is free, but the firmware never expires an entry -- + ; stat_msgs[] is only cleared at init, so a node that has gone away + ; is still listed forever. Hence the age filter in live-devs. + ; + ; can-scan actively pings and is always current, but it walks all + ; 254 ids and takes up to ~2.5 s on an empty bus, so it is backed + ; off and confined to this thread. + ; + ; can-ok is only written when there is real evidence either way, + ; otherwise it would flicker on the cycles where no scan runs. + (loopwhile-thd ("can" 200) true { + (var devs (live-devs)) + (if (not (eq devs nil)) + { + (setq scan-tick 0) + (setq scan-wait 3) + (setq can-devs devs) + (setq can-ok true) + } + { + (setq scan-tick (+ scan-tick 1)) + (if (or rescan-req (>= scan-tick scan-wait)) { + (setq scan-tick 0) + (setq rescan-req false) + (setq devs (can-scan)) + (setq can-devs devs) + (setq can-ok (not (eq devs nil))) + ; Back off when the bus stays empty. A scan + ; pings all 254 ids, so repeating it every + ; few seconds forever is a lot of traffic + ; for a passive accessory to put on a bus + ; it shares with other packages. + (if (eq devs nil) + (setq scan-wait (if (> (* scan-wait 2) scan-wait-max) + scan-wait-max + (* scan-wait 2))) + (setq scan-wait 3)) + }) + }) + (setq can-vin (node-vin can-devs)) + (trap (motion-tick)) + (sleep 1.0) + }) + + ; --- Modem / LTE ------------------------------------------------- + ; Stack is deliberately large. at-cmd nests str-merge, a blocking + ; uart-read and str-find; at 400 words this thread died inside + ; modem-sync, which left modem-on true and modem-rdy false forever + ; with no retry -- indistinguishable from a modem that never answers. + ; + ; The body is trapped so that a transient error costs one cycle + ; rather than permanently killing the thread. + (loopwhile-thd ("modem" 200) true { + (trap { + (if (not modem-rdy) { + (setq modem-on (modem-power-on)) + (if modem-on { + (setq modem-rdy (modem-sync)) + (if (not modem-rdy) { + (setq modem-fails (+ modem-fails 1)) + (print (list "modem sync failed, attempt" modem-fails)) + (print at-rx) + }) + }) + } + { + (setq modem-on (= (gpio-read pin-mstat) 1)) + (if (not modem-on) { + (setq modem-rdy false) + (setq sms-ready false) + }) + }) + + (if modem-rdy { + (setq sim-ok (>= (str-find (at-cmd "AT+CPIN?" 3.0) "READY") 0)) + (setq lte-rssi (parse-csq (at-cmd "AT+CSQ" 3.0))) + (setq net-att (>= (str-find (at-cmd "AT+CGATT?" 5.0) "+CGATT: 1") 0)) + + ; Only ask for the carrier on the transition into + ; attached. It does not change while registered, and + ; COPS is the slowest of these to answer. + (if (and net-att (= (str-len op-name) 0)) + (setq op-name (parse-cops (at-cmd "AT+COPS?" 10.0)))) + (if (not net-att) (setq op-name "")) + } + { + (setq sim-ok false) + (setq net-att false) + (setq lte-rssi -999) + (setq op-name "") + }) + + ; SMS shares this thread and therefore the UART, so + ; it cannot collide with the status polling above. + (if (and sms-on modem-rdy net-att) { + (setq sms-tick (+ sms-tick 1)) + (if (or sms-urc (>= sms-tick sms-every)) { + (setq sms-tick 0) + (setq sms-urc false) + ; Handle several, not one. Two texts + ; arriving close together used to leave + ; the second sitting until the next poll. + (looprange i 0 3 (sms-poll)) + }) + }) + + ; Trapped separately. A publish failure must not + ; skip the LED update below it -- the whole point + ; of the LED is that it stays truthful when + ; something above it has gone wrong. + (trap (mqtt-tick)) + + (update-lte-led) + }) + + (sleep 3.0) + }) + + (spawn 200 log-boot-thd) + (spawn 200 lock-thd) + + ; --- UI push, 1 Hz ----------------------------------------------- + ; Re-broadcast rather than answering a poll, so the UI fills in + ; correctly when VESC Tool connects after the script has started. + (loopwhile-thd ("ui" 200) true { + (send-status) + (sleep 1.0) + }) +}) + +@const-end + +; Save the parsed program as a flash image. On every boot after this one the +; firmware finds a `main` closure in the image and replaces the source with +; "(main)" outright (lispif.c:906) -- so the 56 KB of lisp is never parsed +; again, and the heap is left for runtime data instead of the program. +; +; This requires main to be a defun, and everything that has to run at start +; must be reachable from it. Nothing at top level outside a definition will +; execute on an image boot. +(image-save) +(main) diff --git a/vl_link_status/pkgdesc.qml b/vl_link_status/pkgdesc.qml new file mode 100644 index 00000000..36936928 --- /dev/null +++ b/vl_link_status/pkgdesc.qml @@ -0,0 +1,31 @@ +import QtQuick 2.15 + +Item { + property string pkgName: "VL Link Status" + property string pkgDescriptionMd: "README.md" + property string pkgLisp: "code.lbm" + property string pkgQml: "ui.qml" + property bool pkgQmlIsFullscreen: false + property string pkgOutput: "vl_link_status.vescpkg" + + // Returns true when this package is compatible with the connected device. + function isCompatible (fwRxParams) { + var hwName = fwRxParams.hw.toLowerCase(); + var hwType = fwRxParams.hwTypeStr().toLowerCase(); + + // The classic VESC BMS cannot run packages at all + if (hwType == "vesc bms") { + return false + } + + // The VL Link is an ESP32-C3 running VESC Express, so it enumerates + // as a custom module rather than a motor controller. + if (hwType != "custom module") { + return false + } + + // This package drives GPIO 6 / 7 / 8 and the SIM7070G UART, which + // only exist on the VL Link. Do not let it install anywhere else. + return hwName == "vl link" + } +} diff --git a/vl_link_status/ui.qml b/vl_link_status/ui.qml new file mode 100644 index 00000000..57adf898 --- /dev/null +++ b/vl_link_status/ui.qml @@ -0,0 +1,1372 @@ +/* + Copyright 2026 Jeremy Maddox + + This file is part of the VESC Package VL Link Status. + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.3 +import QtQuick.Controls.Material 2.2 +import Vedder.vesc.utility 1.0 +import Vedder.vesc.commands 1.0 + +Item { + id: appPage + anchors.fill: parent + anchors.margins: 10 + + property Commands mCommands: VescIf.commands() + + // --- state pushed up from the lisp ----------------------------------- + property bool haveData: false + property bool canOk: false + property int canCount: 0 + property int canFirst: -1 + property bool modemOn: false + property bool modemRdy: false + property bool simOk: false + property bool netAtt: false + property int rssi: -999 + property real vin: -1 + property bool sdOk: false + property bool logging: false + property int logFields: 0 + property string carrier: "" + property real logRate: 10.0 + property bool logLte: true + property bool logAtBoot: false + property bool smsOn: false + property int nPending: 0 + property string ownerNum: "" + property bool guardOn: false + property int lockState: -1 + property real tripKm: 0 + property real tripWh: 0 + property real battPct: -1 + + // Cloud / MQTT. mqttLast mirrors the lisp side: + // 0 idle 1 published 2 publish failed 3 connect failed 4 no context + property bool mqttOn: false + property bool mqttUp: false + property int mqttLast: 0 + property int mqttAge: -1 + property string apnStr: "" + property string hostStr: "" + property string portStr: "1883" + property string userStr: "" + property string passStr: "" + property string clientStr: "" + property string topicStr: "" + property int mqttInt: 60 + + // Where the firmware writes logs. Paths are relative to file_basepath. + readonly property string logDir: "/log_can" + + readonly property color colGreen: Utility.getAppHexColor("green") + readonly property color colOrange: Utility.getAppHexColor("orange") + readonly property color colRed: Utility.getAppHexColor("red") + readonly property color colDim: Utility.getAppHexColor("disabledText") + readonly property color colText: Utility.getAppHexColor("lightText") + readonly property color colCard: Utility.getAppHexColor("lightBackground") + + function canDotColor() { + if (!haveData) return colDim + return canOk ? colGreen : colRed + } + + function canLine() { + if (!haveData) return "Waiting for device" + if (!canOk) return "No nodes found" + var s = canCount + (canCount === 1 ? " node" : " nodes") + " \u00b7 ID " + canFirst + if (vin > 0) s += " \u00b7 " + vin.toFixed(1) + " V" + if (battPct >= 0) s += " \u00b7 " + battPct.toFixed(0) + " %" + return s + } + + function lteDotColor() { + if (!haveData || !modemOn) return colDim + if (netAtt) return colGreen + if (simOk) return colOrange + return colRed + } + + function lteLine() { + if (!haveData) return "Waiting for device" + if (!modemOn) return "Modem off" + if (!modemRdy) return "Starting up" + if (!simOk) return "No SIM" + if (!netAtt) return "Searching for network" + var s = carrier.length > 0 ? carrier : "Registered" + if (rssi > -900) s += " \u00b7 " + rssi + " dBm" + return s + } + + // 0-5 bars. Below -110 dBm is unusable, above -70 is full scale. + function sigBars() { + if (!haveData || !modemRdy || rssi <= -900) return 0 + if (rssi >= -70) return 5 + if (rssi >= -80) return 4 + if (rssi >= -90) return 3 + if (rssi >= -100) return 2 + if (rssi >= -110) return 1 + return 0 + } + + function sdDotColor() { + if (!haveData) return colDim + if (!sdOk) return colRed + return logging ? colGreen : colOrange + } + + function sdLine() { + if (!haveData) return "Waiting for device" + if (!sdOk) return "No card" + if (logging) return "Recording \u00b7 " + logFields + " fields @ " + logRate.toFixed(0) + " Hz" + return "Card ready \u00b7 idle" + } + + function cloudDotColor() { + if (!haveData || !mqttOn) return colDim + if (mqttUp && mqttLast === 1) return colGreen + if (mqttUp) return colOrange + // Amber, not red, while an attempt is pending. Red is for a + // verdict that came back, not for work still in progress. + if (mqttLast === 0) return colOrange + return colRed + } + + function cloudLine() { + if (!haveData) return "Waiting for device" + if (!mqttOn) return "Off" + if (!netAtt) return "Waiting for the network" + if (!mqttUp) { + // A failure code only means something once an attempt has + // actually finished and lost. While one is in flight the code + // still holds the PREVIOUS verdict, and reporting that reads + // as a hard error during what is really just a slow startup -- + // the modem needs ten to twenty seconds to attach before the + // first connect can even be tried. + if (mqttLast === 0) return "Connecting to " + hostStr + // Past that, distinguish the two failures that look identical + // from outside: no data context is an APN or plan problem, a + // refusal is credentials or the wrong host. + if (mqttLast === 4) return "No data context \u00b7 check APN" + if (mqttLast === 3) return "Broker refused \u00b7 retrying" + return "Connecting to " + hostStr + } + var s = "Connected \u00b7 " + hostStr + if (mqttLast === 2) return "Connected \u00b7 last publish failed" + if (mqttAge >= 0) s += " \u00b7 sent " + mqttAge + " s ago" + else s += " \u00b7 no sample yet" + return s + } + + // Strings are pasted into a lisp form on the device, which is read + // back with (read data). Only the quote and the backslash can break + // out of a string literal there; parentheses inside one are harmless. + function lispStr(str) { + // split/join rather than a regex: a character class containing a + // quote is a reliable way to confuse every QML syntax highlighter + // and half the linters, for no gain over this. + return str.split("\"").join("") + .split("\\").join("") + .replace(/[\x00-\x1f]/g, "") + .trim() + } + + // Assign, do not bind. `text: userStr` looks equivalent but a QML + // binding is destroyed the first time the user types into the field, + // and after that the device's own value can never get back in. The + // field then reads empty on the next connection while the device still + // holds a good token -- and Save writes that emptiness back over it. + // Skipped while the field has focus so it cannot yank text out from + // under someone mid-edit. + function setField(f, v) { + // Never while a save is in flight. The device is mid-update, so + // anything it echoes back is the OLD configuration and writing it + // into the field would clobber what is being sent. + if (saveStep >= 0) { + return + } + if (f && !f.activeFocus && f.text !== v) { + f.text = v + } + } + + function sendCode(str) { + // Append the null so the lisp side reads a terminated string + mCommands.sendCustomAppData(str + "\0") + } + + // Saving sends five forms. Fired back to back over BLE they can arrive + // faster than the device evaluates them and one gets dropped, so they + // go out one per tick. + // + // Driven by an integer step and not by a queue array. A JavaScript + // array in a `property var` is not reliably written back after being + // mutated in place -- push() can land on a copy, leaving the queue + // permanently empty and every form silently discarded. An int property + // has no such ambiguity. + property int saveStep: -1 + + // Snapshot every field at the moment Save is pressed. + // + // The forms go out one per timer tick, so reading the fields as each + // tick fires leaves a window of several hundred milliseconds where a + // cf frame can arrive and setField can overwrite what was just typed + // -- the field is no longer focused, because pressing Save took focus + // away from it. The symptom is typing a new token and watching the old + // one get saved instead. + property string sHost: "" + property string sPort: "" + property string sUser: "" + property string sPass: "" + property string sClient: "" + property string sTopic: "" + property string sApn: "" + property string sNum: "" + property int sRate: 10 + property bool sLte: false + property bool sBoot: false + property bool sSms: false + property bool sMqtt: false + property int sInt: 60 + + function saveSettings() { + sHost = lispStr(hostField.text) + sPort = portField.text + sUser = lispStr(userField.text) + sPass = lispStr(passField.text) + sClient = lispStr(clientField.text) + sTopic = lispStr(topicField.text) + sApn = lispStr(apnField.text) + sNum = numField.text.replace(/[^0-9]/g, "") + sRate = rateBox.rates[rateBox.currentIndex] + sLte = lteBox.checked + sBoot = bootBox.checked + sSms = smsBox.checked + sMqtt = mqttBox.checked + sInt = Math.round(intSlider.value) + saveStep = 0 + saveTimer.running = true + } + + function saveForm(n) { + switch (n) { + case 0: + return "(cfg " + sRate + ".0 " + (sLte ? "1" : "0") + " " + + (sBoot ? "1" : "0") + ")" + case 1: + return "(sms-cfg \"" + sNum + "\" " + (sSms ? "1" : "0") + ")" + // Broker details before the enable, so the modem thread never + // picks up a reconnect against a half-updated configuration. + case 2: + return "(mqtt-broker \"" + sHost + "\" " + + (parseInt(sPort) > 0 ? parseInt(sPort) : 1883) + " \"" + + sUser + "\" \"" + sPass + "\")" + case 3: + return "(mqtt-ident \"" + sClient + "\" \"" + sTopic + + "\" \"" + sApn + "\")" + case 4: + return "(mqtt-set " + (sMqtt ? "1" : "0") + " " + sInt + ")" + } + return "" + } + + Timer { + id: saveTimer + // 500 ms, not 150. Status frames go up every second and cf frames + // come back in bursts; five forms fired 150 ms apart into that + // shared the link badly enough that the longest ones -- the broker + // and ident forms, at 68 and 50 bytes against 15 for the others -- + // were the ones that went missing. + interval: 500 + repeat: true + running: false + onTriggered: { + if (saveStep < 0 || saveStep > 4) { + running = false + saveStep = -1 + // Read the whole configuration back rather than trusting + // that it landed. The fields repopulate from the device, + // so anything that did not arrive is visible immediately. + sendCode("(ui-sync)") + return + } + sendCode(saveForm(saveStep)) + saveStep = saveStep + 1 + } + } + + ColumnLayout { + anchors.fill: parent + spacing: 8 + + TabBar { + id: tabBar + Layout.fillWidth: true + TabButton { text: "Status" } + TabButton { text: "Logs" } + TabButton { text: nPending > 0 ? "Access (" + nPending + ")" : "Access" } + TabButton { text: "Settings" } + } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: tabBar.currentIndex + + // ================= STATUS ================= + ColumnLayout { + spacing: 10 + + // ---- CAN ---- + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 78 + radius: 6 + color: colCard + + RowLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 14 + + Rectangle { + Layout.alignment: Qt.AlignVCenter + width: 16; height: 16; radius: 8 + color: canDotColor() + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: "CAN Bus" + color: colText + font.pixelSize: 17 + font.bold: true + } + + Text { + Layout.fillWidth: true + text: canLine() + color: colDim + font.pixelSize: 14 + elide: Text.ElideRight + } + } + } + } + + // ---- LTE ---- + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 78 + radius: 6 + color: colCard + + RowLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 14 + + Rectangle { + Layout.alignment: Qt.AlignVCenter + width: 16; height: 16; radius: 8 + color: lteDotColor() + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: "LTE" + color: colText + font.pixelSize: 17 + font.bold: true + } + + Text { + Layout.fillWidth: true + text: lteLine() + color: colDim + font.pixelSize: 14 + elide: Text.ElideRight + } + } + + // Signal meter. Row sets x only, so each bar is + // pushed to a common baseline by hand. + Row { + Layout.alignment: Qt.AlignVCenter + spacing: 3 + + Repeater { + model: 5 + Rectangle { + width: 5 + radius: 1 + height: 6 + index * 4 + y: 22 - height + color: index < sigBars() ? lteDotColor() : colDim + opacity: index < sigBars() ? 1.0 : 0.25 + } + } + } + } + } + + // ---- Storage ---- + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 78 + radius: 6 + color: colCard + + RowLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 14 + + Rectangle { + Layout.alignment: Qt.AlignVCenter + width: 16; height: 16; radius: 8 + color: sdDotColor() + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: "Storage" + color: colText + font.pixelSize: 17 + font.bold: true + } + + Text { + Layout.fillWidth: true + text: sdLine() + color: colDim + font.pixelSize: 14 + elide: Text.ElideRight + } + } + } + } + + // ---- Cloud ---- + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 78 + radius: 6 + color: colCard + + RowLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 14 + + Rectangle { + Layout.alignment: Qt.AlignVCenter + width: 16; height: 16; radius: 8 + color: cloudDotColor() + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: "Cloud" + color: colText + font.pixelSize: 17 + font.bold: true + } + + Text { + Layout.fillWidth: true + text: cloudLine() + color: colDim + font.pixelSize: 14 + elide: Text.ElideRight + } + } + + Text { + visible: mqttOn && mqttUp + text: mqttInt + " s" + color: colDim + font.pixelSize: 13 + } + } + } + + // ---- Trip ---- + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 78 + radius: 6 + color: colCard + + RowLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 14 + + Rectangle { + Layout.alignment: Qt.AlignVCenter + width: 16; height: 16; radius: 8 + color: lockState === 1 ? colRed + : (lockState < 0 ? colOrange + : (guardOn ? colOrange : colDim)) + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: "Trip" + color: colText + font.pixelSize: 17 + font.bold: true + } + + Text { + Layout.fillWidth: true + text: !haveData ? "Waiting for device" + : tripKm.toFixed(2) + " km \u00b7 " + + tripWh.toFixed(0) + " Wh" + + (tripKm > 0.05 + ? " \u00b7 " + (tripWh / tripKm).toFixed(0) + " Wh/km" + : "") + + (lockState === 1 ? " \u00b7 LOCKED" + : lockState < 0 ? " \u00b7 lock unknown" : "") + + (guardOn ? " \u00b7 guarded" : "") + color: colDim + font.pixelSize: 14 + elide: Text.ElideRight + } + } + + Button { + text: "Reset" + implicitHeight: 40 + enabled: VescIf.isPortConnected() + onClicked: sendCode("(trip-reset)") + } + } + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Button { + Layout.fillWidth: true + Layout.preferredHeight: 48 + text: guardOn ? "Guard On" : "Guard Off" + enabled: VescIf.isPortConnected() + onClicked: sendCode("(guard-set " + (guardOn ? "0" : "1") + ")") + } + + Button { + Layout.fillWidth: true + Layout.preferredHeight: 48 + text: lockState === 1 ? "Unlock" : "Lock" + enabled: VescIf.isPortConnected() && canOk + onClicked: sendCode("(lock-set " + (lockState === 1 ? "0" : "1") + ")") + } + } + + Button { + Layout.fillWidth: true + Layout.preferredHeight: 48 + text: logging ? "Stop Logging" : "Start Logging" + enabled: VescIf.isPortConnected() && sdOk + onClicked: sendCode(logging ? "(log-end)" : "(log-begin)") + } + + Button { + Layout.fillWidth: true + Layout.preferredHeight: 48 + text: "Rescan CAN Bus" + enabled: VescIf.isPortConnected() + onClicked: { + sendCode("(rescan)") + VescIf.emitStatusMessage("Scanning CAN bus...", true) + } + } + } + + // ================= LOGS ================= + ColumnLayout { + spacing: 8 + + Text { + Layout.fillWidth: true + text: "Tap Open to load a log into the log analysis view." + color: colDim + font.pixelSize: 13 + wrapMode: Text.WordWrap + } + + // Above the list, not below it. The list grows with the + // card contents, and a control underneath it ends up off + // the bottom of the pane on a short window -- the same way + // the Save button did in Settings. + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Button { + Layout.fillWidth: true + Layout.preferredHeight: 48 + text: "Refresh" + enabled: VescIf.isPortConnected() && !busy.running + onClicked: refreshFiles() + } + + Button { + Layout.preferredHeight: 48 + text: "Cancel" + visible: busy.running + onClicked: mCommands.fileBlockCancel() + } + } + + ListView { + id: fileList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + spacing: 6 + model: ListModel { id: fileModel } + + delegate: Rectangle { + width: fileList.width + height: 56 + radius: 6 + color: colCard + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 8 + spacing: 8 + + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + + Text { + Layout.fillWidth: true + text: model.name + color: colText + font.pixelSize: 15 + elide: Text.ElideMiddle + } + + Text { + text: (model.size / 1024).toFixed(1) + " kB" + color: colDim + font.pixelSize: 12 + } + } + + Button { + text: "Open" + implicitHeight: 40 + enabled: !busy.running + onClicked: openLog(model.name) + } + + Button { + text: "\u00d7" + implicitWidth: 44 + implicitHeight: 40 + enabled: !busy.running + onClicked: removeLog(model.name) + } + } + } + } + + ProgressBar { + id: dlProgress + Layout.fillWidth: true + visible: busy.running + from: 0 + to: 100 + value: 0 + } + + Text { + Layout.fillWidth: true + visible: busy.running + text: dlProgress.value.toFixed(0) + " %" + color: colDim + font.pixelSize: 12 + } + + } + // ================= ACCESS ================= + // ScrollView because this pane grows with the roster and will + // not fit a phone screen once a few members are added. + // + // Left as a direct child of the tab. Wrapping it in a + // ColumnLayout to pin a button outside the scroll area makes + // the ScrollView's width depend on its content's implicit + // width, which depends back on the ScrollView -- a binding + // loop QML resolves by zeroing something, and the pane comes + // up broken. Refresh goes first INSIDE the column instead. + ScrollView { + id: accessScroll + clip: true + contentWidth: availableWidth + contentHeight: accCol.implicitHeight + 120 + + ColumnLayout { + id: accCol + width: accessScroll.availableWidth + spacing: 8 + + Button { + Layout.fillWidth: true + Layout.preferredHeight: 48 + text: "Refresh" + enabled: VescIf.isPortConnected() + onClicked: sendCode("(ui-sync)") + } + + Text { + Layout.fillWidth: true + text: smsOn + ? "Unknown numbers that text the Link appear here for approval." + : "SMS control is off. Enable it in Settings." + color: colDim + font.pixelSize: 13 + wrapMode: Text.WordWrap + } + + Text { + Layout.fillWidth: true + visible: pendingModel.count > 0 + text: "Requests" + color: colText + font.pixelSize: 15 + font.bold: true + } + + Repeater { + model: ListModel { id: pendingModel } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 56 + radius: 6 + color: colCard + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 8 + spacing: 8 + + Rectangle { + width: 10; height: 10; radius: 5 + color: colOrange + } + + Text { + Layout.fillWidth: true + text: "+" + model.num + color: colText + font.pixelSize: 15 + elide: Text.ElideRight + } + + Button { + text: "Allow" + implicitHeight: 40 + onClicked: sendCode("(member-approve \"" + model.num + "\")") + } + + Button { + text: "Deny" + implicitHeight: 40 + onClicked: sendCode("(member-deny \"" + model.num + "\")") + } + } + } + } + + Text { + Layout.fillWidth: true + text: "Members" + color: colText + font.pixelSize: 15 + font.bold: true + } + + Text { + Layout.fillWidth: true + visible: memberModel.count === 0 + text: "None yet." + color: colDim + font.pixelSize: 13 + } + + Repeater { + model: ListModel { id: memberModel } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 56 + radius: 6 + color: colCard + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 8 + spacing: 8 + + Text { + Layout.fillWidth: true + text: "+" + model.num + color: model.en ? colText : colDim + font.pixelSize: 15 + elide: Text.ElideRight + } + + Switch { + checked: model.en + onToggled: sendCode("(member-set " + model.slot + + " " + (checked ? "1" : "0") + ")") + } + + Button { + text: "\u00d7" + implicitWidth: 44 + implicitHeight: 40 + onClicked: sendCode("(member-del " + model.slot + ")") + } + } + } + } + + Item { Layout.preferredHeight: 8 } + + Text { + Layout.fillWidth: true + text: "Lock is latched by a package on the controller, not held " + + "by this one. Nothing is reported as locked until the " + + "controller confirms it \u2014 \"unknown\" means no reply " + + "came back, so treat the vehicle as unlocked." + color: colDim + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + + } + } + + // ================= SETTINGS ================= + // Flickable + Column, deliberately, not ScrollView + + // ColumnLayout. + // + // A ColumnLayout computes its implicitHeight from its children + // at their IMPLICIT widths, and a word-wrapping Text at its + // implicit width is one long line exactly one line tall. A + // paragraph that renders four lines deep is therefore counted + // as one, the pane under-reports its own height by hundreds of + // pixels, and the bottom of it becomes unreachable. A Column + // instead stacks children at their ACTUAL heights, so + // contentHeight is simply the Column's height and there is + // nothing left to infer. + // + // Save sits outside the Flickable. It is the one control that + // must never be unreachable. + ColumnLayout { + spacing: 8 + + // At the TOP, not the bottom. + // + // A save button below a scrolling pane is only reachable + // if the pane fits the window, and this one does not on + // every screen -- the content is simply taller than VESC + // Tool gives the tab, so the button ends up off the bottom + // edge where no amount of scrolling reaches it. Above the + // scroll area it is always on screen by construction. + Button { + Layout.fillWidth: true + Layout.preferredHeight: 44 + text: saveStep < 0 ? "Save Settings" : "Saving..." + enabled: VescIf.isPortConnected() && saveStep < 0 + onClicked: { + if (mqttBox.checked && lispStr(userField.text) === "") { + VescIf.emitStatusMessage( + "Cloud telemetry needs a token in the username field", + false) + return + } + saveSettings() + VescIf.emitStatusMessage("Saving settings...", true) + } + } + + Flickable { + id: setFlick + Layout.fillWidth: true + Layout.fillHeight: true + // Zeroed preferred height, because a scrollable item + // otherwise asks the layout for its whole content + // height and squeezes the Save button off the pane. + Layout.preferredHeight: 0 + Layout.minimumHeight: 0 + clip: true + contentWidth: width + contentHeight: setCol.height + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { + policy: setFlick.contentHeight > setFlick.height + ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff + } + + Column { + id: setCol + width: setFlick.width + spacing: 14 + + Text { + width: parent.width + text: "Saved to the device. Applies to the next recording." + color: colDim + font.pixelSize: 13 + wrapMode: Text.WordWrap + } + + Row { + width: parent.width + spacing: 10 + + Text { + text: "Log rate" + color: colText + font.pixelSize: 15 + anchors.verticalCenter: parent.verticalCenter + } + + Item { + width: parent.width - 150 - parent.spacing + height: 1 + } + + ComboBox { + id: rateBox + width: 140 + model: ["1 Hz", "5 Hz", "10 Hz", "20 Hz", "50 Hz"] + property var rates: [1, 5, 10, 20, 50] + currentIndex: { + var i = rates.indexOf(Math.round(logRate)) + return i < 0 ? 2 : i + } + } + } + + Switch { + id: lteBox + width: parent.width + text: "Log LTE signal and attach state" + checked: logLte + } + + Switch { + id: bootBox + width: parent.width + text: "Start logging automatically" + checked: logAtBoot + } + + Text { + width: parent.width + visible: bootBox.checked + text: "Waits up to 60 s for a CAN node. No shutdown event " + + "exists on this hardware, so stop logging before " + + "cutting power or the file tail may be lost." + color: colDim + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + + Rectangle { + width: parent.width + height: 1 + color: colDim + opacity: 0.3 + } + + Switch { + id: smsBox + width: parent.width + text: "SMS control" + checked: smsOn + } + + Column { + width: parent.width + visible: smsBox.checked + spacing: 4 + + TextField { + id: numField + width: parent.width + placeholderText: "Allowed number, e.g. +15551234567" + inputMethodHints: Qt.ImhDialableCharactersOnly + text: ownerNum + } + + Text { + width: parent.width + text: "Approved numbers only, matched on the last 10 " + + "digits. Read-only by design \u2014 caller ID is " + + "forgeable, so nothing here moves the vehicle." + color: colDim + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + } + + Rectangle { + width: parent.width + height: 1 + color: colDim + opacity: 0.3 + } + + Switch { + id: mqttBox + width: parent.width + text: "Cloud telemetry (MQTT)" + checked: mqttOn + } + + Column { + width: parent.width + visible: mqttBox.checked + spacing: 8 + + TextField { + id: hostField + width: parent.width + placeholderText: "Broker host, e.g. mqtt.thingsboard.cloud" + inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText + } + + Row { + width: parent.width + spacing: 10 + + Text { + text: "Port" + color: colText + font.pixelSize: 15 + anchors.verticalCenter: parent.verticalCenter + } + + Item { + width: parent.width - 110 - 40 - parent.spacing + height: 1 + } + + TextField { + id: portField + width: 110 + placeholderText: "1883" + inputMethodHints: Qt.ImhDigitsOnly + } + } + + TextField { + id: userField + width: parent.width + placeholderText: "Username / ThingsBoard access token" + inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText + } + + TextField { + id: passField + width: parent.width + placeholderText: "Password (blank for ThingsBoard)" + echoMode: TextInput.Password + inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText + } + + TextField { + id: clientField + width: parent.width + placeholderText: "Client ID" + inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText + } + + TextField { + id: topicField + width: parent.width + placeholderText: "Topic" + inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText + } + + TextField { + id: apnField + width: parent.width + placeholderText: "APN (leave blank to use the network's)" + inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText + } + + Row { + width: parent.width + + Text { + text: "Publish every" + color: colText + font.pixelSize: 15 + } + + Item { + width: parent.width - 200 + height: 1 + } + + Text { + text: Math.round(intSlider.value) + " s" + color: colText + font.pixelSize: 15 + font.bold: true + } + } + + Slider { + id: intSlider + width: parent.width + from: 15 + to: 600 + stepSize: 15 + value: mqttInt + } + + Text { + width: parent.width + text: "A floor, not a guarantee: connecting costs a " + + "handshake of ten seconds or more on Cat-M1. " + + "Each sample is a few hundred bytes." + color: colDim + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + + Text { + width: parent.width + text: "Plain TCP, not TLS \u2014 the SIM7070G's TLS " + + "stack is not reachable through these commands. " + + "Use a token scoped to this device only." + color: colOrange + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + } + + Item { width: 1; height: 8 } + } + } + + } + } + } + + // Drives the progress bar while a blocking transfer runs. fileBlockRead + // spins its own event loop, so this keeps ticking during the transfer. + Timer { + id: busy + interval: 200 + repeat: true + running: false + onTriggered: dlProgress.value = mCommands.getFilePercentage() + } + + function refreshFiles() { + fileModel.clear() + var files = mCommands.fileBlockList(logDir) + if (!files || files.length === 0) { + VescIf.emitStatusMessage("No logs found in " + logDir, false) + return + } + for (var i = 0; i < files.length; i++) { + if (files[i].isDir) { + continue + } + fileModel.append({ "name": files[i].name, "size": files[i].size }) + } + } + + function openLog(name) { + busy.running = true + dlProgress.value = 0 + + var data = mCommands.fileBlockRead(logDir + "/" + name) + + busy.running = false + + if (!data || data.length === 0) { + VescIf.emitStatusMessage("Could not read " + name, false) + return + } + + // Hands the bytes straight to VESC Tool's log analysis view. This is + // what makes logs usable on mobile, where there is no file browser + // and nowhere to save a file to. + if (VescIf.loadRtLogFile(data)) { + VescIf.emitStatusMessage("Loaded " + name, true) + } else { + VescIf.emitStatusMessage("Could not parse " + name, false) + } + } + + function removeLog(name) { + if (mCommands.fileBlockRemove(logDir + "/" + name)) { + VescIf.emitStatusMessage("Deleted " + name, true) + refreshFiles() + } else { + VescIf.emitStatusMessage("Could not delete " + name, false) + } + } + + Connections { + target: mCommands + + function onCustomAppDataReceived(data) { + var str = data.toString().replace(/\0/g, "").trim() + + // Roster arrives as its own short frames because it will not + // fit inside the 100 byte status frame. + if (str.startsWith("mb ")) { + var m = str.split(/\s+/) + if (m.length < 4) return + var slot = parseInt(m[1]) + if (slot === 0) { + memberModel.clear() + pendingModel.clear() + ownerNum = m[2] === "-" ? "" : m[2] + } else if (m[2] !== "-") { + memberModel.append({ "slot": slot, + "num": m[2], + "en": m[3] === "1" }) + } + return + } + + if (str.startsWith("pd ")) { + pendingModel.append({ "num": str.substring(3).trim() }) + return + } + + // MQTT configuration, one frame per field. It cannot ride in + // the status frame: a broker host or topic would blow the 100 + // byte budget, and "-" stands in for an empty value so the + // frame never ends in a dangling separator. + if (str.startsWith("cf ")) { + var c = str.split(/\s+/) + if (c.length < 3) return + var v = c.slice(2).join(" ") + if (v === "-") v = "" + switch (parseInt(c[1])) { + case 0: apnStr = v; setField(apnField, v); break + case 1: hostStr = v; setField(hostField, v); break + case 2: portStr = v; setField(portField, v); break + case 3: userStr = v; setField(userField, v); break + case 4: passStr = v; setField(passField, v); break + case 5: clientStr = v; setField(clientField, v); break + case 6: topicStr = v; setField(topicField, v); break + case 7: mqttInt = parseInt(v); break + } + return + } + + if (str === "rx") { + return + } + + if (!str.startsWith("st ")) { + return + } + + var t = str.split(/\s+/) + if (t.length < 15) { + return + } + + // Booleans arrive packed into one integer -- the device has a + // 2560 cons cell heap and building eleven separate string + // fragments once a second exhausted it. + var f = parseInt(t[1]) + canOk = (f & 1) !== 0 + modemOn = (f & 2) !== 0 + modemRdy = (f & 4) !== 0 + simOk = (f & 8) !== 0 + netAtt = (f & 16) !== 0 + sdOk = (f & 32) !== 0 + logging = (f & 64) !== 0 + logLte = (f & 128) !== 0 + logAtBoot = (f & 256) !== 0 + smsOn = (f & 512) !== 0 + guardOn = (f & 1024) !== 0 + mqttOn = (f & 2048) !== 0 + mqttUp = (f & 4096) !== 0 + + canCount = parseInt(t[2]) + canFirst = parseInt(t[3]) + rssi = parseInt(t[4]) + vin = parseFloat(t[5]) + logFields = parseInt(t[6]) + logRate = parseFloat(t[7]) + nPending = parseInt(t[8]) + lockState = parseInt(t[9]) + tripKm = parseFloat(t[10]) + tripWh = parseFloat(t[11]) + battPct = parseFloat(t[12]) + mqttLast = parseInt(t[13]) + mqttAge = parseInt(t[14]) + // Everything past index 14 is the carrier name, which may itself + // contain spaces, so rejoin rather than taking a single token. + carrier = t.length > 15 ? t.slice(15).join(" ") : "" + + if (!haveData) { + // First status frame: ask for the roster, which is not + // broadcast on a timer the way status is. + sendCode("(ui-sync)") + } + haveData = true + } + } +} diff --git a/vl_link_status/version b/vl_link_status/version new file mode 100644 index 00000000..347f5833 --- /dev/null +++ b/vl_link_status/version @@ -0,0 +1 @@ +1.4.1