Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions boards/eluminocity-ch21130/companion/Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand All @@ -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)"
Expand Down
9 changes: 6 additions & 3 deletions boards/eluminocity-ch21130/companion/image/build-dcofimage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <DcoFImage> [--expected-sha256 <hex>]
Expand Down Expand Up @@ -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")
Expand Down
21 changes: 12 additions & 9 deletions boards/eluminocity-ch21130/companion/src/commands.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
70 changes: 44 additions & 26 deletions boards/eluminocity-ch21130/companion/src/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <unistd.h>

static volatile sig_atomic_t g_stop = 0;
Expand Down Expand Up @@ -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;
Expand All @@ -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)
Expand All @@ -223,45 +230,56 @@ 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;
charger_state_init(&prev); /* force a full publish */
} 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);

Expand Down
10 changes: 5 additions & 5 deletions boards/eluminocity-ch21130/companion/src/web.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion boards/eluminocity-ch21130/companion/src/web.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
24 changes: 24 additions & 0 deletions boards/eluminocity-ch21130/companion/test/test_commands.c
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading
Loading