From fb5eea4aab0330b5e8e2ede28a3dad8987763929 Mon Sep 17 00:00:00 2001 From: gowrav Date: Fri, 10 Jul 2026 07:58:42 +0530 Subject: [PATCH] Add MQTTCAN4VESC: CAN to MQTT telemetry bridge for VESC Express Runs on a VESC Express and publishes the motor controller's CAN status messages (statuses 1-6) to an MQTT broker over WiFi, as one JSON document or as one topic per signal. Read-only with respect to the vehicle: it decodes CAN traffic and never commands the controller. Configuration is done from the package's QML panel in VESC Tool over BLE or USB -- broker host/port/credentials, topic prefix, publish mode and interval, MQTT keepalive, CAN mode, controller id, and the two motor constants. Settings persist in EEPROM and survive reboots and package updates. WiFi is deliberately NOT configured here: the Express firmware owns the station credentials and its auto-reconnect, and configuring it in two places lets the two disagree. Nineteen fields are published every interval regardless of which status messages the controller has enabled, so the payload shape is stable; undecoded fields carry 0. Both the raw signals (erpm, current_motor) and the values derived from them (motor_speed, motor_torque) are sent, so a wrong pole-pair count or torque constant can be corrected downstream rather than by re-flashing vehicles. Three CAN modes: "vesc" (default) decodes the status frames while leaving the VESC protocol enabled, so VESC Tool still reaches the controller over CAN, and falls back to canget-* polling if no status frames arrive; "raw" decodes with the protocol disabled; "sim" generates simulated telemetry for bench-testing the MQTT chain with no controller attached. The panel shows live status -- WiFi and MQTT state, a CAN-frame counter, speed, torque, voltage, temperatures -- plus where the running config came from (EEPROM or defaults) and the result of the last save, so a silent storage failure cannot look like success. Requires VESC Express firmware 6.05+. MQTT is plain TCP; LispBM has no TLS, so a private broker on a trusted network is recommended. Tested on a VESC Express (XIAO ESP32-C3) against a real controller and a simulated bus: telemetry verified end to end at broker.emqx.io, and settings verified to persist across power cycles. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 2 +- mqttcan4vesc/Makefile | 13 + mqttcan4vesc/README.md | 54 ++ mqttcan4vesc/mqttcan4vesc.lisp | 866 +++++++++++++++++++++++++++++++++ mqttcan4vesc/pkgdesc.qml | 18 + mqttcan4vesc/ui.qml | 489 +++++++++++++++++++ res_all.qrc | 1 + 7 files changed, 1442 insertions(+), 1 deletion(-) create mode 100644 mqttcan4vesc/Makefile create mode 100644 mqttcan4vesc/README.md create mode 100644 mqttcan4vesc/mqttcan4vesc.lisp create mode 100644 mqttcan4vesc/pkgdesc.qml create mode 100644 mqttcan4vesc/ui.qml diff --git a/Makefile b/Makefile index 61959062..d052dde1 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 +PKGS += mt6701_config mqttcan4vesc TEST_PKGS = blacktip_dpv diff --git a/mqttcan4vesc/Makefile b/mqttcan4vesc/Makefile new file mode 100644 index 00000000..71225a5b --- /dev/null +++ b/mqttcan4vesc/Makefile @@ -0,0 +1,13 @@ +# Requires a VESC Tool binary. Point VESC_TOOL at it if it is not on PATH, e.g. +# make VESC_TOOL="/Applications/VESC Tool.app/Contents/MacOS/VESC Tool" +VESC_TOOL ?= vesc_tool + +all: mqttcan4vesc.vescpkg + +mqttcan4vesc.vescpkg: pkgdesc.qml mqttcan4vesc.lisp ui.qml README.md + "$(VESC_TOOL)" --buildPkgFromDesc pkgdesc.qml + +clean: + rm -f mqttcan4vesc.vescpkg + +.PHONY: all clean diff --git a/mqttcan4vesc/README.md b/mqttcan4vesc/README.md new file mode 100644 index 00000000..2a5a61c9 --- /dev/null +++ b/mqttcan4vesc/README.md @@ -0,0 +1,54 @@ +# MQTTCAN4VESC + +Turns a **VESC Express** into a CAN → MQTT telemetry bridge, configurable from +this panel — no Lisp scripting needed. + +The Express listens to the motor controller's CAN status messages (statuses +1–6), decodes them into engineering units, and publishes them over WiFi to an +MQTT broker, either as one JSON document or as one topic per signal. It is +read-only with respect to the vehicle: it never commands the controller. + +Published every interval, whatever the controller has enabled (undecoded fields +carry `0`): `erpm`, `current_motor`, `motor_speed`, `motor_torque`, `duty`, +`amp_hours`, `amp_hours_charged`, `watt_hours`, `watt_hours_charged`, +`temp_fet`, `temp_motor`, `current_in`, `pid_pos`, `tachometer`, `v_in`, +`adc1`, `adc2`, `adc3`, `ppm`. Both the raw signals (`erpm`, `current_motor`) +and the values derived from them are sent, so a wrong pole-pair or torque +constant can be corrected downstream instead of re-flashing. + +## Before you start: WiFi + +WiFi is **not** configured from this panel. Set it once on the device itself: +VESC Tool → **VESC Express** → **WiFi** tab → WiFi Mode = `Station`, then fill +in **Station Mode SSID** and **Station Mode Key**, write the config and reboot. +The firmware connects and reconnects on its own; this package just uses the +link. (The ESP32-C3 is 2.4 GHz only — a 5 GHz network will never connect.) + +## Using the panel + +Open this package's panel in VESC Tool (connected to the Express over BLE or +USB) and set: + +- **MQTT Broker** — host, port, and optional username/password. +- **Publishing** — topic prefix, mode (json / topics / both), publish interval, and MQTT keepalive. +- **Motor** — the controller's VESC ID, the speed divisor (pole pairs) and the torque constant used to convert raw CAN values. + +Press **Save & Apply** to store the settings on the device and reconnect (no +reboot needed). **Test Connection** shows the current WiFi/MQTT state, +**Restore Defaults** resets everything, and **Reboot** restarts the device. + +The **Status** box updates about once a second: WiFi/MQTT state, live speed, +torque, voltage, temperatures, and a CAN-frame counter so you can tell at a +glance whether CAN data is arriving. + +## CAN mode + +- **vesc** (default) — decodes status 1–6 and leaves the VESC protocol enabled, so VESC Tool can still reach the controller over CAN. Falls back to `canget-*` polling if no status frames arrive. +- **raw** — same decode, but disables the VESC protocol on CAN. +- **sim** — publishes simulated telemetry with no controller attached, for bench-testing the MQTT chain. Never ship a vehicle in this mode. + +## Notes + +- Requires VESC Express firmware 6.05+. +- Uses plain (non-TLS) MQTT. Prefer a private broker with a username and password; the credentials and the telemetry both cross the network in the clear. +- Settings are stored in the device's EEPROM and survive reboots and package updates. diff --git a/mqttcan4vesc/mqttcan4vesc.lisp b/mqttcan4vesc/mqttcan4vesc.lisp new file mode 100644 index 00000000..8ef3f320 --- /dev/null +++ b/mqttcan4vesc/mqttcan4vesc.lisp @@ -0,0 +1,866 @@ +; MQTTCAN4VESC -- CAN -> MQTT telemetry bridge for VESC Express, configured from +; a QML panel inside VESC Tool (via custom app data). Runs ON THE VESC EXPRESS. +; +; TELEMETRY: decodes VESC CAN status messages 1-6 from controller `cfg-vesc-id` +; and publishes them over MQTT (JSON on one topic by default). +; +; CONFIGURATION: all settings live in EEPROM and are edited from the package's +; panel in VESC Tool over BLE/USB. The panel talks to this script with a small +; text protocol (GET / SET / SAVE / TEL / TEST / DETECT / DEFAULTS / REBOOT). +; +; One-time device settings (reboot after writing): WiFi Mode = Station; +; CAN baud = 500k; controller broadcasts statuses 1-6. Firmware 6.05+. + +; Read all definitions into flash (const heap) to save RAM cons cells -- see the +; note at @const-end. Everything from here to @const-end lives in flash. +@const-start + +(def pkg-version "mqttcan4vesc 1.6.1") +(def proto-version 2) + +;;; ================= WiFi ==================================================== +; WiFi is NOT configured here. The Express firmware owns it: set WiFi Mode = +; Station + Station Mode SSID/Key in VESC Tool -> VESC Express -> WiFi. The +; firmware auto-connects on boot and auto-reconnects on drop. This script only +; observes (wifi-status) and waits. +; +; Do NOT call (wifi-connect ...) here: it would override the stored station +; config at runtime. Do NOT call (wifi-disconnect): the firmware sets +; wifi_reconnect_disabled = true on disconnect, which kills auto-reconnect +; until reboot. + +;;; ================= Defaults (used when EEPROM is empty/invalid) ============= +(def def-mqtt-host "broker.emqx.io") +(def def-mqtt-port 1883) +(def def-mqtt-user "") ; empty -> no MQTT auth +(def def-mqtt-pass "") +(def def-topic-prefix "vesc/telemetry/") +(def def-pub-mode 0) ; 0 json | 1 topics | 2 both +(def def-pub-interval 0.2) ; seconds between publishes (0.1 - 5.0) +(def def-vesc-id 111) +(def def-speed-div 4.0) ; eRPM / speed-div (pole pairs) +(def def-torque-k 0.01176) ; Nm per raw 0.1 A unit +(def def-keepalive 60) ; MQTT keepalive seconds +; 0 raw — VESC protocol OFF; status 1-6 decoded from event-can-eid +; 1 vesc — VESC protocol ON (VESC Tool CAN bridging works); status 1-6 are +; STILL decoded from event-can-eid (the firmware hands every frame to +; lisp before its own decoder), canget-* polling only as fallback +; 2 sim — fake data, no VESC needed +(def def-can-mode 1) + +;;; ================= Working configuration (RAM) ============================= +(def cfg-mqtt-host def-mqtt-host) +(def cfg-mqtt-port def-mqtt-port) +(def cfg-mqtt-user def-mqtt-user) +(def cfg-mqtt-pass def-mqtt-pass) +(def cfg-topic-prefix def-topic-prefix) +(def cfg-pub-mode def-pub-mode) +(def cfg-pub-interval def-pub-interval) +(def cfg-vesc-id def-vesc-id) +(def cfg-speed-div def-speed-div) +(def cfg-torque-k def-torque-k) +(def cfg-keepalive def-keepalive) +(def cfg-can-mode def-can-mode) + +;;; ================= VESC CAN status command numbers ========================= +(def cmd-status-1 9) (def cmd-status-2 14) (def cmd-status-3 15) +(def cmd-status-4 16) (def cmd-status-5 27) (def cmd-status-6 28) + +;;; ================= Runtime state =========================================== +(def sock nil) +(def last-tx (systime)) +(def last-pub (systime)) +(def new-data false) +(def reconnect-req false) ; set by SAVE/DEFAULTS -> main loop reconnects +(def mqtt-err "none") ; last connection failure reason (for panel) +(def events-on false) ; event-enable succeeded (retried on boot until CAN ready) +(def can-frames 0) ; total CAN status frames decoded (is CAN alive?) +(def last-frame 0) ; systime of last decoded status frame (vesc-mode fallback) +(def last-seen-node -1) ; node id of last status-1 frame seen (for Detect) +(def cfg-src "defaults") ; "eeprom" once a valid saved record loads +(def save-fail -1) ; slot number of the first failed eeprom write + +; Raw, as the controller reports them: erpm is ELECTRICAL rpm, current-motor is +; motor phase current in amps. motor-speed and motor-torque are derived from +; these two via cfg-speed-div / cfg-torque-k, and depend on those constants +; being right. Publish both so a consumer can re-derive if a constant changes. +(def erpm 0.0) (def current-motor 0.0) +(def motor-speed 0.0) (def motor-torque 0.0) (def duty 0.0) +(def ah-used 0.0) (def ah-charged 0.0) +(def wh-used 0.0) (def wh-charged 0.0) +(def temp-fet 0.0) (def temp-motor 0.0) (def current-in 0.0) (def pid-pos 0.0) +(def tacho 0.0) (def v-in 0.0) +(def adc1 0.0) (def adc2 0.0) (def adc3 0.0) (def ppm 0.0) + +;;; ================= EEPROM layout (v2) ====================================== +; Numeric slots (i = eeprom-store-i, f = eeprom-store-f): +; 0 i magic 1 i mqtt-port 2 i vesc-id 3 i pub-mode 4 i keepalive +; 5 i pub-interval-ms 6 f speed-div 7 f torque-k (8,9 reserved) +; String blocks (len word + packed data words, 4 chars/word): +; [10-36 legacy ssid/pass — no longer written or read; WiFi lives in the +; firmware's own config now. Slots kept reserved so the layout is stable.] +; host len@40 data 41-56 (64 B) user len@60 data 61-68 (32 B) +; mpass len@70 data 71-78 (32 B) prefix len@80 data 81-92 (48 B) +; Magic differs from the old v3 value (0x1234ABCD) so pre-panel devices fall +; back to defaults safely instead of misreading the old layout. The layout is +; unchanged by dropping ssid/pass, so the magic stays "STR2" and every other +; saved parameter survives the upgrade. +; NOTE the i32 suffix, and do not remove it. Two traps live here: +; 1. eeprom-read-i returns a boxed i32. LispBM `eq` (struct_eq) demands +; IDENTICAL types, so comparing it against a default-typed literal is +; always false -- the saved record would never be accepted and every +; setting would silently revert to defaults on each boot. +; 2. 0x53545232 does not fit LispBM's 28-bit fixnum: written without a +; suffix it encodes to 0x03545232, i.e. the constant is truncated. +; Hence: an explicit i32 literal, and a NUMERIC (=) comparison in magic-ok. +; Bumped to "STR3" in 1.6.1: records written before the eep-store-str fix hold +; corrupted strings (every 4th character). A new magic makes those records fail +; the check, so a device loads clean defaults instead of a mangled broker host. +(def eep-magic 0x53545233i32) ; "STR3" + +(def A-MAGIC 0) (def A-PORT 1) (def A-VESCID 2) (def A-PUBMODE 3) +(def A-KEEPALIVE 4) (def A-PUBMS 5) (def A-SPEEDDIV 6) (def A-TORQUE 7) +(def A-CANMODE 8) ; (9 still reserved; 10-36 legacy ssid/pass) +(def A-HOST 40) +(def A-USER 60) (def A-MPASS 70) (def A-PREFIX 80) + +; eeprom-store-i/-f return nil on failure instead of throwing. Silently +; discarding that is how a whole config could fail to persist while the panel +; reported success. Record the first failing slot so SAVE can name it. +(defun st-i (a v) { + (if (not (eq (eeprom-store-i a v) t)) + (if (< save-fail 0) (setq save-fail a))) +}) +(defun st-f (a v) { + (if (not (eq (eeprom-store-f a v) t)) + (if (< save-fail 0) (setq save-fail a))) +}) + +; Pack a string into EEPROM starting at `addr`: addr = length, then `maxwords` +; data words (4 chars each, zero padded). +; NOTE the 0i32 accumulator and the (to-i32 ch) cast, and do not remove them. +; A default fixnum is only 28 bits, so packing the 4th byte of a word -- +; (shl ch 24) -- overflowed it and the top nibble wrapped into the sign. Every +; character at index%4 == 3 came back mangled ('k' -> 251, 'e' -> 5, '.' -> +; 254), which corrupted mqtt_host, topic_prefix, user and password on reload. +; Invisible until a reboot, because the typed values live in RAM until then. +(defun eep-store-str (addr maxwords s) { + (var len (str-len s)) + (st-i addr len) + (looprange w 0 maxwords { + (var v 0i32) + (looprange b 0 4 { + (var idx (+ (* w 4) b)) + (var ch (if (< idx len) (bufget-u8 s idx) 0)) + (setq v (bitwise-or v (shl (to-i32 ch) (* b 8)))) + }) + (st-i (+ addr 1 w) v) + }) +}) + +; Read a string previously packed with eep-store-str. Returns a null-terminated +; byte-array (usable as a string) or nil if the stored length is invalid. +(defun eep-read-str (addr maxlen) { + (var len (eeprom-read-i addr)) + (if (or (not (eq (type-of len) (type-of 1i32))) (< len 0) (> len maxlen)) + nil + { + (var s (bufcreate (+ len 1))) + (looprange i 0 len { + (var v (eeprom-read-i (+ addr 1 (/ i 4)))) + (bufset-u8 s i (bitwise-and (shr v (* (mod i 4) 8)) 0xFF)) + }) + s + }) +}) + +(defun save-config () { + (setq save-fail -1) + (st-i A-PORT cfg-mqtt-port) + (st-i A-VESCID cfg-vesc-id) + (st-i A-PUBMODE cfg-pub-mode) + (st-i A-KEEPALIVE cfg-keepalive) + (st-i A-PUBMS (to-i (* cfg-pub-interval 1000.0))) + (st-i A-CANMODE cfg-can-mode) + (st-f A-SPEEDDIV cfg-speed-div) + (st-f A-TORQUE cfg-torque-k) + (eep-store-str A-HOST 16 cfg-mqtt-host) + (eep-store-str A-USER 8 cfg-mqtt-user) + (eep-store-str A-MPASS 8 cfg-mqtt-pass) + (eep-store-str A-PREFIX 12 cfg-topic-prefix) + (st-i A-MAGIC eep-magic) ; magic last -> only valid when whole record written +}) + +(defun load-defaults () { + (setq cfg-mqtt-host def-mqtt-host) + (setq cfg-mqtt-port def-mqtt-port) + (setq cfg-mqtt-user def-mqtt-user) + (setq cfg-mqtt-pass def-mqtt-pass) + (setq cfg-topic-prefix def-topic-prefix) + (setq cfg-pub-mode def-pub-mode) + (setq cfg-pub-interval def-pub-interval) + (setq cfg-vesc-id def-vesc-id) + (setq cfg-speed-div def-speed-div) + (setq cfg-torque-k def-torque-k) + (setq cfg-keepalive def-keepalive) + (setq cfg-can-mode def-can-mode) +}) + +; Load config from EEPROM. Returns t if a valid saved record was found, +; nil if defaults were used. +(defun load-config () { + (load-defaults) + (setq cfg-src "defaults") + (if (magic-ok) + { + (var p (eeprom-read-i A-PORT)) + (var v (eeprom-read-i A-VESCID)) + (var m (eeprom-read-i A-PUBMODE)) + (var k (eeprom-read-i A-KEEPALIVE)) + (var pi (eeprom-read-i A-PUBMS)) + (var cm (eeprom-read-i A-CANMODE)) + (var sd (eeprom-read-f A-SPEEDDIV)) + (var tk (eeprom-read-f A-TORQUE)) + (var ho (eep-read-str A-HOST 64)) + (var us (eep-read-str A-USER 32)) + (var mp (eep-read-str A-MPASS 32)) + (var pf (eep-read-str A-PREFIX 48)) + (if p (setq cfg-mqtt-port p)) + (if v (setq cfg-vesc-id v)) + (if m (setq cfg-pub-mode m)) + (if k (setq cfg-keepalive k)) + (if pi (setq cfg-pub-interval (/ (to-float pi) 1000.0))) + (if cm (setq cfg-can-mode cm)) + (if sd (setq cfg-speed-div sd)) + (if tk (setq cfg-torque-k tk)) + (if ho (setq cfg-mqtt-host ho)) + (if us (setq cfg-mqtt-user us)) + (if mp (setq cfg-mqtt-pass mp)) + (if pf (setq cfg-topic-prefix pf)) + (setq cfg-src "eeprom") + t + } + nil) +}) + +(defun clear-config () (eeprom-store-i A-MAGIC 0)) ; invalidate -> defaults on next boot + +; Is a valid saved record present? Compare numerically: eeprom-read-i yields an +; i32, and returns nil when the slot was never written. +(defun magic-ok () { + (var m (eeprom-read-i A-MAGIC)) + (if (eq (type-of m) (type-of 1i32)) (= m eep-magic) nil) +}) + +;;; ================= Minimal MQTT 3.1.1 client (QoS 0, publish only) ========= + +; CONNECT with optional username/password auth (flags 0x80/0x40). +(defun mqtt-send-connect (s client-id) { + (var idlen (str-len client-id)) + (var ulen (str-len cfg-mqtt-user)) + (var plen (str-len cfg-mqtt-pass)) + (var has-user (> ulen 0)) + (var has-pass (and has-user (> plen 0))) + (var flags (bitwise-or 0x02 + (bitwise-or (if has-user 0x80 0) (if has-pass 0x40 0)))) + (var rem (+ 10 2 idlen + (if has-user (+ 2 ulen) 0) + (if has-pass (+ 2 plen) 0))) + (var pkt (bufcreate (+ 2 rem))) ; rem < 128 (creds are length-capped) + (bufset-u8 pkt 0 0x10) + (bufset-u8 pkt 1 rem) + (bufset-u16 pkt 2 4) + (bufset-u8 pkt 4 77) (bufset-u8 pkt 5 81) + (bufset-u8 pkt 6 84) (bufset-u8 pkt 7 84) + (bufset-u8 pkt 8 4) + (bufset-u8 pkt 9 flags) + (bufset-u16 pkt 10 cfg-keepalive) + (bufset-u16 pkt 12 idlen) + (bufcpy pkt 14 client-id 0 idlen) + (var off (+ 14 idlen)) + (if has-user { + (bufset-u16 pkt off ulen) + (bufcpy pkt (+ off 2) cfg-mqtt-user 0 ulen) + (setq off (+ off 2 ulen)) + }) + (if has-pass { + (bufset-u16 pkt off plen) + (bufcpy pkt (+ off 2) cfg-mqtt-pass 0 plen) + (setq off (+ off 2 plen)) + }) + (tcp-send s pkt) +}) + +(defun mqtt-wait-connack (s) { + (var resp (tcp-recv s 4 5.0 false)) + (if (or (eq resp 'no-data) (eq resp 'disconnected) (eq resp nil)) + nil + (and (>= (buflen resp) 4) + (= (bufget-u8 resp 0) 0x20) + (= (bufget-u8 resp 3) 0))) +}) + +(defun mqtt-publish (s topic payload) { + (var tlen (str-len topic)) + (var plen (str-len payload)) + (var rem-len (+ 2 tlen plen)) + (var hdr (if (< rem-len 128) 2 3)) + (var pkt (bufcreate (+ hdr rem-len))) + (bufset-u8 pkt 0 0x30) + (if (< rem-len 128) + (bufset-u8 pkt 1 rem-len) + { + (bufset-u8 pkt 1 (bitwise-or (bitwise-and rem-len 0x7F) 0x80)) + (bufset-u8 pkt 2 (shr rem-len 7)) + }) + (bufset-u16 pkt hdr tlen) + (bufcpy pkt (+ hdr 2) topic 0 tlen) + (bufcpy pkt (+ hdr 2 tlen) payload 0 plen) + (tcp-send s pkt) +}) + +(defun mqtt-ping (s) (tcp-send s [0xC0 0x00])) + +(defun mqtt-disconnect-local () { + (if sock (tcp-close sock)) + (setq sock nil) +}) + +(defun mqtt-connect () { + (mqtt-disconnect-local) + (var s (tcp-connect cfg-mqtt-host cfg-mqtt-port)) + (if (or (eq s nil) (eq s 'unknown_host) (eq s 'unknown-host)) + { (setq mqtt-err "tcp-connect failed") (print "TCP connect to broker failed") } + { + (var cid (str-merge "mqttcan4vesc-" (str-from-n (mod (rand) 65536)))) + ; trap: on early boot the network stack may not be ready and + ; tcp-send can throw -- catch it, close the socket, and let the + ; main loop retry instead of killing the thread. + (match (trap { + (mqtt-send-connect s cid) + (mqtt-wait-connack s) + }) + ((exit-ok (? ok)) + (if ok + { + (setq sock s) + (setq last-tx (systime)) + (setq mqtt-err "none") + (print "MQTT connected") + } + { + (tcp-close s) + (setq mqtt-err "CONNACK rejected/auth") + (print "MQTT CONNACK failed") + })) + ((exit-error (? e)) + { + (tcp-close s) + (setq mqtt-err "connect send error (retrying)") + (print "MQTT connect send error (retrying)") + })) + }) +}) + +; Drain any bytes the broker sent (PINGRESP etc.); detect a dropped link. +(defun mqtt-drain (s) { + (var r (tcp-recv s 64 nil false)) + (if (eq r 'disconnected) (mqtt-disconnect-local)) +}) + +(defun pub (topic payload) + (if sock { + (var r (mqtt-publish sock (str-merge cfg-topic-prefix topic) payload)) + (if (eq r t) (setq last-tx (systime)) (mqtt-disconnect-local)) + })) +(defun pubf (topic val) (pub topic (str-from-n val "%.4f"))) + +;;; ================= CAN handling ============================================ + +(defun proc-can-eid (id data) + (if (>= (buflen data) 8) { + (var node (bitwise-and id 0xFFu32)) + (var cmd (shr id 8)) + (if (= cmd cmd-status-1) (setq last-seen-node node)) ; for Detect + (if (= node cfg-vesc-id) { + (cond + ((= cmd cmd-status-1) { + ; bytes 0-3 = eRPM (i32), 4-5 = current * 10 (i16), 6-7 = duty * 1000 + (var raw-i (to-float (bufget-i16 data 4))) ; amps * 10 + (setq erpm (to-float (bufget-i32 data 0))) + (setq current-motor (/ raw-i 10.0)) + (setq motor-speed (/ erpm cfg-speed-div)) + (setq motor-torque (* raw-i cfg-torque-k)) + (setq duty (/ (to-float (bufget-i16 data 6)) 1000.0)) + }) + ((= cmd cmd-status-2) { + (setq ah-used (/ (to-float (bufget-i32 data 0)) 10000.0)) + (setq ah-charged (/ (to-float (bufget-i32 data 4)) 10000.0)) + }) + ((= cmd cmd-status-3) { + (setq wh-used (/ (to-float (bufget-i32 data 0)) 10000.0)) + (setq wh-charged (/ (to-float (bufget-i32 data 4)) 10000.0)) + }) + ((= cmd cmd-status-4) { + (setq temp-fet (/ (to-float (bufget-i16 data 0)) 10.0)) + (setq temp-motor (/ (to-float (bufget-i16 data 2)) 10.0)) + (setq current-in (/ (to-float (bufget-i16 data 4)) 10.0)) + (setq pid-pos (/ (to-float (bufget-i16 data 6)) 50.0)) + }) + ((= cmd cmd-status-5) { + (setq tacho (to-float (bufget-i32 data 0))) + (setq v-in (/ (to-float (bufget-i16 data 4)) 10.0)) + }) + ((= cmd cmd-status-6) { + (setq adc1 (/ (to-float (bufget-i16 data 0)) 1000.0)) + (setq adc2 (/ (to-float (bufget-i16 data 2)) 1000.0)) + (setq adc3 (/ (to-float (bufget-i16 data 4)) 1000.0)) + (setq ppm (/ (to-float (bufget-i16 data 6)) 1000.0)) + }) + (t nil)) + (setq can-frames (+ can-frames 1)) + (setq last-frame (systime)) + (setq new-data true) + }) + })) + +;;; ================= Telemetry publishing ==================================== + +(defun jf (name val last) + (str-merge "\"" name "\":" (str-from-n val "%.4f") (if last "}" ","))) + +(defun telemetry-json () + (str-merge "{" + ; raw, straight off the bus + (jf "erpm" erpm nil) + (jf "current_motor" current-motor nil) + ; derived from the two above (speed-div / torque-k) + (jf "motor_speed" motor-speed nil) + (jf "motor_torque" motor-torque nil) + (jf "duty" duty nil) + (jf "amp_hours" ah-used nil) + (jf "amp_hours_charged" ah-charged nil) + (jf "watt_hours" wh-used nil) + (jf "watt_hours_charged" wh-charged nil) + (jf "temp_fet" temp-fet nil) + (jf "temp_motor" temp-motor nil) + (jf "current_in" current-in nil) + (jf "pid_pos" pid-pos nil) + (jf "tachometer" tacho nil) + (jf "v_in" v-in nil) + (jf "adc1" adc1 nil) + (jf "adc2" adc2 nil) + (jf "adc3" adc3 nil) + (jf "ppm" ppm true))) + +(defun publish-topics () { + (pubf "erpm" erpm) (pubf "current_motor" current-motor) + (pubf "motor_speed" motor-speed) (pubf "motor_torque" motor-torque) + (pubf "duty" duty) + (pubf "amp_hours" ah-used) (pubf "amp_hours_charged" ah-charged) + (pubf "watt_hours" wh-used) (pubf "watt_hours_charged" wh-charged) + (pubf "temp_fet" temp-fet) (pubf "temp_motor" temp-motor) + (pubf "current_in" current-in) (pubf "pid_pos" pid-pos) + (pubf "tachometer" tacho) (pubf "v_in" v-in) + (pubf "adc1" adc1) (pubf "adc2" adc2) (pubf "adc3" adc3) (pubf "ppm" ppm) +}) + +(defun publish-telemetry () { + (if (or (= cfg-pub-mode 0) (= cfg-pub-mode 2)) (pub "telemetry" (telemetry-json))) + (if (or (= cfg-pub-mode 1) (= cfg-pub-mode 2)) (publish-topics)) +}) + +; vesc-mode FALLBACK ONLY: if no status frames are being decoded (statuses +; disabled on the ESC, or a firmware that hides them from lisp), pull what the +; firmware's own VESC-protocol decoder cached via canget-* (real-unit floats). +; Fewer fields than the event decode: amp_hours / watt_hours / tachometer / +; pid_pos have no canget-* and stay 0. Does NOT bump can-frames — that counter +; means "real status frames decoded", so a frozen can_frames + moving telemetry +; on the panel is the signature of running on this fallback. +(defun poll-vesc () { + (var id cfg-vesc-id) + (setq erpm (canget-rpm id)) ; electrical rpm + (setq current-motor (canget-current id)) ; amps + (setq motor-speed (/ erpm cfg-speed-div)) + ; torque-k is per raw 0.1 A unit, so scale amps back up by 10 + (setq motor-torque (* current-motor (* 10.0 cfg-torque-k))) + (setq duty (canget-duty id)) + (setq v-in (canget-vin id)) + (setq temp-fet (canget-temp-fet id)) + (setq temp-motor (canget-temp-motor id)) + (setq current-in (canget-current-in id)) + (setq adc1 (canget-adc id 0)) (setq adc2 (canget-adc id 1)) (setq adc3 (canget-adc id 2)) + (setq ppm (canget-ppm id)) +}) + +;;; ================= Simulation (can_mode = 2) =============================== +; Bench/demo mode: no VESC on the CAN bus. Each publish tick fills the +; telemetry globals with a plausible random walk so the full MQTT/dashboard +; chain can be tested end to end. can_frames counts sim ticks in this mode. + +(defun frand () (/ (to-float (bitwise-and (rand) 0x7FFF)) 32767.0)) ; 0.0 - 1.0 +(defun clampf (v lo hi) (if (< v lo) lo (if (> v hi) hi v))) +(defun walk (v lo hi step) (clampf (+ v (* (- (frand) 0.5) 2.0 step)) lo hi)) + +(defun sim-telemetry () { + (var dt cfg-pub-interval) + ; walk the RAW signals, then derive exactly as the real decoder does, so + ; sim output stays self-consistent with speed-div / torque-k. + (setq erpm (walk erpm 0.0 10000.0 480.0)) + (setq current-motor (walk current-motor 0.0 60.0 4.0)) + (setq motor-speed (/ erpm cfg-speed-div)) + (setq motor-torque (* current-motor (* 10.0 cfg-torque-k))) + (setq duty (clampf (/ motor-speed 2600.0) 0.0 0.95)) + (setq current-in (clampf (+ (* current-motor duty) (* (frand) 2.0)) 0.0 60.0)) + (setq v-in (walk v-in 42.0 54.4 0.2)) + (setq temp-fet (walk temp-fet 25.0 85.0 0.5)) + (setq temp-motor (walk temp-motor 25.0 95.0 0.5)) + (setq pid-pos (walk pid-pos 0.0 360.0 15.0)) + (setq adc1 (walk adc1 0.0 3.3 0.1)) + (setq adc2 (walk adc2 0.0 3.3 0.1)) + (setq adc3 (walk adc3 0.0 3.3 0.1)) + (setq ppm (walk ppm 0.0 1.0 0.05)) + ; integrators: consumed charge/energy and distance follow the sim load + (setq ah-used (+ ah-used (/ (* current-in dt) 3600.0))) + (setq wh-used (+ wh-used (/ (* (* current-in v-in) dt) 3600.0))) + (setq tacho (+ tacho (* motor-speed dt))) + (setq can-frames (+ can-frames 1)) +}) + +;;; ================= WiFi helper ============================================= +; The firmware connects and auto-reconnects on its own (station config in +; VESC Tool). We only read the status; `wifi-status` errors if WiFi Mode is +; Disabled, so trap it and report 'disabled rather than dying. + +(defun wifi-state () + (match (trap (wifi-status)) + ((exit-ok (? s)) s) + ((exit-error (? e)) 'disabled))) + +(defun wifi-up () (eq (wifi-state) 'connected)) + +;;; ================= Panel protocol (custom app data) ======================== +; QML sends null-terminated ASCII commands; we reply with null-terminated +; ASCII. Dispatch on the first whitespace-delimited token. See docs/qml-panel.md. + +(defun streq (a b) (= (str-cmp a b) 0)) + +; NOTE: `str-find` is absent on older Express firmware (it is the last +; extension added in lbm_string_extensions_init, so it is the first to drop +; off an older build). Strings are byte arrays, so scan for the char code +; ourselves. Returns the index of the first byte == c, or -1. +(defun str-idx (s c) { + (var n (str-len s)) + (var i 0) + (var res -1) + (loopwhile (and (< i n) (< res 0)) + (if (= (bufget-u8 s i) c) + (setq res i) + (setq i (+ i 1)))) + res +}) + +(defun first-token (s) { + (var i (str-idx s 32)) + (if (< i 0) s (str-part s 0 i)) +}) +(defun rest-after (s) { + (var i (str-idx s 32)) + ; guard: newer firmware's str-part errors when start >= len, so return + ; "" when there is no space OR nothing follows it (e.g. "SET key " with + ; an empty value like a blank wifi/mqtt password). + (if (or (< i 0) (>= (+ i 1) (str-len s))) + "" + (str-part s (+ i 1))) +}) + +(defun mode-name (m) (cond ((= m 1) "topics") ((= m 2) "both") (t "json"))) +(defun mode-code (s) (cond ((streq s "topics") 1) ((streq s "both") 2) (t 0))) +(defun can-mode-name (m) (cond ((= m 1) "vesc") ((= m 2) "sim") (t "raw"))) +(defun can-mode-code (s) (cond ((streq s "vesc") 1) ((streq s "sim") 2) (t 0))) + +; Comma-join a list of ints (for DETECT scan results); "none" if empty. +(defun join-ids (lst) { + (var s "") + (loopwhile lst { + (setq s (if (= (str-len s) 0) + (str-from-n (car lst)) + (str-merge s "," (str-from-n (car lst))))) + (setq lst (cdr lst)) + }) + (if (= (str-len s) 0) "none" s) +}) + +; Detect the controller id. vesc mode: can-scan the bus. raw mode: report the +; node id of the last status-1 frame sniffed. sim mode: report the Express's +; OWN can id -- a real controller can never share it, so a vesc_id equal to the +; bridge's own id is an unambiguous "this data is simulated" marker. It is also +; a plain number in 0..254, so unlike the old literal reply "sim" it passes the +; panel's field validation and survives a Save. +(defun do-detect () + (cond + ((= cfg-can-mode 2) (str-merge "DETECT " (str-from-n (can-local-id)))) + ((= cfg-can-mode 1) { + ; active scan; if nothing answers, fall back to the last node + ; whose status-1 frame we sniffed + (var ids (join-ids (can-scan))) + (str-merge "DETECT " + (if (and (streq ids "none") (>= last-seen-node 0)) + (str-from-n last-seen-node) ids)) + }) + (t (str-merge "DETECT " (if (>= last-seen-node 0) + (str-from-n last-seen-node) "none"))))) + +(defun kv (k v) (str-merge k "=" v "\n")) +(defun kvn (k v) (str-merge k "=" (str-from-n v) "\n")) +(defun kvf (k v) (str-merge k "=" (str-from-n v "%.4f") "\n")) + +(defun build-cfg () + (str-merge "CFG\n" + (kvn "ver" proto-version) + (kv "mqtt_host" cfg-mqtt-host) + (kvn "mqtt_port" cfg-mqtt-port) + (kv "mqtt_user" cfg-mqtt-user) + (kv "mqtt_pass" cfg-mqtt-pass) + (kv "topic_prefix" cfg-topic-prefix) + (kv "pub_mode" (mode-name cfg-pub-mode)) + (kvf "pub_interval" cfg-pub-interval) + (kv "can_mode" (can-mode-name cfg-can-mode)) + (kvn "vesc_id" cfg-vesc-id) + (kvf "speed_div" cfg-speed-div) + (kvf "torque_k" cfg-torque-k) + (kvn "keepalive" cfg-keepalive))) + +(defun build-tel () + (str-merge "TEL\n" + (kv "wifi_state" (to-str (wifi-state))) + (kv "mqtt_state" (if sock "connected" "disconnected")) + (kv "mqtt_err" mqtt-err) + (kv "cfg_src" cfg-src) + (kv "can_mode" (can-mode-name cfg-can-mode)) + (kvf "motor_speed" motor-speed) + (kvf "motor_torque" motor-torque) + (kvf "duty" duty) + (kvf "v_in" v-in) + (kvf "temp_fet" temp-fet) + (kvf "temp_motor" temp-motor) + (kvf "current_in" current-in) + (kvn "can_frames" can-frames) + (kv "pkg_ver" pkg-version))) + +; Validate + apply one SET. Returns nil ok, or an error string. +(defun apply-set (key val) { + (var vlen (str-len val)) + (cond + ; wifi_ssid / wifi_pass are deliberately absent: WiFi is configured + ; in VESC Tool -> VESC Express -> WiFi, not from this panel. + ((streq key "mqtt_host") + (if (or (< vlen 1) (> vlen 63)) "host len 1-63" + (progn (setq cfg-mqtt-host val) nil))) + ((streq key "mqtt_port") { + (var p (str-to-i val)) + (if (or (< p 1) (> p 65535)) "port 1-65535" + (progn (setq cfg-mqtt-port p) nil)) }) + ((streq key "mqtt_user") + (if (> vlen 32) "user len <=32" + (progn (setq cfg-mqtt-user val) nil))) + ((streq key "mqtt_pass") + (if (> vlen 32) "mpass len <=32" + (progn (setq cfg-mqtt-pass val) nil))) + ((streq key "topic_prefix") + (if (> vlen 48) "prefix len <=48" + (progn (setq cfg-topic-prefix val) nil))) + ((streq key "pub_mode") + (progn (setq cfg-pub-mode (mode-code val)) nil)) + ((streq key "can_mode") + (progn (setq cfg-can-mode (can-mode-code val)) nil)) + ((streq key "pub_interval") { + (var f (str-to-f val)) + (if (or (< f 0.1) (> f 5.0)) "interval 0.1-5.0" + (progn (setq cfg-pub-interval f) nil)) }) + ((streq key "vesc_id") { + (var v (str-to-i val)) + (if (or (< v 0) (> v 254)) "id 0-254" + (progn (setq cfg-vesc-id v) nil)) }) + ((streq key "speed_div") { + (var f (str-to-f val)) + (if (<= f 0.0) "speed_div >0" + (progn (setq cfg-speed-div f) nil)) }) + ((streq key "torque_k") + (progn (setq cfg-torque-k (str-to-f val)) nil)) + ((streq key "keepalive") { + (var k (str-to-i val)) + (if (or (< k 10) (> k 300)) "keepalive 10-300" + (progn (setq cfg-keepalive k) nil)) }) + (t (str-merge "unknown key " key))) +}) + +(defun do-set (r) { + (var key (first-token r)) + (var val (rest-after r)) + (var err (apply-set key val)) + (if err (send-data (str-merge "ERR SET " key " " err)) + (send-data (str-merge "ACK SET " key))) +}) + +(defun handle-cmd (data) { + (var cmd (first-token data)) + (cond + ((streq cmd "GET") (send-data (build-cfg))) + ((streq cmd "TEL") (send-data (build-tel))) + ((streq cmd "SET") (do-set (rest-after data))) + ((streq cmd "DETECT") (send-data (do-detect))) + ((streq cmd "SAVE") { + ; Write, then READ BACK the magic to prove the record took. A + ; bare (save-config) is not evidence: handle-cmd traps errors, + ; so a throwing eeprom write used to vanish without a reply and + ; looked exactly like a working save that failed to persist. + (match (trap (save-config)) + ((exit-ok (? v)) + (if (>= save-fail 0) + (send-data (str-merge "ERR SAVE eeprom-write-rejected slot=" + (str-from-n save-fail))) + (if (magic-ok) + { + (setq reconnect-req true) + (send-data "ACK SAVE") + } + (send-data (str-merge "ERR SAVE verify-failed magic-read=" + (to-str (eeprom-read-i A-MAGIC))))))) + ((exit-error (? e)) + (send-data (str-merge "ERR SAVE threw: " (to-str e))))) }) + ((streq cmd "TEST") + (send-data + (if (and (wifi-up) sock) + "TEST ok" + (str-merge "TEST fail wifi=" (to-str (wifi-state)) + " mqtt=" (if sock "up" mqtt-err))))) + ((streq cmd "DEFAULTS") { + (clear-config) + (load-defaults) + (setq reconnect-req true) + (send-data (build-cfg)) }) + ((streq cmd "REBOOT") { + (send-data "ACK REBOOT") + (sleep 0.3) + (reboot) }) + (t (send-data (str-merge "ERR unknown " cmd)))) +}) + +;;; ================= Event handler (CAN + panel, single thread) ============== + +(defun event-handler () + (loopwhile t + (recv + ; trap each handler so one bad frame / panel command can't kill the thread + ((event-can-eid . ((? id) . (? data))) (trap (proc-can-eid id data))) + ((event-data-rx . (? data)) (trap (handle-cmd data))) + (_ nil)))) + +;;; ================= Main loop =============================================== + +(defun main-loop () + (loopwhile t { + ; self-healing: catch any transient error (e.g. tcp-send/tcp-recv + ; before the network stack is ready) so it can't kill this thread; + ; drop the socket and retry on the next iteration. + (match (trap { + ; SAVE/DEFAULTS: only the MQTT session is ours to restart. Never + ; touch WiFi here -- (wifi-disconnect) would disable the firmware's + ; auto-reconnect until reboot. + (if reconnect-req { + (setq reconnect-req false) + (mqtt-disconnect-local) + (sleep 0.5) + }) + (if (not (wifi-up)) + ; the firmware connects and reconnects on its own; just wait + { + (mqtt-disconnect-local) + (sleep 1) + } + (if (not sock) + { (mqtt-connect) (if (not sock) (sleep 3)) } + { + (cond + ; sim mode: generate fake data on the interval + ((= cfg-can-mode 2) + (if (>= (secs-since last-pub) cfg-pub-interval) { + (setq last-pub (systime)) + (sim-telemetry) + (publish-telemetry) + })) + ; raw AND vesc mode: publish decoded status data, + ; throttled to the interval. The firmware delivers + ; every CAN frame to event-can-eid even with the + ; VESC decoder on, so vesc mode gets the full + ; status 1-6 decode too. + ((and new-data (>= (secs-since last-pub) cfg-pub-interval)) { + (setq new-data false) + (setq last-pub (systime)) + (publish-telemetry) + }) + ; vesc mode, no status frames for 3 s (disabled on + ; the ESC?): fall back to polling the firmware's + ; canget-* cache so telemetry keeps flowing. + ((and (= cfg-can-mode 1) + (> (secs-since last-frame) 3.0) + (>= (secs-since last-pub) cfg-pub-interval)) { + (setq last-pub (systime)) + (poll-vesc) + (publish-telemetry) + }) + (t nil)) + (if (> (secs-since last-tx) (/ cfg-keepalive 2)) { + (if (eq (mqtt-ping sock) t) + (setq last-tx (systime)) + (mqtt-disconnect-local)) + }) + (if sock (mqtt-drain sock)) + })) + }) + ((exit-error (? e)) (mqtt-disconnect-local)) + ((exit-ok (? v)) nil)) + (sleep 0.1) + })) + +;;; ================= Entry point ============================================= +; Newer LispBM `image-save` requires a global closure named `main`, and the +; firmware auto-runs (main) on boot. Keep ALL runtime startup here so it re-runs +; every boot (image-save saves the code/env, not running threads/registrations). +(defun main () { + ; Let the firmware's WiFi / CAN / network subsystems finish coming up + ; before we use them. On a cold boot the LBM VM starts very early; using + ; the network too soon throws (e.g. tcp-send) and, without the traps + ; below, would leave the script dead until a manual restart. + (sleep 3) + (load-config) ; sets cfg-can-mode from EEPROM (needed next) + ; The firmware hands every CAN frame to event-can-eid BEFORE (and + ; regardless of) its own VESC-protocol decoder, so status sniffing + ; works with the protocol on. vesc/sim -> protocol ON (VESC Tool CAN + ; bridging + canget-* fallback work). raw -> protocol OFF; only needed + ; if a firmware build ever stops delivering events with it on. + (can-use-vesc (if (= cfg-can-mode 0) false true)) + (print (str-merge "MQTTCAN4VESC config from " cfg-src)) + (print (str-merge "MQTTCAN4VESC loaded. mode=" (can-mode-name cfg-can-mode) + " wifi=" (to-str (wifi-state)) " broker=" cfg-mqtt-host)) + (if (eq (wifi-state) 'disabled) + (print "WiFi is disabled/AP-mode. Set WiFi Mode = Station + SSID/Key in VESC Tool -> VESC Express -> WiFi.")) + (event-register-handler (spawn event-handler)) + (event-enable 'event-can-eid) + (event-enable 'event-data-rx) + (main-loop) +}) + +@const-end +; ^ everything above is read into flash (const heap), not the RAM cons heap. +; This is required on the ESP32-C3 Express: when BOTH WiFi and BLE are enabled +; the LispBM RAM heap is halved (~4096 cons cells / 32 KB), and the script would +; otherwise hit "eval: out of memory" on upload. Keep the runtime startup below +; OUTSIDE the const region. + +;;; ================= Start ==================================================== +; Save the flat image (requires the `main` closure above), then run it. On every +; boot the firmware auto-runs (main); calling it here starts it for this session. +(image-save) +(main) diff --git a/mqttcan4vesc/pkgdesc.qml b/mqttcan4vesc/pkgdesc.qml new file mode 100644 index 00000000..fd81bf14 --- /dev/null +++ b/mqttcan4vesc/pkgdesc.qml @@ -0,0 +1,18 @@ +import QtQuick 2.15 + +Item { + property string pkgName: "MQTTCAN4VESC" + property string pkgDescriptionMd: "README.md" + property string pkgLisp: "mqttcan4vesc.lisp" + property string pkgQml: "ui.qml" + property bool pkgQmlIsFullscreen: false + property string pkgOutput: "mqttcan4vesc.vescpkg" + + // This package runs on a VESC Express, not on the motor controller. + // Stock boards report "VESC Express"; custom Express variants commonly + // report "VESC Exp ", so accept both spellings. + function isCompatible(fwRxParams) { + var hw = fwRxParams.hw.toLowerCase() + return hw.indexOf("express") >= 0 || hw.indexOf("exp ") >= 0 + } +} diff --git a/mqttcan4vesc/ui.qml b/mqttcan4vesc/ui.qml new file mode 100644 index 00000000..f78267e3 --- /dev/null +++ b/mqttcan4vesc/ui.qml @@ -0,0 +1,489 @@ +// MQTTCAN4VESC configuration panel. +// +// Talks to mqttcan4vesc.lisp on the VESC Express with a small text protocol over +// custom app data (see docs/qml-panel.md): +// out: "GET" | "SET " | "SAVE" | "TEL" | "TEST" | "DEFAULTS" | "REBOOT" +// in: "CFG\n..." | "TEL\n..." | "ACK ..." | "ERR ..." | "TEST ..." +// +// Single scrollable column so it works on narrow mobile VESC Tool. + +import QtQuick 2.15 +import QtQuick.Controls 2.12 +import QtQuick.Layouts 1.3 + +import Vedder.vesc.commands 1.0 +import Vedder.vesc.utility 1.0 + +Item { + id: root + anchors.fill: parent + anchors.margins: 10 + + property Commands mCommands: VescIf.commands() + property color txt: Utility.getAppHexColor("lightText") + + Component.onCompleted: { + sendCmd("GET") // populate fields + telTimer.start() + } + + // Poll telemetry/status ~1 Hz + Timer { + id: telTimer + interval: 1000 + repeat: true + onTriggered: sendCmd("TEL") + } + + ScrollView { + anchors.fill: parent + clip: true + contentWidth: availableWidth + + ColumnLayout { + width: root.width - 20 + spacing: 8 + + Text { + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + color: txt + font.pointSize: 18 + text: "MQTTCAN4VESC" + } + + // ---------------- Live status ---------------- + GroupBox { + Layout.fillWidth: true + title: "Status" + label: Label { color: txt; text: parent.title } + + GridLayout { + anchors.fill: parent + columns: 2 + columnSpacing: 10 + rowSpacing: 2 + + Label { color: txt; text: "WiFi" } + Label { id: stWifi; color: txt; text: "-" } + Label { color: txt; text: "MQTT" } + Label { id: stMqtt; color: txt; text: "-" } + Label { color: txt; text: "CAN frames" } + Label { id: stFrames; color: txt; text: "-" } + Label { color: txt; text: "Speed" } + Label { id: stSpeed; color: txt; text: "-" } + Label { color: txt; text: "Torque" } + Label { id: stTorque; color: txt; text: "-" } + Label { color: txt; text: "V in" } + Label { id: stVin; color: txt; text: "-" } + Label { color: txt; text: "Temp FET / Motor" } + Label { id: stTemp; color: txt; text: "-" } + Label { color: txt; text: "Current in" } + Label { id: stCurr; color: txt; text: "-" } + Label { color: txt; text: "Config from" } + Label { id: stSrc; color: txt; text: "-" } + Label { color: txt; text: "Last save" } + Label { id: stSave; color: txt; text: "-"; Layout.fillWidth: true; wrapMode: Text.Wrap } + Label { color: txt; text: "Version" } + Label { id: stVer; color: txt; text: "-"; font.pointSize: 8 } + } + } + + // ---------------- WiFi (configured in the firmware, not here) ---- + GroupBox { + Layout.fillWidth: true + title: "WiFi" + label: Label { color: txt; text: parent.title } + + ColumnLayout { + anchors.fill: parent + spacing: 4 + + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + color: txt + font.pointSize: 8 + text: "WiFi is configured on the device itself, not from this panel: " + + "VESC Express → WiFi → set WiFi Mode = Station and fill in " + + "Station Mode SSID / Key, then write and reboot. The firmware " + + "connects and reconnects automatically; this package only uses the link." + } + } + } + + // ---------------- MQTT broker ---------------- + GroupBox { + Layout.fillWidth: true + title: "MQTT Broker" + label: Label { color: txt; text: parent.title } + + ColumnLayout { + anchors.fill: parent + spacing: 4 + + Label { color: txt; text: "Host (max 63)" } + TextField { id: fHost; Layout.fillWidth: true; maximumLength: 63 } + + Label { color: txt; text: "Port" } + TextField { + id: fPort + Layout.fillWidth: true + inputMethodHints: Qt.ImhDigitsOnly + validator: IntValidator { bottom: 1; top: 65535 } + } + + Label { color: txt; text: "Username (max 32, blank = no auth)" } + TextField { id: fUser; Layout.fillWidth: true; maximumLength: 32 } + + Label { color: txt; text: "Password (max 32)" } + RowLayout { + Layout.fillWidth: true + TextField { + id: fMqttPass + Layout.fillWidth: true + maximumLength: 32 + echoMode: showMqttPass.checked ? TextInput.Normal : TextInput.Password + } + CheckBox { id: showMqttPass; text: "Show" } + } + } + } + + // ---------------- Publishing ---------------- + GroupBox { + Layout.fillWidth: true + title: "Publishing" + label: Label { color: txt; text: parent.title } + + ColumnLayout { + anchors.fill: parent + spacing: 4 + + Label { color: txt; text: "Topic prefix (max 48)" } + TextField { id: fPrefix; Layout.fillWidth: true; maximumLength: 48 } + + Label { color: txt; text: "Publish mode" } + ComboBox { + id: fMode + Layout.fillWidth: true + model: ["json", "topics", "both"] + } + + Label { color: txt; text: "Publish interval (s, 0.1 - 5.0)" } + TextField { + id: fInterval + Layout.fillWidth: true + validator: DoubleValidator { bottom: 0.1; top: 5.0; decimals: 2 } + } + + Label { color: txt; text: "MQTT keepalive (s, 10 - 300)" } + TextField { + id: fKeepalive + Layout.fillWidth: true + inputMethodHints: Qt.ImhDigitsOnly + validator: IntValidator { bottom: 10; top: 300 } + } + } + } + + // ---------------- CAN ---------------- + GroupBox { + Layout.fillWidth: true + title: "CAN" + label: Label { color: txt; text: parent.title } + + ColumnLayout { + anchors.fill: parent + spacing: 4 + + Label { color: txt; text: "CAN mode" } + ComboBox { + id: fCanMode + Layout.fillWidth: true + model: ["raw", "vesc", "sim"] + } + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + color: txt + font.pointSize: 8 + text: fCanMode.currentText === "vesc" + ? "vesc (recommended): full status 1-6 decode AND VESC Tool can reach the ESC over CAN. Falls back to canget-* polling if no status frames arrive. Reboot after Save to switch modes." + : fCanMode.currentText === "sim" + ? "sim: publishes simulated telemetry — no VESC needed. For bench/demo testing of the MQTT chain. Reboot after Save to switch modes." + : "raw: full status 1-6 decode; disables VESC-protocol CAN (no VESC Tool bridging). Only for firmware that hides CAN events in vesc mode. Reboot after Save to switch modes." + } + + Label { color: txt; text: "Controller VESC ID (0 - 254)" } + RowLayout { + Layout.fillWidth: true + TextField { + id: fVescId + Layout.fillWidth: true + inputMethodHints: Qt.ImhDigitsOnly + validator: IntValidator { bottom: 0; top: 254 } + } + Button { text: "Detect"; onClicked: sendCmd("DETECT") } + } + } + } + + // ---------------- Motor / decode ---------------- + GroupBox { + Layout.fillWidth: true + title: "Motor" + label: Label { color: txt; text: parent.title } + + ColumnLayout { + anchors.fill: parent + spacing: 4 + + Label { color: txt; text: "Speed divisor (pole pairs)" } + TextField { + id: fSpeedDiv + Layout.fillWidth: true + validator: DoubleValidator { bottom: 0.0001; top: 1000 } + } + + Label { color: txt; text: "Torque constant (Nm per raw 0.1 A)" } + TextField { + id: fTorqueK + Layout.fillWidth: true + validator: DoubleValidator { bottom: 0; top: 100 } + } + } + } + + // ---------------- Actions ---------------- + Button { + Layout.fillWidth: true + text: "Save & Apply" + onClicked: saveAll() + } + RowLayout { + Layout.fillWidth: true + Button { Layout.fillWidth: true; text: "Test Connection"; onClicked: sendCmd("TEST") } + Button { Layout.fillWidth: true; text: "Reload"; onClicked: sendCmd("GET") } + } + RowLayout { + Layout.fillWidth: true + Button { + Layout.fillWidth: true + text: "Restore Defaults" + onClicked: { + VescIf.emitMessageDialog("Restore Defaults", + "Reset all settings to factory defaults?", true, false) + sendCmd("DEFAULTS") + } + } + Button { + Layout.fillWidth: true + text: "Reboot" + onClicked: sendCmd("REBOOT") + } + } + + // ---------------- Log ---------------- + Label { color: txt; text: "Log" } + ScrollView { + Layout.fillWidth: true + Layout.preferredHeight: 120 + clip: true + TextArea { + id: logArea + readOnly: true + font.family: "DejaVu Sans Mono" + font.pointSize: 8 + wrapMode: TextEdit.Wrap + } + } + } + } + + // ================= Protocol ================= + + function sendCmd(str) { + mCommands.sendCustomAppData(str + "\0") // null-terminate for lisp + } + + function log(s) { + logArea.text += s + "\n" + // Without this the newest line (notably the device's ACK/ERR SAVE reply, + // which arrives after saveAll's own message) scrolls out of view and the + // panel looks as though the device never answered. + logArea.cursorPosition = logArea.length + } + + // ---- paced command queue ------------------------------------------------- + // + // Custom app data reaches the script through lbm_event(), which SILENTLY + // DROPS the message when the event handler's mailbox is fuller than the + // event queue (eval_cps.c: lbm_event). The same mailbox carries every + // event-can-eid frame -- six status messages at up to 50 Hz -- so firing + // 12 SETs and a SAVE back-to-back reliably lost the tail of the burst. + // Symptom: the SETs applied (RAM), SAVE vanished with no reply, and nothing + // ever reached EEPROM. + // + // So: send one command per tick, and do not consider a save done until the + // device acknowledges it. Re-send SAVE if it goes unanswered. + + property var cmdQueue: [] + property int saveTries: 0 + readonly property int maxSaveTries: 5 + + Timer { // drains cmdQueue, one command per tick + id: sendTimer + interval: 60 + repeat: true + onTriggered: { + if (root.cmdQueue.length === 0) { sendTimer.stop(); return } + var c = root.cmdQueue.shift() + sendCmd(c) + if (c === "SAVE") saveWatchdog.restart() + } + } + + Timer { // SAVE unanswered -> assume dropped, retry + id: saveWatchdog + interval: 1500 + repeat: false + onTriggered: { + if (root.saveTries < root.maxSaveTries) { + root.saveTries++ + log("No reply to SAVE (likely dropped) - retry " + root.saveTries + + "/" + root.maxSaveTries) + root.cmdQueue.push("SAVE") + sendTimer.start() + } else { + log("SAVE failed: device never acknowledged after " + + root.maxSaveTries + " attempts.") + stSave.text = "FAILED - no reply from device" + } + } + } + + function queueCmds(list) { + for (var i = 0; i < list.length; i++) root.cmdQueue.push(list[i]) + sendTimer.start() + } + + function saveAll() { + // no wifi_ssid / wifi_pass: WiFi lives in the Express firmware config + root.saveTries = 0 + stSave.text = "saving..." + queueCmds([ + "SET mqtt_host " + fHost.text, + "SET mqtt_port " + fPort.text, + "SET mqtt_user " + fUser.text, + "SET mqtt_pass " + fMqttPass.text, + "SET topic_prefix " + fPrefix.text, + "SET pub_mode " + fMode.currentText, + "SET pub_interval " + fInterval.text, + "SET keepalive " + fKeepalive.text, + "SET can_mode " + fCanMode.currentText, + "SET vesc_id " + fVescId.text, + "SET speed_div " + fSpeedDiv.text, + "SET torque_k " + fTorqueK.text, + "SAVE" + ]) + log("Save queued (" + root.cmdQueue.length + " commands)...") + } + + // Parse a "TYPE\nk=v\nk=v..." reply into a JS object of key->value. + function parseKv(str) { + var o = {} + var lines = str.split("\n") + for (var i = 1; i < lines.length; i++) { // line 0 is the type token + var line = lines[i] + if (line.length === 0) continue + var eq = line.indexOf("=") // split on FIRST '=' only + if (eq < 0) continue + o[line.substring(0, eq)] = line.substring(eq + 1) + } + return o + } + + function applyCfg(str) { + var c = parseKv(str) + if ("mqtt_host" in c) fHost.text = c.mqtt_host + if ("mqtt_port" in c) fPort.text = c.mqtt_port + if ("mqtt_user" in c) fUser.text = c.mqtt_user + if ("mqtt_pass" in c) fMqttPass.text = c.mqtt_pass + if ("topic_prefix" in c) fPrefix.text = c.topic_prefix + if ("pub_mode" in c) fMode.currentIndex = Math.max(0, fMode.model.indexOf(c.pub_mode)) + if ("pub_interval" in c) fInterval.text = c.pub_interval + if ("keepalive" in c) fKeepalive.text = c.keepalive + if ("can_mode" in c) fCanMode.currentIndex = Math.max(0, fCanMode.model.indexOf(c.can_mode)) + if ("vesc_id" in c) fVescId.text = c.vesc_id + if ("speed_div" in c) fSpeedDiv.text = c.speed_div + if ("torque_k" in c) fTorqueK.text = c.torque_k + } + + function applyTel(str) { + var t = parseKv(str) + if ("wifi_state" in t) stWifi.text = t.wifi_state + if ("mqtt_state" in t) stMqtt.text = t.mqtt_state + (("mqtt_err" in t && t.mqtt_err !== "none") ? " [" + t.mqtt_err + "]" : "") + if ("can_frames" in t) stFrames.text = t.can_frames + if ("motor_speed" in t) stSpeed.text = t.motor_speed + if ("motor_torque" in t) stTorque.text = t.motor_torque + if ("v_in" in t) stVin.text = t.v_in + if ("temp_fet" in t) stTemp.text = t.temp_fet + " / " + (("temp_motor" in t) ? t.temp_motor : "-") + if ("current_in" in t) stCurr.text = t.current_in + if ("cfg_src" in t) stSrc.text = t.cfg_src === "eeprom" ? "EEPROM (saved)" : "DEFAULTS (nothing saved)" + if ("pkg_ver" in t) stVer.text = t.pkg_ver + } + + Connections { + target: mCommands + + function onCustomAppDataReceived(data) { + var str = data.toString() + if (str.startsWith("CFG")) applyCfg(str) + else if (str.startsWith("TEL")) applyTel(str) + else if (str.startsWith("DETECT")) { + // Reply forms: "DETECT 111", "DETECT 1,2,3", "DETECT none", "DETECT sim". + // Only a numeric node id may reach the VESC ID field. In sim mode the + // device answers "sim"; writing that into the field made Save & Apply + // send "SET vesc_id sim", which the device parsed as 0 and stored. + var rest = str.substring(6).replace(/\0/g, "").trim() + log("Detect: " + (rest.length > 0 ? rest : "(empty reply)")) + var first = rest.split(",")[0].trim() + if (/^[0-9]+$/.test(first)) { + var id = parseInt(first, 10) + if (id >= 0 && id <= 254) fVescId.text = String(id) + else log("Detect: ignoring out-of-range id " + first) + } else if (rest.length > 0 && rest !== "none" && rest !== "sim") { + log("Detect: ignoring non-numeric reply \"" + rest + "\"") + } + } + // "ACK SET " is frequent during a save and stays quiet, but the + // one-shot "ACK SAVE" is the only proof the EEPROM record was written + // and verified. Never hide it. + else if (str.startsWith("ACK SAVE")) { + saveWatchdog.stop() + log("Saved to EEPROM and verified.") + stSave.text = "OK - written and verified" + // Re-read what the device actually stored. A SET may itself have + // been dropped, in which case the panel would otherwise keep + // showing the typed value rather than the saved one. + root.cmdQueue.push("GET") + sendTimer.start() + } + else if (str.startsWith("ACK")) { /* quiet: acks are frequent during save */ } + else if (str.startsWith("ERR")) { + log(str) + if (str.startsWith("ERR SAVE")) { + saveWatchdog.stop() + stSave.text = str.substring(4) + } + } + else if (str.startsWith("TEST")) { + log(str) + VescIf.emitMessageDialog("Test Connection", str, true, false) + } else { + log(str) + } + } + } +} diff --git a/res_all.qrc b/res_all.qrc index f0bbd214..c4d7272b 100644 --- a/res_all.qrc +++ b/res_all.qrc @@ -34,6 +34,7 @@ UnleashedCreativityLights/UCLights.vescpkg wheelie_limiter/wheelie_limiter.vescpkg mt6701_config/mt6701_config.vescpkg + mqttcan4vesc/mqttcan4vesc.vescpkg