From 5b2e7d15a746c44595edf230062a491e25661523 Mon Sep 17 00:00:00 2001 From: Andrew Rankin Date: Thu, 21 May 2026 10:35:48 -0400 Subject: [PATCH 1/4] eluminocity: fix delta-bridge.conf.default path so first-boot seed works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default config was written to /etc/delta-bridge.conf.default but first-boot.sh seeds from $DBDIR/delta-bridge.conf.default (/etc/delta-bridge/delta-bridge.conf.default). The cp failed silently and first-boot.sh still printed "seeded ...", so the unit booted on compiled-in defaults (web_enable=0, rfid_enable=0) — no web UI, no RFID. Bench-caught 2026-05-21 on the first real M12 flash: the eraseblock fix booted clean, but the web UI never came up. Root-caused to this one path mismatch; delta-bridge's own log history confirms web + RFID work when a config file is present. - build-dcofimage.sh: write the default to etc/delta-bridge/ - first-boot.sh: guard the "seeded" message on cp success - verify_dcofimage.py: assert the default at the correct subdir path (the verifier had encoded the same wrong path, so it passed the bug) Co-Authored-By: Claude Opus 4.7 --- .../companion/image/build-dcofimage.sh | 9 ++++++--- .../companion/image/verify_dcofimage.py | 7 +++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/boards/eluminocity-ch21130/companion/image/build-dcofimage.sh b/boards/eluminocity-ch21130/companion/image/build-dcofimage.sh index 987a4a9..126bfef 100755 --- a/boards/eluminocity-ch21130/companion/image/build-dcofimage.sh +++ b/boards/eluminocity-ch21130/companion/image/build-dcofimage.sh @@ -117,7 +117,7 @@ chmod 0755 "$ROOT/root/LED_control" # --- 2b. default config + first-boot script --- mkdir -p "$ROOT/etc/delta-bridge" -cat > "$ROOT/etc/delta-bridge.conf.default" <<'EOF' +cat > "$ROOT/etc/delta-bridge/delta-bridge.conf.default" <<'EOF' # delta-bridge default config — copied to /Storage/delta-bridge.conf on # first boot by /etc/delta-bridge/first-boot.sh. Edit via the web UI # (Config tab) or directly on /Storage/delta-bridge.conf. @@ -196,8 +196,11 @@ rm -f "$CONF.new" # Seed config from default if missing (independent of USB). if [ ! -f "$CONF" ]; then - cp "$DBDIR/delta-bridge.conf.default" "$CONF" - echo "first-boot: seeded $CONF from default" + if cp "$DBDIR/delta-bridge.conf.default" "$CONF" 2>/dev/null; then + echo "first-boot: seeded $CONF from default" + else + echo "first-boot: WARNING — could not seed $CONF (default missing at $DBDIR/delta-bridge.conf.default)" + fi fi # Mount USB if a stick is present. /etc/rc runs `mdev -s` before us, so diff --git a/boards/eluminocity-ch21130/companion/image/verify_dcofimage.py b/boards/eluminocity-ch21130/companion/image/verify_dcofimage.py index 62b0c2e..d4b9290 100755 --- a/boards/eluminocity-ch21130/companion/image/verify_dcofimage.py +++ b/boards/eluminocity-ch21130/companion/image/verify_dcofimage.py @@ -12,7 +12,7 @@ - Kept-stock binaries present (main, Pri_Comm, Charging_Standard_RFID, FlashLog, RTC, ErrorHandle, snmpd, snmptrap, mini_httpd, wpa_supplicant) - /etc/funs invokes first-boot.sh before any daemon - - /etc/delta-bridge/{first-boot.sh, stk-manifest.txt} + /etc/delta-bridge.conf.default present + - /etc/delta-bridge/{first-boot.sh, stk-manifest.txt, delta-bridge.conf.default} present Usage: verify_dcofimage.py [--expected-sha256 ] @@ -172,9 +172,12 @@ def check_image(image_path, expected_sha256=None): ok = False # Support files + # delta-bridge.conf.default MUST live under etc/delta-bridge/ — that's + # the path first-boot.sh's $DBDIR seed step reads. A bare etc/ copy + # silently breaks the first-boot seed (bench-caught 2026-05-21). for sub in ("delta-bridge/first-boot.sh", "delta-bridge/stk-manifest.txt", - "delta-bridge.conf.default"): + "delta-bridge/delta-bridge.conf.default"): p = extract / "etc" / sub if not p.is_file(): fail(f"/etc/{sub} missing") From d27c7c6eab54e6723a780a1c61115d971363d9da Mon Sep 17 00:00:00 2001 From: Andrew Rankin Date: Thu, 21 May 2026 12:25:37 -0400 Subject: [PATCH 2/4] eluminocity: decouple delta-bridge web/RFID from MQTT broker + write_enable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bench-caught bugs (2026-05-21 M12 flash), same root theme: the web UI's monitoring path was wrongly coupled to unrelated subsystems. Bug #2 — web/RFID starved while the MQTT broker is down. The main loop gated web_tick()/rfid_reader_tick() behind a `continue` in the broker- reconnect path, and slept the full backoff (up to 60s). With the default broker (127.0.0.1, never up) the web UI was permanently unreachable — and the web UI is the only way to fix a bad broker setting. Fix: the loop now runs at its normal period_us cadence regardless of broker state; the reconnect attempt is gated on a wall-clock deadline instead of a blocking sleep, so web/RFID are serviced every iteration. Bug #3 — /api/state showed charger_state_init() defaults (V/I/P=0, availability "offline") whenever write_enable=false, because main.c handed the web server a NULL shmem pointer. Fix: always pass &sm (a read-only attach when write_enable=false); reads work through the RO mapping. Writes already fail safe via writable_ptr(), but cs_apply_* now gates explicitly on sm->writable and returns a clear "write disabled (write_enable=false)" 503 instead of the misleading "shmem not attached" / "shmem write failed". - main.c: non-blocking broker retry; ws.shm = &sm unconditionally - commands.c: cs_apply_{rated_amps,authorize,clear_faults}_write gate on !sm->writable with a clear message - test_commands.c: +9 assertions — RO-shmem writes rejected, no mutation Co-Authored-By: Claude Opus 4.7 --- .../companion/src/commands.c | 21 +++--- .../eluminocity-ch21130/companion/src/main.c | 70 ++++++++++++------- .../eluminocity-ch21130/companion/src/web.c | 10 +-- .../eluminocity-ch21130/companion/src/web.h | 4 +- .../companion/test/test_commands.c | 24 +++++++ 5 files changed, 88 insertions(+), 41 deletions(-) diff --git a/boards/eluminocity-ch21130/companion/src/commands.c b/boards/eluminocity-ch21130/companion/src/commands.c index 3ea8306..bd0efc3 100644 --- a/boards/eluminocity-ch21130/companion/src/commands.c +++ b/boards/eluminocity-ch21130/companion/src/commands.c @@ -64,10 +64,11 @@ int cs_apply_rated_amps_write(struct shmem *sm, "(want int 6..30), ignored\n"); return -1; } - if (!sm) { - set_err(err, errcap, "shmem not attached"); + if (!sm || !sm->writable) { + set_err(err, errcap, "write disabled (write_enable=false)"); fprintf(stderr, - "delta-bridge: rated_amps: write requested but shmem is NULL\n"); + "delta-bridge: rated_amps: write requested but write is " + "disabled (write_enable=false)\n"); return -2; } if (shmem_write_u8(sm, OFF_RATED_AMPS, (uint8_t)v) != 0) { @@ -99,10 +100,11 @@ int cs_apply_authorize_write(struct shmem *sm, "ignored\n"); return -1; } - if (!sm) { - set_err(err, errcap, "shmem not attached"); + if (!sm || !sm->writable) { + set_err(err, errcap, "write disabled (write_enable=false)"); fprintf(stderr, - "delta-bridge: authorize: write requested but shmem is NULL\n"); + "delta-bridge: authorize: write requested but write is " + "disabled (write_enable=false)\n"); return -2; } if (shmem_write_u8(sm, OFF_USER_STATE, v) != 0) { @@ -119,10 +121,11 @@ int cs_apply_authorize_write(struct shmem *sm, int cs_apply_clear_faults_write(struct shmem *sm, char *err, size_t errcap) { - if (!sm) { - set_err(err, errcap, "shmem not attached"); + if (!sm || !sm->writable) { + set_err(err, errcap, "write disabled (write_enable=false)"); fprintf(stderr, - "delta-bridge: clear_faults: write requested but shmem is NULL\n"); + "delta-bridge: clear_faults: write requested but write is " + "disabled (write_enable=false)\n"); return -2; } if (shmem_write_u32_le(sm, OFF_ALARM_BITMAP, 0u) != 0) { diff --git a/boards/eluminocity-ch21130/companion/src/main.c b/boards/eluminocity-ch21130/companion/src/main.c index d623b5a..edcd2be 100644 --- a/boards/eluminocity-ch21130/companion/src/main.c +++ b/boards/eluminocity-ch21130/companion/src/main.c @@ -20,6 +20,7 @@ #include #include #include +#include #include static volatile sig_atomic_t g_stop = 0; @@ -198,10 +199,16 @@ int main(int argc, char **argv) } /* v0.4 embedded web server — opt-in via web_enable. State reads always - * work; control writes only succeed when write_enable=true (the helpers - * return -2/503 if shm is NULL). bring_up is best-effort: a port - * collision logs and skips the server but does NOT bring down the MQTT - * bridge. */ + * work; control writes only succeed when write_enable=true. bring_up is + * best-effort: a port collision logs and skips the server but does NOT + * bring down the MQTT bridge. + * + * ws.shm is ALWAYS &sm — the web server needs it for read-only state + * (/api/state). When write_enable=false, sm is a read-only attach + * (sm.writable=0) and the cs_apply_* write helpers reject writes with a + * "write disabled" 503. Handing the web server NULL here would instead + * blank out the whole status page (it'd render charger_state_init() + * defaults), so reads MUST get the pointer regardless of write_enable. */ struct web_server ws; memset(&ws, 0, sizeof(ws)); ws.listen_fd = -1; @@ -211,7 +218,7 @@ int main(int argc, char **argv) ws.web_user = cfg.web_user; ws.web_pass = cfg.web_pass; ws.cfg = &cfg; - ws.shm = cfg.write_enable ? &sm : NULL; + ws.shm = &sm; ws.orig_argv = argv; ws.conf_path = conf_path; if (web_server_start(&ws) == 0) @@ -223,10 +230,18 @@ int main(int argc, char **argv) int adapter_up = 0; int period_us = 1000000 / cfg.poll_hz; bo = 0; + /* When the broker is unreachable we retry on a backoff schedule, but the + * loop must NOT block on it: web_tick()/rfid_reader_tick() below have to + * keep running so the web UI stays reachable (it's the only way to fix a + * bad broker setting) and RFID keeps polling. So instead of sleeping the + * backoff, we gate the next reconnect attempt on a wall-clock deadline + * and let the loop run at its normal period_us cadence throughout. */ + time_t next_broker_retry = 0; /* 0 => attempt on the first iteration */ while (!g_stop) { - /* 2. ensure the adapter is up */ - if (!adapter_up) { + /* 2. (re)connect the MQTT adapter — rate-limited by backoff, never + * blocking. Steps 5/6 must run regardless of broker reachability. */ + if (!adapter_up && time(NULL) >= next_broker_retry) { if (nb.init(&nb) == 0) { adapter_up = 1; bo = 0; @@ -234,34 +249,37 @@ int main(int argc, char **argv) } else { bo = backoff_next(bo); fprintf(stderr, "delta-bridge: broker down, retry in %ds\n", bo); - interruptible_sleep(bo); - continue; + next_broker_retry = time(NULL) + bo; } } - /* 3. read + publish */ - charger_state_read(&cur, &sm); - unsigned int dirty = charger_state_diff(&prev, &cur); - int full = (prev.pilot_state == PILOT_UNKNOWN && - prev.voltage_v == 0.0f && prev.current_a == 0.0f); - if (full || dirty) { - if (nb.publish_state(&nb, &cur, dirty, full) != 0) { - adapter_up = 0; /* link down -> reconnect + full */ - continue; + /* 3. read + publish — only meaningful while the adapter is up. */ + if (adapter_up) { + charger_state_read(&cur, &sm); + unsigned int dirty = charger_state_diff(&prev, &cur); + int full = (prev.pilot_state == PILOT_UNKNOWN && + prev.voltage_v == 0.0f && prev.current_a == 0.0f); + if (full || dirty) { + if (nb.publish_state(&nb, &cur, dirty, full) != 0) + adapter_up = 0; /* link down -> reconnect + full */ + } + /* 4. housekeeping; tick() reporting down also forces reconnect. + * Skip if publish already dropped the link this iteration. */ + if (adapter_up) { + prev = cur; + if (nb.tick(&nb) != 0) + adapter_up = 0; } } - prev = cur; - - /* 4. housekeeping; tick() reporting down also forces reconnect */ - if (nb.tick(&nb) != 0) - adapter_up = 0; - /* 5. web tick — drain any pending HTTP requests. Best-effort: a - * failure here doesn't impact the MQTT loop. */ + /* 5. web tick — drain pending HTTP requests. Runs every iteration, + * independent of broker reachability: a broker outage must not take + * down the web UI. */ if (web_up) web_tick(&ws); - /* 6. rfid tick — non-blocking; cheap when no card is in field. */ + /* 6. rfid tick — non-blocking; cheap when no card is in field. Also + * independent of the broker. */ if (rdr) rfid_reader_tick(rdr); diff --git a/boards/eluminocity-ch21130/companion/src/web.c b/boards/eluminocity-ch21130/companion/src/web.c index 7dfac34..771e308 100644 --- a/boards/eluminocity-ch21130/companion/src/web.c +++ b/boards/eluminocity-ch21130/companion/src/web.c @@ -384,11 +384,11 @@ static size_t json_quote(char *out, size_t cap, size_t off, const char *s) static size_t build_state_json(struct web_server *ws, char *body, size_t cap) { - /* Snapshot the live shmem into a charger_state. If shm is NULL (no - * write_enable, no RW attach) we still want the page to function — we - * fall back to RO-attached numbers if available, but the bridge always - * has SOME shmem pointer at this point because main.c attaches before - * starting the web server. For belt-and-braces, gate on shm != NULL. */ + /* Snapshot the live shmem into a charger_state. main.c always hands the + * web server &sm — a read-only attach when write_enable=false — so the + * status page reflects live data regardless of write_enable. The + * shm != NULL guard below is belt-and-braces for host tests that may + * construct a bare web_server. */ struct charger_state cs; charger_state_init(&cs); int availability_online = 0; diff --git a/boards/eluminocity-ch21130/companion/src/web.h b/boards/eluminocity-ch21130/companion/src/web.h index 0305d8a..4a71a29 100644 --- a/boards/eluminocity-ch21130/companion/src/web.h +++ b/boards/eluminocity-ch21130/companion/src/web.h @@ -40,7 +40,9 @@ struct web_server { const char *web_user; /* points into cfg; empty = no auth */ const char *web_pass; struct config *cfg; /* read+write (POST /api/config) */ - struct shmem *shm; /* may be NULL when write_enable=0 */ + struct shmem *shm; /* live shmem (always set by main.c); + * reads work, writes gate on + * shm->writable inside cs_apply_* */ char **orig_argv; /* for execv() on /api/restart */ const char *conf_path; /* where POST /api/config writes */ int auth_disabled; /* derived from web_user/pass empty */ diff --git a/boards/eluminocity-ch21130/companion/test/test_commands.c b/boards/eluminocity-ch21130/companion/test/test_commands.c index fd8995b..41fa55b 100644 --- a/boards/eluminocity-ch21130/companion/test/test_commands.c +++ b/boards/eluminocity-ch21130/companion/test/test_commands.c @@ -77,6 +77,14 @@ static void test_rated_amps(void) /* NULL shmem -> -2 */ CHECK_EQ(cs_apply_rated_amps_write(NULL, (const unsigned char *)"15", 2, &out, err, sizeof(err)), -2); + + /* Read-only shmem (write_enable=false) -> -2 "write disabled", no mutation */ + sm.writable = 0; + err[0] = '\0'; + CHECK_EQ(cs_apply_rated_amps_write(&sm, (const unsigned char *)"15", 2, + &out, err, sizeof(err)), -2); + CHECK(strstr(err, "write disabled") != NULL); + CHECK_EQ(shmem_u8(&sm, OFF_RATED_AMPS), 16); /* unchanged */ } static void test_authorize(void) @@ -117,6 +125,14 @@ static void test_authorize(void) /* NULL shmem */ CHECK_EQ(cs_apply_authorize_write(NULL, (const unsigned char *)"ON", 2, &on, err, sizeof(err)), -2); + + /* Read-only shmem (write_enable=false) -> -2 "write disabled", no mutation */ + sm.writable = 0; + err[0] = '\0'; + CHECK_EQ(cs_apply_authorize_write(&sm, (const unsigned char *)"ON", 2, + &on, err, sizeof(err)), -2); + CHECK(strstr(err, "write disabled") != NULL); + CHECK_EQ(shmem_u8(&sm, OFF_USER_STATE), 0x55); /* unchanged */ } static void test_clear_faults(void) @@ -139,6 +155,14 @@ static void test_clear_faults(void) /* NULL shmem */ CHECK_EQ(cs_apply_clear_faults_write(NULL, err, sizeof(err)), -2); + + /* Read-only shmem (write_enable=false) -> -2 "write disabled", bitmap untouched */ + shmem_write_u32_le(&sm, OFF_ALARM_BITMAP, 0xCAFEu); /* seed while writable */ + sm.writable = 0; + err[0] = '\0'; + CHECK_EQ(cs_apply_clear_faults_write(&sm, err, sizeof(err)), -2); + CHECK(strstr(err, "write disabled") != NULL); + CHECK_EQ(shmem_u32_le(&sm, OFF_ALARM_BITMAP), 0xCAFEu); /* unchanged */ } int main(void) From d4e9523e9c1efa47566760fa2378b4ad35b6b7c4 Mon Sep 17 00:00:00 2001 From: Andrew Rankin Date: Thu, 21 May 2026 13:08:11 -0400 Subject: [PATCH 3/4] eluminocity: make dcofimage cross-build delta-bridge fresh; drop version suffix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DcoFImage build packed a fixed BRIDGE_BIN (delta-bridge.m12) that no make rule ever rebuilt — so editing src/ and running `make dcofimage` silently shipped a stale binary. Cost a wasted bench flash on 2026-05-21: the web-decouple fix was in src/ and the host test binaries but never reached the device. - `dcofimage` now depends on a new `cross` target that cross-compiles delta-bridge fresh in the muslcc Docker image (same invocation CI uses) — a stale binary is now structurally impossible. - BRIDGE_BIN: delta-bridge.m12 -> delta-bridge. The .m/.v suffixes were bench-juggling copies; git history is the version record. Old delta-bridge.{m12,v10..v15} artifacts deleted (none were tracked). Co-Authored-By: Claude Opus 4.7 --- boards/eluminocity-ch21130/companion/Makefile | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/boards/eluminocity-ch21130/companion/Makefile b/boards/eluminocity-ch21130/companion/Makefile index a75736c..462e8bc 100644 --- a/boards/eluminocity-ch21130/companion/Makefile +++ b/boards/eluminocity-ch21130/companion/Makefile @@ -1,10 +1,17 @@ # delta-bridge — companion MQTT bridge for the Eluminocity CH-21130 / Delta EVMU30. # # make cross-compile the static armv5te binary (delta-bridge) +# — needs the musl toolchain prefix on PATH +# make cross cross-compile via the muslcc Docker image (no local +# toolchain install needed) +# make dcofimage cross-build (via `cross`) + pack the M12 DcoFImage # make test build + run all host unit tests # make clean # # CROSS must point at the musl armv5te toolchain prefix (see ../README.md). +# The build artifact is always plain `delta-bridge` — no version suffix. +# Git history is the version record; `make dcofimage` rebuilds it fresh so +# it can never go stale against src/. CROSS ?= armv5l-linux-musleabi- CC ?= cc @@ -24,7 +31,7 @@ TESTS := test/test_smoke test/test_shmem test/test_charger_state test/test_mqtt_ test/test_mqtt_adapter test/test_config test/test_backoff test/test_commands \ test/test_web test/test_rfid test/test_meter test/test_adc test/test_led -.PHONY: all test test-image clean dcofimage +.PHONY: all test test-image clean dcofimage cross all: delta-bridge @@ -38,19 +45,30 @@ test: $(TESTS) test-image test-image: @cd image && python3 test_wrap_dco.py +# --- cross-compile via Docker --- +# Builds `delta-bridge` (ARMv5TE, musl, static) inside the muslcc Docker Hub +# image — musl.cc itself blocks CI/cloud IPs, Docker Hub stays reachable. +# The image ships an unprefixed toolchain, so we pass CROSS="". This is the +# same invocation CI uses (.github/workflows/eluminocity-companion.yml). +cross: + docker run --rm -v "$(CURDIR):/work" -w /work \ + muslcc/x86_64:armv5l-linux-musleabi \ + sh -c 'apk add --no-cache make >/dev/null && make clean && make CROSS=""' + # --- DcoFImage build (M12, see docs/22) --- -# Inputs: +# `dcofimage` depends on `cross`, so the binary is ALWAYS recompiled fresh +# before packing — no hand-built artifact, nothing to go stale. Inputs: # STOCK_ROOTFS — path to extracted stock rootfs -# BRIDGE_BIN — cross-compiled delta-bridge binary +# BRIDGE_BIN — cross-compiled delta-bridge binary (built by `cross`) # OUT_DIR — where to write DcoFImage / DcoFImage-stock-restore # Defaults match this repo's layout; override on the command line. STOCK_ROOTFS ?= ../../../../../testcharger/delta/dump/rootfs-unpacked -BRIDGE_BIN ?= delta-bridge.m12 +BRIDGE_BIN ?= delta-bridge OUT_DIR ?= ../../../../../../build/m12 -dcofimage: +dcofimage: cross @if [ ! -f "$(BRIDGE_BIN)" ]; then \ - echo "error: $(BRIDGE_BIN) missing — see docs/22 for the docker-cc invocation"; \ + echo "error: $(BRIDGE_BIN) missing — 'make cross' should have built it"; \ exit 1; \ fi image/build-dcofimage.sh "$(STOCK_ROOTFS)" "$(BRIDGE_BIN)" "$(OUT_DIR)" From 559f6cae4685b7dfff6322ebd02bef78749dd8e0 Mon Sep 17 00:00:00 2001 From: Andrew Rankin Date: Thu, 21 May 2026 14:23:21 -0400 Subject: [PATCH 4/4] =?UTF-8?q?eluminocity:=20docs=20=E2=80=94=20M12=20ben?= =?UTF-8?q?ch=20validation=20(2026-05-21)=20+=20DeltaEVSEConfig=20write=20?= =?UTF-8?q?findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/22: build invocation simplified to `make dcofimage` (now cross-builds fresh); new "Bench-validation results (2026-05-21)" — M12 image flashed + booted over 3 cycles, the config-seed / web-decouple / stale-binary bugs found and fixed, bug #3 confirmed live on hardware. docs/23: §7 — driving GetConfig() programmatically. The mknod /dev/sda workaround (bench-confirmed), and the critical full-replace finding: a partial DeltaEVSEConfig resets absent keys to defaults, so writes must carry all 36 keys. Plus the ErrorHandle SNMP-trap path. Co-Authored-By: Claude Opus 4.7 --- .../docs/22-m12-stripped-dcofimage.md | 54 ++++++++++--------- .../docs/23-deltaevseconfig-usb-import.md | 33 ++++++++++++ 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/boards/eluminocity-ch21130/docs/22-m12-stripped-dcofimage.md b/boards/eluminocity-ch21130/docs/22-m12-stripped-dcofimage.md index 51f6cf8..af1b36c 100644 --- a/boards/eluminocity-ch21130/docs/22-m12-stripped-dcofimage.md +++ b/boards/eluminocity-ch21130/docs/22-m12-stripped-dcofimage.md @@ -138,32 +138,22 @@ Wrapping handled by `companion/image/wrap_dco.py` (already host-tested). ## Build invocation ```sh -# 1. Cross-compile delta-bridge with the build identifiers exposed at /api/build +# One command. `make dcofimage` cross-compiles delta-bridge fresh inside +# the muslcc Docker image (the `cross` target), packs the DcoFImage, and +# runs verify_dcofimage.py — all in sequence. There is no hand-built +# binary that can go stale against src/. cd boards/eluminocity-ch21130/companion -SHA=$(git rev-parse --short HEAD) -DATE=$(date -u +%Y-%m-%dT%H:%MZ) -docker run --rm -v "$(pwd)":/work -w /work -u "$(id -u):$(id -g)" \ - muslcc/x86_64:armv5l-linux-musleabi cc -Wall -Wextra -std=c11 -O2 \ - -Isrc -static \ - -DDELTA_BRIDGE_VERSION='"m12"' \ - -DDELTA_BRIDGE_BUILD_SHA="\"$SHA\"" \ - -DDELTA_BRIDGE_BUILD_DATE="\"$DATE\"" \ - -o delta-bridge.m12 \ - src/shmem.c src/charger_state.c src/mqtt_codec.c src/mqtt_client.c \ - src/mqtt_adapter.c src/commands.c src/config.c src/web.c src/rfid.c \ - src/meter.c src/adc.c src/led.c src/main.c - -# 2. Build the image (uses the existing extracted stock rootfs) make dcofimage -# or, explicit: -# image/build-dcofimage.sh delta-bridge.m12 - -# 3. Verify (also runs automatically as the second half of `make dcofimage`) -image/verify_dcofimage.py --expected-sha256 \ - "$(sha256sum delta-bridge.m12 | cut -d' ' -f1)" \ - /DcoFImage +# Outputs: build/m12/{DcoFImage, DcoFImage-stock-restore} ``` +> **Build identifiers (follow-up).** The previous hand-invocation passed +> `-DDELTA_BRIDGE_BUILD_SHA` / `-DDELTA_BRIDGE_BUILD_DATE` so `/api/build` +> reported the git sha + date. The Makefile `cross` target does not yet +> pass these, so `/api/build` currently reports `(unknown)`. Follow-up: +> thread `git rev-parse --short HEAD` + a UTC date into the `cross` +> recipe's `-D` flags. + ## Builder `companion/image/build-dcofimage.sh` (rewrite of the previous version @@ -284,8 +274,24 @@ F99B0000 E F99C0000 F99D0000 E F99E0000 F99F0000 E - ✓ Stock's USB-flash auto-detect path works (`/UsbFlash/DcoFImage` → "Update CSU File system by USB" log → erase+write+reboot) - ✓ The 16 MiB image fits and boots cleanly from mtd5 -**What it does NOT validate** (still pending operator): -- The M12 image itself (replacements + trims) — only stock-restore tested so far. Both images use the same builder so the eraseblock fix carries over, but you'll want to verify M12 actually boots with the wrappers in place. +**What it does NOT validate:** the M12 image itself — see the 2026-05-21 results below. + +## Bench-validation results (2026-05-21) + +The M12 image itself, flashed via the stock `/root/main` USB path. Three flash cycles (each ~11 min for the 16 MiB NOR write): + +**Flash 1 — M12 boots clean** through `/sbin/init` to BusyBox; delta-bridge `meter`/`adc`/`led` personalities + the main MQTT/web/RFID process all dispatched. The `--eraseblock=0x10000` fix held — no brick. One bug surfaced: +- **Config seed broken** — `first-boot.sh` seeds from `/etc/delta-bridge/delta-bridge.conf.default`, but `build-dcofimage.sh` wrote the default one directory too high (`/etc/delta-bridge.conf.default`). delta-bridge fell back to compiled-in defaults (`web_enable=0`) → no web UI. Fixed in PR #28. + +**Flash 2 — config seed fixed**, web UI still didn't answer. Root-caused to two more bugs (PR #29): +- **Bug #2** — `web_tick`/`rfid_tick` sat behind the MQTT-broker reconnect `continue`; with the broker unreachable the web UI was permanently starved (and the default broker `127.0.0.1` is *never* up — chicken-and-egg). +- **Bug #3** — `/api/state` returned `charger_state_init()` zeros when `write_enable=false`, because `main.c` handed the web server a NULL shmem pointer. +- Also found: **the stale-binary trap** — `make dcofimage` packed a hand-built `delta-bridge.m12` that no make rule rebuilt, so flashes 1–2 shipped a binary *without* the PR #29 fixes even after they were committed. Fixed: `make dcofimage` now cross-builds fresh every run (PR #29 Makefile commit). + +**Flash 3 — all fixes in the ARM binary.** `/api/state` confirmed **live** with `write_enable=false`: real voltage/current/pilot/faults, `availability:online`. End-to-end `meter → shmem → web` path validated. + +**Validated:** eraseblock fix, config-seed fix, web/`write_enable` decouple (bug #3), the build pipeline. +**Not bench-spot-checked:** bug #2 (web survives broker-down) — in the binary and unit-tested (843/843), but the bench broker was up during validation. Spot-check by pointing the broker at a dead IP and confirming the web UI still answers. ## USB-stick prep — two different layouts needed diff --git a/boards/eluminocity-ch21130/docs/23-deltaevseconfig-usb-import.md b/boards/eluminocity-ch21130/docs/23-deltaevseconfig-usb-import.md index 2f32743..49ef8c4 100644 --- a/boards/eluminocity-ch21130/docs/23-deltaevseconfig-usb-import.md +++ b/boards/eluminocity-ch21130/docs/23-deltaevseconfig-usb-import.md @@ -43,6 +43,7 @@ Confirmed by disassembly of `main` (`0x149e8 .. 0x17108`). The end of `main` is - A USB stick inserted at *any time* (not just boot) gets picked up on the next loop iteration. - The DeltaEVSEConfig file is **not removed** after import — leaving the stick in means the values are re-applied every loop tick. The EncodeLogMessage diff log only grows when something actually changes, so re-applying identical values is silent. - For one-shot provisioning, the operator must unplug the stick after the first apply (or write the file once and rely on the no-op re-apply). +- **`GetConfig()` is a full replace, not a merge** (bench-confirmed 2026-05-21 — see §7). A `DeltaEVSEConfig` that omits keys does **not** leave them alone: absent keys are reset to firmware defaults. Any file handed to it must carry **all 36 keys**. --- @@ -236,6 +237,38 @@ The auto-dump-on-insert behavior is independent of DeltaEVSEConfig — every USB --- +## 7. Driving GetConfig without a USB stick (bench, 2026-05-21) + +For the companion web UI to edit stock config (planned milestone — see the `stock-config-web-ui` project memory), delta-bridge must invoke `GetConfig()` programmatically. Bench-tested 2026-05-21: + +### 7.1 The `/dev/sda` gate + the `mknod` workaround + +A plain on-disk `/UsbFlash/DeltaEVSEConfig` is **not** read — per §1, `GetConfig()` only runs if `access("/dev/sda")` or `access("/dev/sda1")` succeeds, and stock `mount`s `/dev/sda` over `/UsbFlash` first (which would shadow an on-disk file with a real stick's fs). + +**Workaround (bench-confirmed working):** create a fake block node so the `access()` gate passes — + +```sh +mknod /dev/sda b 8 0 # access("/dev/sda") now succeeds +# stock runs `mount /dev/sda /UsbFlash` -> FAILS (no backing device), +# but falls through to GetConfig() anyway, which reads the on-disk +# /UsbFlash/DeltaEVSEConfig we wrote. +rm /dev/sda # clean up afterwards +``` + +A loopback-backed device (`losetup` a FAT image, point `/dev/sda` at it) would be more robust — the mount *succeeds* — if the fall-through-on-mount-failure behavior ever proves fragile. Test 1 (plain file, no `/dev/sda`) was confirmed a no-op; Test 2 (with the fake node) confirmed `GetConfig()` ran. + +### 7.2 Full-replace — supply ALL 36 keys + +`GetConfig()` rewrites the **entire** config struct. A file with a subset of keys resets every absent key to its firmware default — bench-confirmed: a one-key file (`SNMP Trap Receiver:` only) left the unit with a full set of default values. **Programmatic writes must be a read-modify-write of the whole 36-key set:** read current values from shmem (the live source of truth), apply the edit(s), write the complete file. + +Restore source if a config is clobbered: stock auto-dumps `/UsbFlash/Configuration___V` to any inserted USB stick (§6) — a full key set; rename to `DeltaEVSEConfig` and re-import. + +### 7.3 SNMP trap path (related finding) + +`/root/ErrorHandle` (kept stock in M12) attaches shmem and, on every fault/event, `system()`s `/root/snmptrap -v 2c -c "" .1.3.6.1.4.1.6785.1.8.3. … i ` — one OID leaf per event code (~23 of them). Host + community come from the `SNMP Trap Receiver:` config key. Observed: the host's first octet wanders (`88.` / `23.` / `224.168.100.1`); `224.168.100.1` is the firmware default (a multicast placeholder), and ErrorHandle appears to read the host with an octet-0 bug. Net effect on an unconfigured unit: fault telemetry sprayed at a semi-random public IP. Set `SNMP Trap Receiver:` to a sane/blank value to silence it. + +--- + ## References - `/root/main` GetConfig: `0xd394` (function), call site `0x1681c` (inside main loop)