From f21554f9b6054962e8ba71341aeffa8ae8f4e691 Mon Sep 17 00:00:00 2001 From: Simon Dick Date: Sun, 30 Aug 2026 23:14:48 +0100 Subject: [PATCH 1/5] Add mqttstats: headless Commodity for Home Assistant telemetry A standalone AmigaOS Commodity publishing Amiga system telemetry (uptime, chip/fast RAM free, CPU model) to a Home Assistant broker via mqtt.library, with full MQTT Discovery so sensors appear automatically. Configured entirely through Workbench ToolTypes (via amiga.lib's ArgArrayInit), runnable silently from WBStartup, with Commodities Exchange as its only control surface (Enable/Disable/Kill) - the telemetry piece of issue #6, scoped ahead of I2C sensor support which remains a follow-up. Adds the portable src/tools/ha_discovery.[ch] module (HA MQTT Discovery topic/payload builder, host-unit-tested) shared with mqttstats, and userdocs/mqttstats.md covering ToolTypes and Home Assistant setup. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QWq1CRLCQy9AaD9UtLRSM9 --- Makefile | 9 +- docs/ARCHITECTURE.md | 12 +- mkdocs.yml | 1 + src/amiga/mqttstats_main.c | 540 +++++++++++++++++++++++++++++++++++++ src/tools/ha_discovery.c | 107 ++++++++ src/tools/ha_discovery.h | 49 ++++ tests/test_ha_discovery.c | 88 ++++++ tests/test_main.c | 2 + tools/docs2guide.py | 1 + userdocs/Installation.md | 6 +- userdocs/index.md | 4 +- userdocs/mqttstats.md | 97 +++++++ 12 files changed, 908 insertions(+), 8 deletions(-) create mode 100644 src/amiga/mqttstats_main.c create mode 100644 src/tools/ha_discovery.c create mode 100644 src/tools/ha_discovery.h create mode 100644 tests/test_ha_discovery.c create mode 100644 userdocs/mqttstats.md diff --git a/Makefile b/Makefile index f8e514e..a9cb5bb 100644 --- a/Makefile +++ b/Makefile @@ -156,8 +156,8 @@ lint: test: $(BUILD)/run-tests $(BUILD)/run-tests -$(BUILD)/run-tests: $(CORE_SRCS) $(TEST_SRCS) $(CORE_HDRS) $(TEST_HDRS) | $(BUILD)/.dir - $(CC) $(CFLAGS) $(CObjINC) -Itests $(CORE_SRCS) $(TEST_SRCS) -o $@ +$(BUILD)/run-tests: $(CORE_SRCS) $(TEST_SRCS) src/tools/ha_discovery.c $(CORE_HDRS) $(TEST_HDRS) | $(BUILD)/.dir + $(CC) $(CFLAGS) $(CObjINC) -Isrc/tools -Itests $(CORE_SRCS) $(TEST_SRCS) src/tools/ha_discovery.c -o $@ # --- Host: native tools (for local development and the broker smoke test) --- # Named distinctly from the m68k binaries so the two don't collide on a @@ -193,6 +193,7 @@ m68k: library-headers | $(BUILD)/.dir $(M68K_CC) $(M68K_CFLAGS) $(VERSION_DEFS) -I$(LIB_INCDIR) src/amiga/args.c src/amiga/sub_main_lib.c -o $(BUILD)/mqtt_sub $(M68K_CC) $(M68K_CFLAGS) $(VERSION_DEFS) $(CORE_SRCS) $(TOOLS_SRCS) $(AMIGA_SRCS) src/amiga/pub_main.c -o $(BUILD)/mqtt_pub-static $(M68K_CC) $(M68K_CFLAGS) $(VERSION_DEFS) $(CORE_SRCS) $(TOOLS_SRCS) $(AMIGA_SRCS) src/amiga/sub_main.c -o $(BUILD)/mqtt_sub-static + $(M68K_CC) $(M68K_CFLAGS) $(VERSION_DEFS) -I$(LIB_INCDIR) src/tools/ha_discovery.c src/amiga/mqttstats_main.c -lamiga -o $(BUILD)/mqttstats m68k-docker: $(DOCKER) run --rm --platform linux/amd64 $(DOCKER_USER) -v "$(CURDIR)":/work -w /work \ @@ -507,7 +508,7 @@ dist: guide $(LHA) $(MAKE) build @grep -aq "amisslmaster.library" $(BUILD)/mqtt.library || { echo "dist: $(BUILD)/mqtt.library has no AmiSSL/TLS support - refusing to release (see M68K_HAS_AMISSL / make fetch-amissl-sdk)"; exit 1; } @v=$$(sed -n 's/^#define MIDGE_VERSION[[:space:]]*"\(.*\)"$$/\1/p' src/version.h); \ - for b in mqtt_pub mqtt_sub mqtt_pub-static mqtt_sub-static; do \ + for b in mqtt_pub mqtt_sub mqtt_pub-static mqtt_sub-static mqttstats; do \ grep -aqF "\$$VER: $$b $$v (" $(BUILD)/$$b || { echo "dist: $(BUILD)/$$b lacks \"\$$VER: $$b $$v (...)\" - stale build/?"; exit 1; }; \ done; \ grep -aqF "\$$VER: mqtt.library 1.0 (" $(BUILD)/mqtt.library || { echo "dist: $(BUILD)/mqtt.library lacks \"\$$VER: mqtt.library 1.0 (...)\" - stale build/? (library \$$VER tracks LibVersion.LibRevision, not MIDGE_VERSION - see src/library/libinit.c)"; exit 1; } @@ -517,7 +518,7 @@ dist: guide $(LHA) $(BUILD)/dist/midge/developer/inline $(BUILD)/dist/midge/developer/libraries \ $(BUILD)/dist/midge/developer/examples cp $(BUILD)/mqtt_pub $(BUILD)/mqtt_sub $(BUILD)/mqtt_pub-static $(BUILD)/mqtt_sub-static \ - $(BUILD)/midge.guide LICENSE midge.readme $(BUILD)/dist/midge/ + $(BUILD)/mqttstats $(BUILD)/midge.guide LICENSE midge.readme $(BUILD)/dist/midge/ cp $(BUILD)/mqtt.library $(BUILD)/dist/midge/libs/ cp src/library/mqtt_lib.sfd src/library/mqtt.doc $(BUILD)/dist/midge/developer/ cp $(LIB_INCDIR)/fd/mqtt_lib.fd $(BUILD)/dist/midge/developer/fd/ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d251f5d..1d59bf0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,7 +16,9 @@ src/core/ portable C99, zero OS/libc-beyond-C99 dependencies src/host/ host-native platform glue (BSD sockets, getopt) src/amiga/ AmigaOS platform glue (bsdsocket.library, ReadArgs) -src/tools/ mqtt_pub/mqtt_sub logic shared by the static host/Amiga builds +src/tools/ mqtt_pub/mqtt_sub logic shared by the static host/Amiga builds, + plus ha_discovery.[ch] (portable Home Assistant MQTT Discovery + topic/payload builder, used by mqttstats below) src/library/ mqtt.library (see "mqtt.library" below) ``` @@ -39,6 +41,14 @@ reuse `src/amiga/args.c`'s ReadArgs parsing and `tool_opts` for an identical CLI contract, but call through `mqtt.library` instead of linking `src/core`/`src/tools` directly - see "Two build flavours" below. +`src/amiga/mqttstats_main.c` is a separate, self-contained Commodity (the +telemetry piece of the Home Assistant integration - I2C sensor support is +a deferred follow-up) that links `mqtt.library` and `src/tools/ha_discovery.c` +directly; it doesn't go through `tool_opts`/ReadArgs at all, since its +config comes from Workbench ToolTypes via amiga.lib's `ArgArrayInit()` +instead of a CLI template. See `userdocs/mqttstats.md` for the ToolTypes +reference and Home Assistant setup. + ## The transport vtable ```c diff --git a/mkdocs.yml b/mkdocs.yml index d9adcb1..a9a2392 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,5 +42,6 @@ nav: - Home: index.md - Installation: Installation.md - CLI Reference: CLI-Reference.md + - mqttstats: mqttstats.md - mqtt.library: mqtt-library.md - mqtt.library API Reference: mqtt-library-reference.md diff --git a/src/amiga/mqttstats_main.c b/src/amiga/mqttstats_main.c new file mode 100644 index 0000000..a459817 --- /dev/null +++ b/src/amiga/mqttstats_main.c @@ -0,0 +1,540 @@ +/* mqttstats_main.c - mqttstats: a headless AmigaOS Commodity publishing + * Amiga system telemetry (uptime, chip/fast RAM free, CPU model) to a + * Home Assistant broker via mqtt.library, with full MQTT Discovery so the + * sensors appear in HA automatically (issue #6's telemetry piece - I2C + * sensor support is deliberately deferred to a follow-up, see that issue). + * + * Design goals, all from the issue discussion: + * - Configurable as much as possible from a Workbench icon's ToolTypes - + * amiga.lib's ArgArrayInit()/ArgString()/ArgInt() read WBStartup + * ToolTypes and Shell KEYWORD=VALUE arguments through the exact same + * calls (ArgArrayInit() detects argc==0, the Workbench-launch case, + * and pulls ToolTypes from the WBStartup message libnix's crt0 + * stashes for it - no icon.library calls needed here at all). + * - Silent: no window, ever. Diagnostics go to the serial port only (see + * raw_str() below), never stdio: a libnix program's first stdio write + * auto-opens a console window when there's no controlling CLI (e.g. + * a Workbench/WBStartup launch) - on-target testing confirmed this + * does NOT block the calling Shell/Startup-Sequence (an earlier + * working theory to that effect didn't hold up under a proper + * isolated repro), but a popped-up window is still a visible UI, + * which this design goal rules out regardless of whether anything + * blocks on it. + * - A commodities.library broker so Commodities Exchange can Enable/ + * Disable/Kill it - the only control surface this tool has, matching + * "no UI, just disable and quit" from the issue. Unlike a typical + * Commodity there's no HotKey/AttachCxObj: this program never filters + * input events, so the broker exists purely for that Exchange control + * surface, not to intercept anything. + * + * See src/tools/ha_discovery.h for the (portable, host-tested) Home + * Assistant MQTT Discovery topic/payload format this publishes. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include /* ArgArrayInit/ArgArrayDone/ArgString/ArgInt - + amiga.lib, not an LVO library: no proto + stub header of its own, just prototypes */ + +#include +#include +#include + +#include "ha_discovery.h" +#include "version.h" + +MIDGE_VERSTAG("mqttstats") + +/* No stdio for diagnostics (fprintf/printf), deliberately: this program + * must never pop a console window under any circumstance (see this + * file's own "Silent" design goal) - a libnix program's first stdio + * write auto-opens one when there's no controlling CLI, and that's a + * visible UI this design goal rules out regardless of whether anything + * ever blocks on it (on-target testing found it doesn't). Same + * exec/RawPutChar (LVO -516) serial-only diagnostic path already used by + * tests/copperline and tests/library test tools - visible under Copperline + * or a real serial cable, otherwise simply goes nowhere, never a window. */ +static void raw_put(char c) +{ + void *SysBase = *(void **)4UL; + register long d0 __asm__("d0") = (unsigned char)c; + register void *a6 __asm__("a6") = SysBase; + __asm__ volatile("jsr -516(%%a6)" : : "r"(d0), "r"(a6) + : "d1", "a0", "a1", "cc", "memory"); +} + +static void raw_str(const char *s) +{ + while (*s) + raw_put(*s++); +} + +/* Every mqtt.library caller must define this global itself - see + * examples/pubexample.c's banner for why (no libnix auto-open for a + * library the linker doesn't know about). This program is a single, + * non-library, singleton executable (enforced by NBU_UNIQUE below), so a + * plain global is fine here - not the multi-connection subprocess + * situation CLAUDE.md's static/extern trap warns about inside + * mqtt.library itself. */ +struct Library *MqttBase; +struct Library *CxBase; +struct Device *TimerBase; + +/* Poll tick for the main loop - see run_loop()'s own comment for why a + * plain Delay()-based poll was chosen over multiplexing a WaitSelect()- + * style signal mask. */ +#define MQTTSTATS_POLL_TICKS 50 /* ~1s @ 50Hz */ + +#define MQTTSTATS_TOPIC_BUF 128 +#define MQTTSTATS_PAYLOAD_BUF 512 +#define MQTTSTATS_VALUE_BUF 32 + +/* --- ToolTypes-derived configuration --------------------------------- */ + +typedef struct { + STRPTR host; + UWORD port; + STRPTR client_id; + STRPTR node_id; /* sanitized into node_id_buf if not given explicitly */ + STRPTR device_name; + STRPTR username; + STRPTR password; + BOOL tls; + BOOL tls_insecure; + STRPTR ca_file; + ULONG interval_secs; + BYTE cx_priority; +} mqttstats_config; + +/* MQTT topics/HA unique_ids only tolerate a conservative character set - + * copy `src` into `dst` (cap bytes) replacing anything outside + * [A-Za-z0-9_-] with '_'. Truncates rather than overflowing; a truncated + * node id is still unique enough in practice (CLIENTID collisions this + * would create are a pre-existing user misconfiguration, not something + * this function needs to detect). */ +static void sanitize_node_id(const char *src, char *dst, size_t cap) +{ + size_t i = 0; + + if (cap == 0) + return; + cap--; + for (; *src && i < cap; src++) { + char c = *src; + int ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '_' || c == '-'; + dst[i++] = ok ? c : '_'; + } + dst[i] = '\0'; +} + +static void read_config(STRPTR *tt, mqttstats_config *cfg, + char *node_id_buf, size_t node_id_cap) +{ + STRPTR explicit_node_id; + + memset(cfg, 0, sizeof(*cfg)); + cfg->host = ArgString((CONST_STRPTR *)tt, (CONST_STRPTR) "HOST", NULL); + cfg->port = (UWORD) ArgInt((CONST_STRPTR *)tt, (CONST_STRPTR) "PORT", 0); + cfg->client_id = ArgString((CONST_STRPTR *)tt, (CONST_STRPTR) "CLIENTID", + (CONST_STRPTR) "midge-stats"); + cfg->device_name = ArgString((CONST_STRPTR *)tt, (CONST_STRPTR) "DEVICENAME", + (CONST_STRPTR) "Amiga"); + cfg->username = ArgString((CONST_STRPTR *)tt, (CONST_STRPTR) "USER", NULL); + cfg->password = ArgString((CONST_STRPTR *)tt, (CONST_STRPTR) "PASSWORD", NULL); + cfg->tls = ArgInt((CONST_STRPTR *)tt, (CONST_STRPTR) "TLS", 0) ? TRUE : FALSE; + cfg->tls_insecure = ArgInt((CONST_STRPTR *)tt, (CONST_STRPTR) "TLSINSECURE", 0) ? TRUE : FALSE; + if (cfg->tls_insecure) + cfg->tls = TRUE; + cfg->ca_file = ArgString((CONST_STRPTR *)tt, (CONST_STRPTR) "CAFILE", NULL); + cfg->interval_secs = (ULONG) ArgInt((CONST_STRPTR *)tt, (CONST_STRPTR) "INTERVAL", 60); + if (cfg->interval_secs == 0) + cfg->interval_secs = 60; + cfg->cx_priority = (BYTE) ArgInt((CONST_STRPTR *)tt, (CONST_STRPTR) "CX_PRIORITY", 0); + + if (cfg->port == 0) + cfg->port = cfg->tls ? 8883 : 1883; + + explicit_node_id = ArgString((CONST_STRPTR *)tt, (CONST_STRPTR) "NODEID", NULL); + sanitize_node_id((const char *)(explicit_node_id ? explicit_node_id + : cfg->client_id), + node_id_buf, node_id_cap); + cfg->node_id = (STRPTR) node_id_buf; +} + +/* --- Telemetry gathering ------------------------------------------------ */ + +/* timer.device's TR_GETSYSTIME reports seconds+micros since the last + * system reset - genuinely monotonic, unlike dos.library's DateStamp() + * (see tool_clock.h's own comment on issue #8). That distinction doesn't + * matter for mqtt.library or the CLI tools (multi-connection subprocess + * statics are off the table there), but this program is a standalone + * singleton (NBU_UNIQUE enforces at most one instance), so opening the + * device once at startup and keeping it for the process's lifetime is + * completely safe - there is no second instance to trample it. Used for + * both the reported "uptime" metric and this program's own interval + * scheduling (avoiding the exact clock-jump hazard issue #8 documents). */ +static struct timerequest *g_timerreq; +static struct MsgPort *g_timerport; + +static int open_timer(void) +{ + g_timerport = CreateMsgPort(); + if (!g_timerport) + return -1; + g_timerreq = (struct timerequest *) + CreateIORequest(g_timerport, sizeof(*g_timerreq)); + if (!g_timerreq) { + DeleteMsgPort(g_timerport); + g_timerport = NULL; + return -1; + } + if (OpenDevice((STRPTR) TIMERNAME, UNIT_VBLANK, + (struct IORequest *)g_timerreq, 0) != 0) { + DeleteIORequest((struct IORequest *)g_timerreq); + DeleteMsgPort(g_timerport); + g_timerreq = NULL; + g_timerport = NULL; + return -1; + } + TimerBase = g_timerreq->tr_node.io_Device; + return 0; +} + +static void close_timer(void) +{ + if (TimerBase) { + CloseDevice((struct IORequest *)g_timerreq); + TimerBase = NULL; + } + if (g_timerreq) { + DeleteIORequest((struct IORequest *)g_timerreq); + g_timerreq = NULL; + } + if (g_timerport) { + DeleteMsgPort(g_timerport); + g_timerport = NULL; + } +} + +/* Seconds since the last system reset. */ +static ULONG uptime_secs(void) +{ + g_timerreq->tr_node.io_Command = TR_GETSYSTIME; + DoIO((struct IORequest *)g_timerreq); + return (ULONG) g_timerreq->tr_time.tv_secs; +} + +static const char *cpu_model(void) +{ + struct ExecBase *eb = (struct ExecBase *) SysBase; + + if (eb->AttnFlags & AFF_68060) + return "68060"; + if (eb->AttnFlags & AFF_68040) + return "68040"; + if (eb->AttnFlags & AFF_68030) + return "68030"; + if (eb->AttnFlags & AFF_68020) + return "68020"; + return "68000"; +} + +/* --- Publishing ---------------------------------------------------------- */ + +static int publish_retained(APTR client, const char *topic, const char *value, + UBYTE qos) +{ + return MQTT_Publish(client, (STRPTR)(char *)topic, (APTR)(char *)value, + (ULONG) strlen(value), TRUE, qos) == 0 ? 0 : -1; +} + +static void publish_discovery(APTR client, const ha_device *dev) +{ + char topic[MQTTSTATS_TOPIC_BUF]; + char payload[MQTTSTATS_PAYLOAD_BUF]; + + if (ha_discovery_topic(dev, "uptime", topic, sizeof(topic)) > 0 && + ha_discovery_payload(dev, "uptime", "Uptime", "s", "duration", + payload, sizeof(payload)) > 0) + publish_retained(client, topic, payload, 1); + + if (ha_discovery_topic(dev, "chip_free", topic, sizeof(topic)) > 0 && + ha_discovery_payload(dev, "chip_free", "Chip RAM Free", "B", + "data_size", payload, sizeof(payload)) > 0) + publish_retained(client, topic, payload, 1); + + if (ha_discovery_topic(dev, "fast_free", topic, sizeof(topic)) > 0 && + ha_discovery_payload(dev, "fast_free", "Fast RAM Free", "B", + "data_size", payload, sizeof(payload)) > 0) + publish_retained(client, topic, payload, 1); + + if (ha_discovery_topic(dev, "cpu_model", topic, sizeof(topic)) > 0 && + ha_discovery_payload(dev, "cpu_model", "CPU Model", NULL, NULL, + payload, sizeof(payload)) > 0) + publish_retained(client, topic, payload, 1); +} + +static void publish_availability(APTR client, const ha_device *dev, + const char *state) +{ + char topic[MQTTSTATS_TOPIC_BUF]; + + if (ha_availability_topic(dev, topic, sizeof(topic)) > 0) + publish_retained(client, topic, state, 0); +} + +static void publish_state(APTR client, const ha_device *dev) +{ + char topic[MQTTSTATS_TOPIC_BUF]; + char value[MQTTSTATS_VALUE_BUF]; + + if (ha_state_topic(dev, "uptime", topic, sizeof(topic)) > 0) { + sprintf(value, "%lu", (unsigned long) uptime_secs()); + publish_retained(client, topic, value, 0); + } + if (ha_state_topic(dev, "chip_free", topic, sizeof(topic)) > 0) { + sprintf(value, "%lu", (unsigned long) AvailMem(MEMF_CHIP)); + publish_retained(client, topic, value, 0); + } + if (ha_state_topic(dev, "fast_free", topic, sizeof(topic)) > 0) { + sprintf(value, "%lu", (unsigned long) AvailMem(MEMF_FAST)); + publish_retained(client, topic, value, 0); + } + if (ha_state_topic(dev, "cpu_model", topic, sizeof(topic)) > 0) + publish_retained(client, topic, cpu_model(), 0); +} + +/* --- Main loop ------------------------------------------------------------ + * + * A plain Delay()-based poll rather than multiplexing a WaitSelect()-style + * signal mask with a timer.device signal: this program only needs to + * notice a commodity command or a due publish tick within about a second, + * which a ~1s poll already gives it - not worth the extra complexity of a + * second armed IORequest alongside the one already open for uptime/ + * scheduling. Same idiom as sub_main_lib.c's own MQTT_GetMessage() poll + * loop. */ +static void run_loop(APTR client, CxObj *broker, struct MsgPort *cxport, + const ha_device *dev, ULONG interval_secs) +{ + int running = 1; + int enabled = 1; + ULONG last_publish = uptime_secs(); + + for (;;) { + if (cxport) { + CxMsg *cxm; + while ((cxm = (CxMsg *) GetMsg(cxport)) != NULL) { + ULONG type = CxMsgType(cxm); + ULONG id = CxMsgID(cxm); + ReplyMsg((struct Message *) cxm); + if (type != CXM_COMMAND) + continue; + switch (id) { + case CXCMD_ENABLE: + if (!enabled) { + enabled = 1; + publish_availability(client, dev, "online"); + } + if (broker) + ActivateCxObj(broker, 1); + break; + case CXCMD_DISABLE: + if (enabled) { + enabled = 0; + publish_availability(client, dev, "offline"); + } + if (broker) + ActivateCxObj(broker, 0); + break; + case CXCMD_KILL: + running = 0; + break; + default: + break; + } + } + } + if (!running) + break; + + if (SetSignal(0, 0) & SIGBREAKF_CTRL_C) { + SetSignal(0, SIGBREAKF_CTRL_C); + break; + } + + if (enabled) { + ULONG now = uptime_secs(); + /* uptime_secs() only ever counts forward from system reset - + * no clock-jump hazard here (see this file's own timer.device + * comment), so plain subtraction is safe without the + * wraparound guarding tool_now_ms() callers need. */ + if (now - last_publish >= interval_secs) { + publish_state(client, dev); + last_publish = now; + } + } + + Delay(MQTTSTATS_POLL_TICKS); + } +} + +int main(int argc, char **argv) +{ + STRPTR *tt; + mqttstats_config cfg; + char node_id_buf[64]; + ha_device dev; + struct MsgPort *cxport = NULL; + CxObj *broker = NULL; + APTR client = NULL; + struct MqttConnectOpts opts; + ULONG keepalive; + int exit_code = 0; + + /* argc==0 (Workbench launch) makes ArgArrayInit() pull ToolTypes from + * the WBStartup message libnix's crt0 stashed instead - see this + * file's own banner. Shell launch (argc>0) reads KEYWORD=VALUE + * arguments off argv the same way. */ + tt = ArgArrayInit((LONG) argc, (CONST_STRPTR *) argv); + read_config(tt, &cfg, node_id_buf, sizeof(node_id_buf)); + + if (!cfg.host) { + raw_str("mqttstats: HOST tooltype/argument is required\r\n"); + ArgArrayDone(); + return 20; + } + + dev.node_id = (const char *) cfg.node_id; + dev.device_name = (const char *) cfg.device_name; + + if (open_timer() < 0) { + raw_str("mqttstats: can't open timer.device\r\n"); + ArgArrayDone(); + return 20; + } + + CxBase = OpenLibrary((STRPTR) "commodities.library", 37); + if (!CxBase) { + raw_str("mqttstats: can't open commodities.library\r\n"); + close_timer(); + ArgArrayDone(); + return 20; + } + + cxport = CreateMsgPort(); + if (cxport) { + struct NewBroker nb; + LONG cberr = 0; + + memset(&nb, 0, sizeof(nb)); + nb.nb_Version = NB_VERSION; + nb.nb_Name = (STRPTR) "mqttstats"; + nb.nb_Title = (STRPTR) "midge mqttstats"; + nb.nb_Descr = (STRPTR) "Publishes Amiga telemetry to Home Assistant via MQTT"; + nb.nb_Unique = NBU_UNIQUE; + nb.nb_Flags = 0; /* no window - nothing for Exchange to Show/Hide */ + nb.nb_Pri = cfg.cx_priority; + nb.nb_Port = cxport; + + broker = CxBroker(&nb, &cberr); + if (!broker && cberr == CBERR_DUP) { + /* Another mqttstats is already running - exit quietly, same + * as a second midge_pub instance would just publish twice for + * no benefit. */ + DeleteMsgPort(cxport); + CloseLibrary(CxBase); + close_timer(); + ArgArrayDone(); + return 0; + } + if (broker) { + ActivateCxObj(broker, 1); + } else { + DeleteMsgPort(cxport); + cxport = NULL; + } + } + + MqttBase = OpenLibrary((STRPTR) "mqtt.library", 0); + if (!MqttBase) { + raw_str("mqttstats: can't open mqtt.library - copy it to LIBS: first\r\n"); + exit_code = 20; + goto cleanup_cx; + } + + /* Keepalive comfortably above our own publish interval (see run_loop()'s + * banner): our periodic PUBLISHes alone keep the broker's keepalive + * tracking satisfied, since ANY MQTT packet counts, not specifically a + * PINGREQ - mqtt.library's connection child is idle (blocked in + * WaitPort()) between our commands, so it never sends one on its own. */ + keepalive = cfg.interval_secs * 2; + if (keepalive < 60) + keepalive = 60; + if (keepalive > 65535) + keepalive = 65535; + + memset(&opts, 0, sizeof(opts)); + opts.mco_ClientID = cfg.client_id; + opts.mco_Username = cfg.username; + opts.mco_Password = cfg.password; + opts.mco_KeepAlive = (UWORD) keepalive; + opts.mco_CleanSession = TRUE; + opts.mco_AutoReconnect = TRUE; /* a background daemon should ride out drops */ + opts.mco_TLS = cfg.tls; + opts.mco_TLSInsecure = cfg.tls_insecure; + opts.mco_CAFile = cfg.ca_file; + + client = MQTT_CreateClient(cfg.host, cfg.port, &opts); + if (!client) { + raw_str("mqttstats: MQTT_CreateClient failed\r\n"); + exit_code = 20; + goto cleanup_lib; + } + if (MQTT_Connect(client) != 0) { + raw_str("mqttstats: connect failed\r\n"); + exit_code = 20; + goto cleanup_client; + } + + publish_discovery(client, &dev); + publish_availability(client, &dev, "online"); + publish_state(client, &dev); + + run_loop(client, broker, cxport, &dev, cfg.interval_secs); + + publish_availability(client, &dev, "offline"); + MQTT_Disconnect(client); + +cleanup_client: + MQTT_DeleteClient(client); +cleanup_lib: + CloseLibrary(MqttBase); +cleanup_cx: + if (broker) + DeleteCxObjAll(broker); + if (cxport) { + struct Message *m; + while ((m = GetMsg(cxport)) != NULL) + ReplyMsg(m); + DeleteMsgPort(cxport); + } + if (CxBase) + CloseLibrary(CxBase); + close_timer(); + ArgArrayDone(); + return exit_code; +} diff --git a/src/tools/ha_discovery.c b/src/tools/ha_discovery.c new file mode 100644 index 0000000..f776ae0 --- /dev/null +++ b/src/tools/ha_discovery.c @@ -0,0 +1,107 @@ +#include "ha_discovery.h" + +#include +#include + +/* JSON-escapes `src` into `dst` (`cap` bytes, NUL-terminated) - only `"` + * and `\` need it for the fields this module ever puts through here + * (device_name is the one genuinely free-text, caller-supplied field; + * everything else is an internal constant or an already topic-safe + * node_id). Truncates silently rather than overflowing if `src` is + * pathologically long - a cut-off device name is a cosmetic problem, not + * a wire-format one, unlike every other buffer in this file where running + * out of room means the caller's `cap` was too small for a fixed template + * and returning -1 is correct instead. */ +static void json_escape(const char *src, char *dst, size_t cap) +{ + size_t i = 0; + + if (cap == 0) + return; + cap--; /* room for the NUL */ + while (*src && i + 1 < cap) { + if (*src == '"' || *src == '\\') { + if (i + 2 >= cap) + break; + dst[i++] = '\\'; + } + dst[i++] = *src++; + } + dst[i] = '\0'; +} + +int ha_discovery_topic(const ha_device *dev, const char *key, + char *buf, size_t cap) +{ + int n = snprintf(buf, cap, "homeassistant/sensor/midge_%s/%s/config", + dev->node_id, key); + if (n < 0 || (size_t)n >= cap) + return -1; + return n; +} + +int ha_discovery_payload(const ha_device *dev, const char *key, + const char *friendly_name, const char *unit, + const char *device_class, char *buf, size_t cap) +{ + char name_esc[128]; + int n; + + json_escape(dev->device_name, name_esc, sizeof(name_esc)); + + n = snprintf(buf, cap, + "{\"name\":\"%s\",\"unique_id\":\"midge_%s_%s\"," + "\"state_topic\":\"midge/%s/%s\"," + "\"availability_topic\":\"midge/%s/status\"," + "\"payload_available\":\"online\"," + "\"payload_not_available\":\"offline\"", + friendly_name, dev->node_id, key, + dev->node_id, key, + dev->node_id); + if (n < 0 || (size_t)n >= cap) + return -1; + + if (unit) { + int m = snprintf(buf + n, cap - (size_t)n, + ",\"unit_of_measurement\":\"%s\"", unit); + if (m < 0 || (size_t)(n + m) >= cap) + return -1; + n += m; + } + if (device_class) { + int m = snprintf(buf + n, cap - (size_t)n, + ",\"device_class\":\"%s\"", device_class); + if (m < 0 || (size_t)(n + m) >= cap) + return -1; + n += m; + } + + { + int m = snprintf(buf + n, cap - (size_t)n, + ",\"device\":{\"identifiers\":[\"midge_%s\"],\"name\":\"%s\"," + "\"manufacturer\":\"Commodore/Amiga\",\"model\":\"mqttstats\"}}", + dev->node_id, name_esc); + if (m < 0 || (size_t)(n + m) >= cap) + return -1; + n += m; + } + + return n; +} + +int ha_state_topic(const ha_device *dev, const char *key, + char *buf, size_t cap) +{ + int n = snprintf(buf, cap, "midge/%s/%s", dev->node_id, key); + if (n < 0 || (size_t)n >= cap) + return -1; + return n; +} + +int ha_availability_topic(const ha_device *dev, char *buf, size_t cap) +{ + int n = snprintf(buf, cap, "midge/%s/status", dev->node_id); + if (n < 0 || (size_t)n >= cap) + return -1; + return n; +} diff --git a/src/tools/ha_discovery.h b/src/tools/ha_discovery.h new file mode 100644 index 0000000..673b1df --- /dev/null +++ b/src/tools/ha_discovery.h @@ -0,0 +1,49 @@ +#ifndef MIDGE_HA_DISCOVERY_H +#define MIDGE_HA_DISCOVERY_H + +#include + +/* Home Assistant MQTT Discovery topic/payload builders (issue #6's + * telemetry-publishing commodity, mqttstats). Portable C99, no OS + * dependencies - the same split as src/core: this is the part worth + * unit-testing byte-exact on the host, kept apart from the Amiga-only + * glue (ToolTypes, CxBroker, AvailMem/AttnFlags telemetry gathering, + * mqtt.library calls) that actually calls it, in src/amiga/mqttstats_main.c. + * + * See https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery for + * the wire format this targets. */ + +typedef struct { + const char *node_id; /* short, topic-safe identifier for this Amiga + (e.g. derived from CLIENTID) - used in every + topic name and in each sensor's unique_id */ + const char *device_name; /* friendly name shown in HA's device list */ +} ha_device; + +/* Every function below writes a NUL-terminated string into `buf` (`cap` + * bytes) and returns the length written (excluding the NUL) on success, or + * -1 if `cap` was too small (nothing is written in that case - never a + * silent truncation). */ + +/* "homeassistant/sensor/midge_//config" - where retained + * discovery config payloads are published (once, at connect). */ +int ha_discovery_topic(const ha_device *dev, const char *key, + char *buf, size_t cap); + +/* The discovery config JSON payload for one sensor. `unit` and + * `device_class` are Home Assistant fields; pass NULL for either to omit + * it from the payload (some sensors, e.g. a text value, have neither). */ +int ha_discovery_payload(const ha_device *dev, const char *key, + const char *friendly_name, const char *unit, + const char *device_class, char *buf, size_t cap); + +/* "midge//" - where this sensor's current value is + * published on every publish tick. */ +int ha_state_topic(const ha_device *dev, const char *key, + char *buf, size_t cap); + +/* "midge//status" - "online"/"offline", referenced by every + * sensor's availability_topic (see ha_discovery_payload()). */ +int ha_availability_topic(const ha_device *dev, char *buf, size_t cap); + +#endif diff --git a/tests/test_ha_discovery.c b/tests/test_ha_discovery.c new file mode 100644 index 0000000..a8ac082 --- /dev/null +++ b/tests/test_ha_discovery.c @@ -0,0 +1,88 @@ +#include "test.h" + +#include + +#include "ha_discovery.h" + +static void test_topics(void) +{ + ha_device dev = { "a1200", "Amiga" }; + char buf[256]; + int n; + + n = ha_discovery_topic(&dev, "uptime", buf, sizeof(buf)); + TEST_CHECK(n > 0); + TEST_CHECK(strcmp(buf, "homeassistant/sensor/midge_a1200/uptime/config") == 0); + + n = ha_state_topic(&dev, "chip_free", buf, sizeof(buf)); + TEST_CHECK(n > 0); + TEST_CHECK(strcmp(buf, "midge/a1200/chip_free") == 0); + + n = ha_availability_topic(&dev, buf, sizeof(buf)); + TEST_CHECK(n > 0); + TEST_CHECK(strcmp(buf, "midge/a1200/status") == 0); +} + +static void test_discovery_payload_with_unit_and_class(void) +{ + ha_device dev = { "a1200", "Amiga" }; + char buf[512]; + int n = ha_discovery_payload(&dev, "uptime", "Amiga Uptime", "s", + "duration", buf, sizeof(buf)); + + TEST_CHECK(n > 0); + TEST_CHECK(strstr(buf, "\"name\":\"Amiga Uptime\"") != NULL); + TEST_CHECK(strstr(buf, "\"unique_id\":\"midge_a1200_uptime\"") != NULL); + TEST_CHECK(strstr(buf, "\"state_topic\":\"midge/a1200/uptime\"") != NULL); + TEST_CHECK(strstr(buf, "\"availability_topic\":\"midge/a1200/status\"") != NULL); + TEST_CHECK(strstr(buf, "\"payload_available\":\"online\"") != NULL); + TEST_CHECK(strstr(buf, "\"payload_not_available\":\"offline\"") != NULL); + TEST_CHECK(strstr(buf, "\"unit_of_measurement\":\"s\"") != NULL); + TEST_CHECK(strstr(buf, "\"device_class\":\"duration\"") != NULL); + TEST_CHECK(strstr(buf, "\"identifiers\":[\"midge_a1200\"]") != NULL); + TEST_CHECK(strstr(buf, "\"name\":\"Amiga\"") != NULL); /* the device block's own name */ +} + +static void test_discovery_payload_omits_unit_and_class(void) +{ + ha_device dev = { "a1200", "Amiga" }; + char buf[512]; + int n = ha_discovery_payload(&dev, "cpu_model", "Amiga CPU Model", NULL, + NULL, buf, sizeof(buf)); + + TEST_CHECK(n > 0); + TEST_CHECK(strstr(buf, "unit_of_measurement") == NULL); + TEST_CHECK(strstr(buf, "device_class") == NULL); +} + +static void test_discovery_payload_escapes_device_name(void) +{ + ha_device dev = { "a1200", "Simon's \"Amiga\"" }; + char buf[512]; + int n = ha_discovery_payload(&dev, "uptime", "Uptime", NULL, NULL, buf, + sizeof(buf)); + + TEST_CHECK(n > 0); + TEST_CHECK(strstr(buf, "\"name\":\"Simon's \\\"Amiga\\\"\"") != NULL); +} + +static void test_bufsize_errors(void) +{ + ha_device dev = { "a1200", "Amiga" }; + char tiny[4]; + + TEST_CHECK(ha_discovery_topic(&dev, "uptime", tiny, sizeof(tiny)) == -1); + TEST_CHECK(ha_state_topic(&dev, "uptime", tiny, sizeof(tiny)) == -1); + TEST_CHECK(ha_availability_topic(&dev, tiny, sizeof(tiny)) == -1); + TEST_CHECK(ha_discovery_payload(&dev, "uptime", "Uptime", "s", "duration", + tiny, sizeof(tiny)) == -1); +} + +void run_ha_discovery_tests(void) +{ + test_topics(); + test_discovery_payload_with_unit_and_class(); + test_discovery_payload_omits_unit_and_class(); + test_discovery_payload_escapes_device_name(); + test_bufsize_errors(); +} diff --git a/tests/test_main.c b/tests/test_main.c index 83392db..f8f16fe 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -5,11 +5,13 @@ test_ctx g_test = { 0, 0 }; void run_codec_tests(void); void run_client_tests(void); +void run_ha_discovery_tests(void); int main(void) { run_codec_tests(); run_client_tests(); + run_ha_discovery_tests(); printf("\n%d passed, %d failed\n", g_test.passed, g_test.failed); diff --git a/tools/docs2guide.py b/tools/docs2guide.py index 9773492..703643f 100644 --- a/tools/docs2guide.py +++ b/tools/docs2guide.py @@ -27,6 +27,7 @@ ('index', 'midge'), ('Installation', 'Installation'), ('CLI-Reference', 'CLI Reference'), + ('mqttstats', 'mqttstats'), ('mqtt-library', 'mqtt.library'), ('mqtt-library-reference', 'mqtt.library API Reference'), ] diff --git a/userdocs/Installation.md b/userdocs/Installation.md index 9c548e4..d1be17b 100644 --- a/userdocs/Installation.md +++ b/userdocs/Installation.md @@ -14,6 +14,7 @@ The Aminet archive is a single drawer, `midge`, containing: midge/ mqtt_pub, mqtt_sub the default tools (need mqtt.library) mqtt_pub-static, mqtt_sub-static statically linked, no library needed + mqttstats headless HA telemetry Commodity (needs mqtt.library) midge.guide this documentation, for MultiView/AmigaGuide midge.readme, LICENSE libs/ @@ -30,8 +31,9 @@ specific location. ## Installing mqtt.library (needed for the default tools) -`mqtt_pub`/`mqtt_sub` - the tools you get by just typing their names - are -built against `mqtt.library` and need it present at `LIBS:` to run: +`mqtt_pub`/`mqtt_sub` and `mqttstats` - the tools you get by just typing +their names - are built against `mqtt.library` and need it present at +`LIBS:` to run: ``` Copy midge/libs/mqtt.library LIBS: diff --git a/userdocs/index.md b/userdocs/index.md index 0ba53f8..aabb38a 100644 --- a/userdocs/index.md +++ b/userdocs/index.md @@ -4,7 +4,9 @@ midge is a native MQTT 3.1.1 client for classic AmigaOS, aimed at putting your Amiga on a modern smart-home network (Home Assistant, Mosquitto, EMQX) as both a display/control surface and a publisher of its own telemetry. -This release ships the command-line tools `mqtt_pub` and `mqtt_sub`, and +This release ships the command-line tools `mqtt_pub` and `mqtt_sub`, +`mqttstats` (a headless Commodity publishing Amiga telemetry to Home +Assistant via MQTT discovery - see [mqttstats](mqttstats.md)), and `mqtt.library`, a shared library exposing the same MQTT client to other AmigaOS programs, with optional TLS via AmiSSL. A ReAction dashboard application is planned for a future release. diff --git a/userdocs/mqttstats.md b/userdocs/mqttstats.md new file mode 100644 index 0000000..a959fb6 --- /dev/null +++ b/userdocs/mqttstats.md @@ -0,0 +1,97 @@ +# mqttstats + +`mqttstats` is a headless AmigaOS Commodity that publishes system telemetry +(uptime, chip RAM free, fast RAM free, CPU model) to a Home Assistant broker +over MQTT, with full [MQTT Discovery](https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery) +so the sensors appear in Home Assistant automatically - no manual entity +configuration needed on the HA side. + +I2C/external sensor support is planned as a follow-up and not part of this +release - see the project's GitHub issues. + +## What it does + +Once running, `mqttstats` connects to your broker and publishes, every +`INTERVAL` seconds (60 by default): + +| Sensor | Unit | Notes | +|---|---|---| +| Uptime | seconds | Time since the Amiga's last reset (via `timer.device`), not since `mqttstats` started. | +| Chip RAM free | bytes | `AvailMem(MEMF_CHIP)`. | +| Fast RAM free | bytes | `AvailMem(MEMF_FAST)`. | +| CPU model | - | `68020`/`68030`/`68040`/`68060`, detected from `AttnFlags`. | + +It also publishes an availability topic (`online` while running, `offline` +on a clean shutdown) and registers a Home Assistant MQTT Discovery config +for each sensor on connect, so Home Assistant creates matching entities +under one device per Amiga without any `configuration.yaml` editing. + +## Running it + +`mqttstats` has no window and no menu - by design, it never opens one. Its +only control surface is [Commodities Exchange](https://en.wikipedia.org/wiki/AmigaOS#Commodities_Exchange): +once running, use Exchange to Disable, re-enable, or Kill it. There is +nothing else to interact with. + +Everything is configured through ToolTypes (or, run from a Shell, the +identical `KEYWORD=VALUE` arguments) - there are no command-line switches +to memorise. Set them via the icon's Information window (Workbench) or on +the command line the same way you'd configure any other Amiga tool. + +| ToolType | Meaning | +|---|---| +| `HOST` | Broker hostname or IP address (**required**). | +| `PORT` | Broker TCP port (default 1883, or 8883 if `TLS` is set). | +| `CLIENTID` | MQTT client identifier (default `midge-stats`). Also the default source for `NODEID` - give each Amiga a unique one if you run `mqttstats` on more than one machine against the same broker. | +| `DEVICENAME` | Friendly name shown in Home Assistant for this Amiga's device (default `Amiga`). | +| `NODEID` | Overrides the MQTT topic/discovery id derived from `CLIENTID` (letters, digits, `_`, `-` only; other characters are replaced with `_`). Only needed if you want topics that don't track `CLIENTID`. | +| `USER` / `PASSWORD` | Broker credentials, if required. | +| `TLS` | Connect over TLS via AmiSSL - see [TLS on the Amiga](CLI-Reference.md#tls-on-the-amiga) (same requirements and caveats as `mqtt_pub`/`mqtt_sub`). | +| `TLSINSECURE` | Connect over TLS but skip certificate verification (implies `TLS`). Testing only. | +| `CAFILE` | Trust an additional CA certificate (PEM file). Ignored without `TLS`, and ignored if `TLSINSECURE` is also given. | +| `INTERVAL` | Publish interval in seconds (default 60). | +| `CX_PRIORITY` | Commodities Exchange broker priority (default 0) - only matters if you're stacking many Commodities and care about their relative activation order. | + +**Multiple Amigas on one broker**: give each machine's icon its own +`CLIENTID` (and, if you want, `DEVICENAME`) - that's also what determines +the MQTT topic prefix and Home Assistant device identity, so two Amigas +with the same `CLIENTID` would collide on both. + +### Running from Workbench / WBStartup + +Set the ToolTypes on the `mqttstats` icon (Icons > Information, or the +equivalent in your Workbench version), then either double-click it or drop +the icon into `WBStartup:` to have it launch silently every boot. Either +way, nothing appears on screen - if `HOST` is missing or a library fails to +open, `mqttstats` quits immediately with no visible indication (diagnostics +go to the serial port only, for anyone debugging under an emulator or with +a serial cable attached - see [CLI Reference](CLI-Reference.md) for the +general shape of Amiga tool diagnostics). Check Home Assistant for the +expected entities to confirm it's actually connected. + +### Running from a Shell + +``` +mqttstats HOST 192.168.1.10 CLIENTID a1200-office +``` + +Useful for a quick test before committing to ToolTypes and WBStartup - the +same required/optional arguments apply, just as `KEYWORD VALUE` pairs +instead of icon ToolTypes. + +## Home Assistant setup + +Nothing needs configuring on the Home Assistant side beyond +[MQTT discovery being enabled](https://www.home-assistant.io/integrations/mqtt/#configuration) +(the default for HA's own MQTT integration). Once `mqttstats` connects: + +1. Each of the four sensors above appears automatically under a device + named after `DEVICENAME`, grouped with any other `mqttstats` sensors + from the same Amiga. +2. The device shows as unavailable if `mqttstats` disconnects + (broker down, network dropped, `mqttstats` killed via Exchange) and + available again once it reconnects or restarts. +3. To stop reporting a given Amiga, Kill its `mqttstats` via Commodities + Exchange (or just don't relaunch it after a reboot) - Home Assistant + marks its entities unavailable rather than removing them; delete the + device from HA's MQTT integration page if you want it gone for good. From 46fe950cf7bf123e8f547eb0d7ac37a5b8d6f5b3 Mon Sep 17 00:00:00 2001 From: Simon Dick Date: Sun, 30 Aug 2026 23:35:31 +0100 Subject: [PATCH 2/5] Add a Workbench icon for mqttstats Without a .info icon, mqttstats couldn't actually be double-clicked or dropped into WBStartup: - a direct requirement from the issue this tool was built for. tools/mkicons.py (ported from AmiAuth's generator) produces a classic 4-colour, 2-bitplane DiskObject icon with a few common Tool Types pre-listed as edit-in-place comments (HOST, CLIENTID, INTERVAL, CX_PRIORITY). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QWq1CRLCQy9AaD9UtLRSM9 --- Makefile | 1 + icons/mqttstats.info | Bin 0 -> 349 bytes icons/mqttstats.info.png | Bin 0 -> 1596 bytes tools/mkicons.py | 208 +++++++++++++++++++++++++++++++++++++++ userdocs/Installation.md | 2 +- userdocs/mqttstats.md | 25 +++-- 6 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 icons/mqttstats.info create mode 100644 icons/mqttstats.info.png create mode 100644 tools/mkicons.py diff --git a/Makefile b/Makefile index a9cb5bb..3da586c 100644 --- a/Makefile +++ b/Makefile @@ -519,6 +519,7 @@ dist: guide $(LHA) $(BUILD)/dist/midge/developer/examples cp $(BUILD)/mqtt_pub $(BUILD)/mqtt_sub $(BUILD)/mqtt_pub-static $(BUILD)/mqtt_sub-static \ $(BUILD)/mqttstats $(BUILD)/midge.guide LICENSE midge.readme $(BUILD)/dist/midge/ + cp icons/mqttstats.info $(BUILD)/dist/midge/mqttstats.info cp $(BUILD)/mqtt.library $(BUILD)/dist/midge/libs/ cp src/library/mqtt_lib.sfd src/library/mqtt.doc $(BUILD)/dist/midge/developer/ cp $(LIB_INCDIR)/fd/mqtt_lib.fd $(BUILD)/dist/midge/developer/fd/ diff --git a/icons/mqttstats.info b/icons/mqttstats.info new file mode 100644 index 0000000000000000000000000000000000000000..d6ff6a2eb653473a5c45f0879292175406c100d5 GIT binary patch literal 349 zcmaDXz`)1=0}2cx3@i-HU=f5UOcq2lGQ)U`4L}wMBY*;g2{MHV$V8}RU{K^>U?`r$ z!0`Vcga)bP5MW@Cv0z}}lL6~v{{O#$g@M6=rT&it3kbR&fzUuc`~Uw8jQ{_D)ximv znQ#Fh17s!=0GWvmBPjtfML<}?!#_C0*3i;O&(O?5&rr|MKocYFO8a>0+Ck tnUbEWTU?S@QVf>j)9~~QaSaM{^szOADCTpHhz|(z^bhh3iL^Cf0070YH(&q& literal 0 HcmV?d00001 diff --git a/icons/mqttstats.info.png b/icons/mqttstats.info.png new file mode 100644 index 0000000000000000000000000000000000000000..4ba93cd57d0804b232705a92220cdda4253295e2 GIT binary patch literal 1596 zcmb7Edo6AvE)s|L0=ic*<_6#WrWeEU) z6mvJ-82|)^pn!{;V^FYiao~!33;^QAOuCip31o7>Dplo2eAjE8rt+N9hJLIw z8f)mE(`)Bz6OSJ8RVY4G_3TbXh&$SC?f#@*jkuXQxbE`PjodXd+*VA93(T4;tISOrj&eQ?S(t=Rzh-&@2xbd^X)Aeh-Ef=>H_19WAkqDE3WbM>t zfnpv{`&c9rPC1~%@?vnoxQDl{I)ZAgwqcGFakgsE2C5c~d*-@MfNFe8o*2DjaL3pA zj5(w(bWaZw%DFR&&JFg^w+|jz(?n1`V`knYqDyp#&bp#R~a0{N7y-o=2O-Z)q$1+kp>D%3OE!9e# zMKfC$*+?Wev0z`M9g02luc)=y%>@K2{zNuL3~k#N5s?NE>4dOeQxY!B=8uh05R{CK z+zXQZ@Q613R(X3=oM^c2T>-YT?0fwHwv2Ow89YuMT- z)|7G9>niN`-wGEk`V@HP*LPriUjw(gy==nhiKwx8CKou!?+=HjOT9h*-#d1{Iqmb~ z<;lrO&zv73NCUw&2R`<;6DGj#*H%YDwGEl$9LS5c{;Eefg_c?L#T-^uS4i}MhO+Hi zAVbFSi@zFT4|2tFq~>PpE%To=P({n1X4Nt$HUu_H<5xBfCBRqJQ1&btAR5Mv-muKY zYoR+aTw&B^jNAdA$bb_Ki=)j6>DWh^l-vO zidC{-EiS9F4IQf1YSOp|<8`A95wvwA;K4zR=%{Zr(oUsu`PRx{RidOeN1q*TJzM9`yWLf$W7!ESjyL({WM_my28 zM^0Z`o21HCVE&Z>W#rw}jZuK4XawXzh>pfvY&zg4;HlN-!UG7av3S%Il5(*qnkB2l zk#^)&-1t+jQmp9m?)bwHa#na-ZXQ6o7)1gT*wCKZclHik@Ltndm<8L__Tk)uKt9Xp de%OO9BPs_%XSTS;Y+L!cnKt(Hik<9)zX4Fob_4(b literal 0 HcmV?d00001 diff --git a/tools/mkicons.py b/tools/mkicons.py new file mode 100644 index 0000000..25617c7 --- /dev/null +++ b/tools/mkicons.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""mkicons.py - generate midge's classic Amiga .info icon files. + +Writes original-format (OS 1.x layout, OS 2.x revision) 4-colour, 2-bitplane +DiskObject icons - the most compatible kind: rendered by every Workbench from +2.04 up, and remapped sensibly by NewIcons/GlowIcons-era systems. Ported from +AmiAuth's tools/mkicons.py (same generator, different icon set). + +The pixel art is edited HERE as ASCII grids ('.'=bg grey, '#'=black, +'w'=white, 'b'=blue - the standard WB 3.x 4-colour palette indices 0..3). +Regenerate the committed icons/ files with: python3 tools/mkicons.py icons/ + +A --check mode re-parses every file it just wrote and asserts the geometry, +type, tooltypes and plane data survive a round trip; --ppm additionally dumps +host-viewable previews next to the icons. +""" + +import struct +import sys +import os + +# --- Amiga palette for previews (WB 3.x default 4 colours) ------------------- +PALETTE = {0: (149, 149, 149), 1: (0, 0, 0), 2: (255, 255, 255), 3: (59, 103, 162)} +CHARS = {'.': 0, '#': 1, 'w': 2, 'b': 3} + +WBDRAWER, WBTOOL, WBPROJECT = 1, 3, 4 +NO_ICON_POSITION = -0x80000000 + +# --- pixel art --------------------------------------------------------------- +# 32x20. A little Amiga box with broadcast waves rising from it - mqttstats +# publishes telemetry outward, it doesn't display anything of its own. +ART_MQTTSTATS = """ +................................ +..........#....#....#.......... +.........#b#..#b#..#b#......... +........#bbb##bbb##bbb#........ +.........###..###..###......... +................................ +............#......#........... +...........#b#....#b#.......... +............###....###......... +................................ +......###################...... +.....#wwwwwwwwwwwwwwwwwww#..... +.....#wwwbbbbbbbbbbbbbwww#..... +.....#wwwbwwwwwwwwwwwbwww#..... +.....#wwwbwwwbbbbbwwwbwww#..... +.....#wwwbwwwwwwwwwwwbwww#..... +.....#wwwbbbbbbbbbbbbbwww#..... +.....###################....... +.......###############......... +................................ +""" + + +def parse_art(art): + rows = [r for r in art.splitlines() if r.strip()] + w = max(len(r) for r in rows) + rows = [r.ljust(w, '.') for r in rows] + grid = [[CHARS.get(c, 0) for c in row] for row in rows] + return grid, w, len(rows) + + +def planes(grid, w, h, depth=2): + """Pack the pixel grid into `depth` word-aligned bitplanes.""" + bpr = ((w + 15) // 16) * 2 + out = b'' + for p in range(depth): + for y in range(h): + rowbytes = bytearray(bpr) + for x in range(w): + if (grid[y][x] >> p) & 1: + rowbytes[x // 8] |= 0x80 >> (x % 8) + out += bytes(rowbytes) + return out + + +def image(grid, w, h, depth=2): + """struct Image + plane data (ImageData is a placeholder pointer).""" + hdr = struct.pack('>hhhhh I BB I', 0, 0, w, h, depth, + 1, # ImageData (placeholder != 0) + (1 << depth) - 1, 0, # PlanePick, PlaneOnOff + 0) # NextImage + return hdr + planes(grid, w, h, depth) + + +def cstr(s): + b = s.encode('ascii') + b'\0' + return struct.pack('>I', len(b)) + b + + +def icon(art, do_type, default_tool=None, tooltypes=(), stack=8192): + grid, w, h = parse_art(art) + is_drawer = do_type == WBDRAWER + + gadget = struct.pack('>IhhhhHHHIIIIIHI', + 0, # NextGadget + 0, 0, w, h, # LeftEdge/TopEdge/Width/Height + 0x0004, # Flags: GADGIMAGE|GADGHCOMP + 0x0003, # Activation: RELVERIFY|IMMEDIATE + 0x0001, # GadgetType: BOOLGADGET + 1, # GadgetRender (placeholder != 0) + 0, 0, # SelectRender, GadgetText + 0, 0, # MutualExclude, SpecialInfo + 0, # GadgetID + 1) # UserData: OS 2.x icon revision + assert len(gadget) == 44, len(gadget) + + do = struct.pack('>HH', 0xE310, 1) + gadget + do += struct.pack('>Bx', do_type) + do += struct.pack('>IIiiIII', + 1 if default_tool else 0, # do_DefaultTool + 1 if tooltypes else 0, # do_ToolTypes + NO_ICON_POSITION, NO_ICON_POSITION, + 1 if is_drawer else 0, # do_DrawerData + 0, # do_ToolWindow + stack) + assert len(do) == 78, len(do) + + body = b'' + if is_drawer: + # struct DrawerData: NewWindow (48) + dd_CurrentX/Y (8) + newwin = struct.pack('>hhhh', 30, 30, 400, 120) # box + newwin += struct.pack('>BB', 255, 255) # pens + newwin += struct.pack('>I', 0) # IDCMP + newwin += struct.pack('>I', 0) # Flags + newwin += struct.pack('>IIII', 0, 0, 0, 0) # FirstGadget, + # CheckMark, + # Title, Screen + newwin += struct.pack('>I', 0) # BitMap + newwin += struct.pack('>hhhh', 90, 40, 0x7fff, 0x7fff) # Min/Max size + newwin += struct.pack('>H', 1) # WBENCHSCREEN + assert len(newwin) == 48, len(newwin) + body += newwin + struct.pack('>ii', 0, 0) # dd_CurrentX/Y + + body += image(grid, w, h) + if default_tool: + body += cstr(default_tool) + if tooltypes: + body += struct.pack('>I', (len(tooltypes) + 1) * 4) + for t in tooltypes: + body += cstr(t) + if is_drawer: + # OS 2.x DrawerData extension (revision 1): dd_Flags + dd_ViewModes. + body += struct.pack('>IH', 1, 0) # view icons, default mode + return do + body + + +ICONS = { + 'mqttstats.info': dict(art=ART_MQTTSTATS, do_type=WBTOOL, stack=8192, + tooltypes=('(HOST=192.168.1.10)', + '(CLIENTID=midge-stats)', + '(INTERVAL=60)', + 'CX_PRIORITY=0')), +} + + +def check(path, spec): + """Round-trip: re-parse the written file and verify what matters.""" + data = open(path, 'rb').read() + magic, version = struct.unpack('>HH', data[:4]) + assert magic == 0xE310 and version == 1, 'bad DiskObject header' + w, h = struct.unpack('>hh', data[12:16]) # Gadget.Width/Height + do_type = data[48] + assert do_type == spec['do_type'], f'type {do_type} != {spec["do_type"]}' + grid, aw, ah = parse_art(spec['art']) + assert (w, h) == (aw, ah), f'geometry {(w, h)} != {(aw, ah)}' + # image starts after DiskObject (78) + optional DrawerData (56) + off = 78 + (56 if do_type == WBDRAWER else 0) + iw, ih, depth = struct.unpack('>hhh', data[off + 4:off + 10]) + assert (iw, ih, depth) == (w, h, 2), 'image header mismatch' + pdata = data[off + 20:off + 20 + ((w + 15) // 16) * 2 * h * 2] + assert pdata == planes(grid, w, h), 'plane data mismatch' + # strings land in order: default tool, then each tooltype + for t in spec.get('tooltypes', ()): + assert t.encode() + b'\0' in data, f'tooltype missing: {t}' + dt = spec.get('default_tool') + if dt: + assert dt.encode() + b'\0' in data, 'default tool missing' + return True + + +def ppm(path, spec): + grid, w, h = parse_art(spec['art']) + scale = 8 + out = [f'P3 {w * scale} {h * scale} 255'] + for y in range(h): + row = ' '.join(' '.join(map(str, PALETTE[grid[y][x]])) + for x in range(w) for _ in range(scale)) + out.extend([row] * scale) + open(path, 'w').write('\n'.join(out) + '\n') + + +def main(): + outdir = sys.argv[1] if len(sys.argv) > 1 else 'icons' + os.makedirs(outdir, exist_ok=True) + for name, spec in ICONS.items(): + path = os.path.join(outdir, name) + with open(path, 'wb') as f: + f.write(icon(**{k: v for k, v in spec.items()})) + check(path, spec) + if '--ppm' in sys.argv: + ppm(path + '.ppm', spec) + print(f'{path}: OK ({os.path.getsize(path)} bytes)') + + +if __name__ == '__main__': + main() diff --git a/userdocs/Installation.md b/userdocs/Installation.md index d1be17b..1f3a2f8 100644 --- a/userdocs/Installation.md +++ b/userdocs/Installation.md @@ -14,7 +14,7 @@ The Aminet archive is a single drawer, `midge`, containing: midge/ mqtt_pub, mqtt_sub the default tools (need mqtt.library) mqtt_pub-static, mqtt_sub-static statically linked, no library needed - mqttstats headless HA telemetry Commodity (needs mqtt.library) + mqttstats, mqttstats.info headless HA telemetry Commodity (needs mqtt.library) midge.guide this documentation, for MultiView/AmigaGuide midge.readme, LICENSE libs/ diff --git a/userdocs/mqttstats.md b/userdocs/mqttstats.md index a959fb6..d4a3da0 100644 --- a/userdocs/mqttstats.md +++ b/userdocs/mqttstats.md @@ -59,15 +59,22 @@ with the same `CLIENTID` would collide on both. ### Running from Workbench / WBStartup -Set the ToolTypes on the `mqttstats` icon (Icons > Information, or the -equivalent in your Workbench version), then either double-click it or drop -the icon into `WBStartup:` to have it launch silently every boot. Either -way, nothing appears on screen - if `HOST` is missing or a library fails to -open, `mqttstats` quits immediately with no visible indication (diagnostics -go to the serial port only, for anyone debugging under an emulator or with -a serial cable attached - see [CLI Reference](CLI-Reference.md) for the -general shape of Amiga tool diagnostics). Check Home Assistant for the -expected entities to confirm it's actually connected. +`mqttstats` ships with its own icon (`mqttstats.info` in the archive, +alongside the binary) - copy both together wherever you put `mqttstats`. +Open its Information window (Workbench's Icons menu, or right-click on +some Workbench versions) and set at least `HOST` in the Tool Types list; +the icon ships with a few common ones already listed as comments (shown +grayed out, in parentheses) as a reminder of what's available - edit them +in place to activate. Then either double-click the icon to run it, or drag +it into `WBStartup:` to have it launch silently every boot. + +Either way, nothing appears on screen - if `HOST` is missing or a library +fails to open, `mqttstats` quits immediately with no visible indication +(diagnostics go to the serial port only, for anyone debugging under an +emulator or with a serial cable attached - see +[CLI Reference](CLI-Reference.md) for the general shape of Amiga tool +diagnostics). Check Home Assistant for the expected entities to confirm +it's actually connected. ### Running from a Shell From d36922f3c63779c88fa19a639d68309f541f87df Mon Sep 17 00:00:00 2001 From: Simon Dick Date: Mon, 31 Aug 2026 07:34:36 +0100 Subject: [PATCH 3/5] mqttstats: full ToolTypes icon, connect-refused alert, retry backoff, version sensors - Icon now lists every ToolType (not just four), all disabled/commented - matches every read_config() key so nothing is hidden from the icon. - Initial MQTT_Connect() failures now retry with capped exponential backoff (mco_AutoReconnect only covers post-connect drops, not a failed first attempt) - lets mqttstats launch from WBStartup ahead of the TCP/IP stack's own startup entry without just quitting once. - Added MQTT_CONNECT_REFUSED (-102) as a proper public constant in mqtt.library's (mqtt.doc updated, api-reference regenerated). mqttstats checks for it specifically: unlike a transient connect failure, a CONNACK refusal (bad credentials, rejected client id, ...) means retrying won't help, so it opens intuition.library itself and shows a one-off EasyRequest before quitting - mqtt.library itself stays UI-free, this is purely the caller's own choice. - Added Kickstart version and Workbench version telemetry sensors (exec.library's and workbench.library's own lib_Version/lib_Revision). Formatting them hit a confirmed m68k-amigaos-gcc -O2 miscompile (garbage upper bits when a UWORD struct field reaches sprintf's "%u" varargs); worked around with a dedicated noinline manual formatter - see format_bcd_version()'s own comment for the full story. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QWq1CRLCQy9AaD9UtLRSM9 --- icons/mqttstats.info | Bin 349 -> 478 bytes src/amiga/mqttstats_main.c | 241 +++++++++++++++++++++++++-- src/library/include/libraries/mqtt.h | 13 ++ src/library/mqtt.doc | 11 +- tools/mkicons.py | 15 +- userdocs/mqtt-library-reference.md | 11 +- userdocs/mqttstats.md | 47 ++++-- 7 files changed, 310 insertions(+), 28 deletions(-) diff --git a/icons/mqttstats.info b/icons/mqttstats.info index d6ff6a2eb653473a5c45f0879292175406c100d5..0f286eba97424517ec85c870807d9fa76cd48c1b 100644 GIT binary patch delta 199 zcmcc1bdPxhE2Fvz0|SGIhKGM}h^?Wek)ENMg`T0Fp@Ak)n1M$lz&{8mZed|OS%y)e zURcA$HO$l5)z8t_)z&dLGd&Tkj7!7M-^JC_#TLxt&)>hx-S?* Np8i3eA(6H~K>)8iCprKC delta 69 zcmcb|e3xkhE2Fpw0|SGIhKGM}h^?Wek)ENMg`T0Fp}}MwMg>Ja4Nt!i*Pt**A6qj6 XO`t*sKIe$|fFMu*AkUCUTLT6F6ekZ4 diff --git a/src/amiga/mqttstats_main.c b/src/amiga/mqttstats_main.c index a459817..7071678 100644 --- a/src/amiga/mqttstats_main.c +++ b/src/amiga/mqttstats_main.c @@ -38,12 +38,14 @@ #include #include #include +#include #include #include #include #include #include +#include #include /* ArgArrayInit/ArgArrayDone/ArgString/ArgInt - amiga.lib, not an LVO library: no proto @@ -91,7 +93,17 @@ static void raw_str(const char *s) * mqtt.library itself. */ struct Library *MqttBase; struct Library *CxBase; +struct IntuitionBase *IntuitionBase; /* opened only for the connect-refused + alert below - see + alert_connect_refused(). proto/ + intuition.h itself declares this + extern (typed struct IntuitionBase + *, unlike the plain struct Library + * every other base here uses) - + this is the matching definition, + not a fresh global. */ struct Device *TimerBase; +struct Library *WorkbenchBase; /* version only - see workbench_version() */ /* Poll tick for the main loop - see run_loop()'s own comment for why a * plain Delay()-based poll was chosen over multiplexing a WaitSelect()- @@ -253,6 +265,84 @@ static const char *cpu_model(void) return "68000"; } +/* format_bcd_version() below works around a confirmed m68k-amigaos-gcc -O2 + * miscompile (not an AmigaOS/NDK issue): reading a UWORD struct field + * (lib_Version/lib_Revision) into an sprintf() "%u" call leaves garbage in + * the promoted value's upper 16 bits - confirmed via an isolated repro, + * -O0 unaffected. Neither an explicit `& 0xFFFF` mask (optimized away as + * "provably redundant" by GCC's own - wrong, here - UWORD range analysis; + * confirmed by a byte-identical rebuild with and without it) nor routing + * through a `volatile` local (works in an isolated repro but NOT in this + * file's actual build - confirmed on-target, still wrong here) reliably + * fixed it, which means whatever's really going on is sensitive to this + * file's surrounding code, not just the read+format pattern in isolation. + * Sidestepping sprintf's "%u" varargs path entirely with a plain, + * never-inlined, non-variadic formatter is the one approach confirmed + * correct on-target for these two call sites - see the version-history + * around this comment (git blame) if a future toolchain upgrade makes it + * worth revisiting whether this workaround is still needed. */ +static void __attribute__((noinline)) +format_bcd_version(char *buf, unsigned long ver, unsigned long rev) +{ + unsigned long vals[2]; + int vi; + char *p = buf; + + vals[0] = ver; + vals[1] = rev; + for (vi = 0; vi < 2; vi++) { + char digits[6]; + int n = 0; + unsigned long v = vals[vi]; + + if (vi == 1) + *p++ = '.'; + if (v == 0) { + digits[n++] = '0'; + } else { + while (v > 0 && n < (int) sizeof(digits)) { + digits[n++] = (char) ('0' + (v % 10)); + v /= 10; + } + } + while (n > 0) + *p++ = digits[--n]; + } + *p = '\0'; +} + +/* Kickstart's own version is just exec.library's - SysBase is always open + * (it's how every program gets here at all), so unlike workbench_version() + * below there's no "library missing" case to handle. */ +static void kickstart_version(char *buf) +{ + struct ExecBase *eb = (struct ExecBase *) SysBase; + volatile unsigned long ver = eb->LibNode.lib_Version; + volatile unsigned long rev = eb->LibNode.lib_Revision; + + format_bcd_version(buf, ver, rev); +} + +/* Kickstart (ROM) and Workbench (disk-based) versions can genuinely differ + * - e.g. a newer ROM booted against an older Workbench disk set, or vice + * versa - so this is deliberately a separate sensor from kickstart_version + * above, not assumed to always match it. workbench.library's own version + * tracks the installed Workbench release the same way exec.library's + * tracks Kickstart. WorkbenchBase is opened once in main() and may be NULL + * (a bare CLI-only setup that's never actually installed/loaded Workbench, + * however rare) - reported as "unavailable" rather than a misleading 0.0. */ +static void workbench_version(char *buf) +{ + if (WorkbenchBase) { + volatile unsigned long ver = WorkbenchBase->lib_Version; + volatile unsigned long rev = WorkbenchBase->lib_Revision; + + format_bcd_version(buf, ver, rev); + } else { + strcpy(buf, "unavailable"); + } +} + /* --- Publishing ---------------------------------------------------------- */ static int publish_retained(APTR client, const char *topic, const char *value, @@ -286,6 +376,16 @@ static void publish_discovery(APTR client, const ha_device *dev) ha_discovery_payload(dev, "cpu_model", "CPU Model", NULL, NULL, payload, sizeof(payload)) > 0) publish_retained(client, topic, payload, 1); + + if (ha_discovery_topic(dev, "kickstart_version", topic, sizeof(topic)) > 0 && + ha_discovery_payload(dev, "kickstart_version", "Kickstart Version", + NULL, NULL, payload, sizeof(payload)) > 0) + publish_retained(client, topic, payload, 1); + + if (ha_discovery_topic(dev, "workbench_version", topic, sizeof(topic)) > 0 && + ha_discovery_payload(dev, "workbench_version", "Workbench Version", + NULL, NULL, payload, sizeof(payload)) > 0) + publish_retained(client, topic, payload, 1); } static void publish_availability(APTR client, const ha_device *dev, @@ -316,6 +416,100 @@ static void publish_state(APTR client, const ha_device *dev) } if (ha_state_topic(dev, "cpu_model", topic, sizeof(topic)) > 0) publish_retained(client, topic, cpu_model(), 0); + if (ha_state_topic(dev, "kickstart_version", topic, sizeof(topic)) > 0) { + kickstart_version(value); + publish_retained(client, topic, value, 0); + } + if (ha_state_topic(dev, "workbench_version", topic, sizeof(topic)) > 0) { + workbench_version(value); + publish_retained(client, topic, value, 0); + } +} + +/* --- Initial-connect retry ------------------------------------------------ + * + * mco_AutoReconnect (see MQTT_CreateClient()'s opts) only covers a drop + * *after* MQTT_Connect() has already succeeded once - it explicitly does + * not retry a failed first attempt (see 's own comment on + * mco_AutoReconnect). That matters here specifically because mqttstats is + * meant to run from WBStartup, where there's no guaranteed ordering against + * the TCP/IP stack's own WBStartup entry (Roadshow/AmiTCP/Miami) - launched + * before bsdsocket.library is up, a bare single MQTT_Connect() attempt + * would just fail once and quit for the rest of the session. So this loop + * retries MQTT_Connect() itself, with the same capped exponential backoff + * mco_AutoReconnect uses internally (1s, 2s, 4s, ... capped at 32s, + * forever) - EXCEPT for MQTT_CONNECT_REFUSED (see alert_connect_refused() + * below), which means the broker is up and actively saying no: retrying + * that unchanged would just spam it forever for no reason, so it stops and + * surfaces the problem instead. */ + +/* Polls cxport/CTRL_C in MQTTSTATS_POLL_TICKS chunks across a backoff wait, + * so a Kill from Exchange (or Ctrl-C from a Shell launch) can interrupt a + * long wait instead of only being noticed once run_loop() itself starts. + * ENABLE/DISABLE while still trying to connect have nothing to toggle yet + * (no client, no availability topic to publish), so are drained and + * ignored here - Exchange's Enable/Disable simply has no effect until the + * initial connect succeeds. Returns TRUE if the caller should give up + * (killed/interrupted), FALSE if the wait completed normally. */ +static BOOL wait_ticks_or_kill(struct MsgPort *cxport, ULONG total_ticks) +{ + ULONG waited = 0; + + while (waited < total_ticks) { + if (cxport) { + CxMsg *cxm; + while ((cxm = (CxMsg *) GetMsg(cxport)) != NULL) { + ULONG type = CxMsgType(cxm); + ULONG id = CxMsgID(cxm); + ReplyMsg((struct Message *) cxm); + if (type == CXM_COMMAND && id == CXCMD_KILL) + return TRUE; + } + } + if (SetSignal(0, 0) & SIGBREAKF_CTRL_C) { + SetSignal(0, SIGBREAKF_CTRL_C); + return TRUE; + } + Delay(MQTTSTATS_POLL_TICKS); + waited += MQTTSTATS_POLL_TICKS; + } + return FALSE; +} + +/* MQTT_CONNECT_REFUSED means a config problem (bad credentials, rejected + * client id, ...) that silently retrying forever will never fix - only + * relaunching after editing the icon's ToolTypes will. Left to just quit + * quietly (the original design, see this file's own history), a broker + * that requires auth but was never given any would fail every single + * launch with no visible indication why. mqtt.library itself has no idea + * this alert exists or should happen - it only ever returns the plain + * negative code above; opening intuition.library and deciding to show + * something is entirely this application's own choice, same as any other + * mqtt.library caller is free to make differently. */ +static void alert_connect_refused(const char *host) +{ + struct EasyStruct es; + + raw_str("mqttstats: broker refused the connection (bad credentials, " + "client id, or protocol version?)\r\n"); + + IntuitionBase = (struct IntuitionBase *) + OpenLibrary((STRPTR) "intuition.library", 37); + if (!IntuitionBase) + return; + + es.es_StructSize = sizeof(es); + es.es_Flags = 0; + es.es_Title = (UBYTE *) "mqttstats"; + es.es_TextFormat = (UBYTE *) + "mqttstats: %s refused the connection.\n" + "Check HOST/USER/PASSWORD/CLIENTID in this icon's Tool Types, " + "then relaunch."; + es.es_GadgetFormat = (UBYTE *) "OK"; + EasyRequest(NULL, &es, NULL, host); + + CloseLibrary((struct Library *) IntuitionBase); + IntuitionBase = NULL; } /* --- Main loop ------------------------------------------------------------ @@ -476,6 +670,10 @@ int main(int argc, char **argv) goto cleanup_cx; } + /* Non-fatal: workbench_version() reports "unavailable" if this fails - + * see that function's own comment. */ + WorkbenchBase = OpenLibrary((STRPTR) "workbench.library", 0); + /* Keepalive comfortably above our own publish interval (see run_loop()'s * banner): our periodic PUBLISHes alone keep the broker's keepalive * tracking satisfied, since ANY MQTT packet counts, not specifically a @@ -498,16 +696,36 @@ int main(int argc, char **argv) opts.mco_TLSInsecure = cfg.tls_insecure; opts.mco_CAFile = cfg.ca_file; - client = MQTT_CreateClient(cfg.host, cfg.port, &opts); - if (!client) { - raw_str("mqttstats: MQTT_CreateClient failed\r\n"); - exit_code = 20; - goto cleanup_lib; - } - if (MQTT_Connect(client) != 0) { - raw_str("mqttstats: connect failed\r\n"); - exit_code = 20; - goto cleanup_client; + { + ULONG backoff_secs = 1; + + for (;;) { + LONG rc; + + client = MQTT_CreateClient(cfg.host, cfg.port, &opts); + if (client) { + rc = MQTT_Connect(client); + if (rc == 0) + break; /* connected - fall through below */ + if (rc == MQTT_CONNECT_REFUSED) { + alert_connect_refused((const char *) cfg.host); + MQTT_DeleteClient(client); + client = NULL; + exit_code = 20; + goto cleanup_lib; + } + MQTT_DeleteClient(client); + client = NULL; + } + raw_str("mqttstats: connect failed, retrying...\r\n"); + if (wait_ticks_or_kill(cxport, backoff_secs * 50)) { + /* Killed via Exchange, or Ctrl-C from a Shell launch, + * before ever connecting - a clean exit, not an error. */ + goto cleanup_lib; + } + if (backoff_secs < 32) + backoff_secs *= 2; + } } publish_discovery(client, &dev); @@ -519,9 +737,10 @@ int main(int argc, char **argv) publish_availability(client, &dev, "offline"); MQTT_Disconnect(client); -cleanup_client: MQTT_DeleteClient(client); cleanup_lib: + if (WorkbenchBase) + CloseLibrary(WorkbenchBase); CloseLibrary(MqttBase); cleanup_cx: if (broker) diff --git a/src/library/include/libraries/mqtt.h b/src/library/include/libraries/mqtt.h index bb4659d..5c14d93 100644 --- a/src/library/include/libraries/mqtt.h +++ b/src/library/include/libraries/mqtt.h @@ -177,4 +177,17 @@ struct MqttMessage { MQTT_Disconnect() nor an unexpected drop has happened since) */ +/* One src/core/mqtt_client.h mqtt_client_err value worth a public name here: + * MQTT_Connect() returns this (unchanged from core's own + * -MQTT_CLIENT_ERR_CONNECT_REFUSED) when a CONNACK actually arrived but + * carried a non-zero return code - bad username/password, not authorized, + * an unacceptable protocol version, a rejected client id, or the broker + * reporting itself unavailable. Unlike a timeout or transport failure, this + * is the broker actively saying no: not something a caller should retry + * unchanged and expect to eventually succeed - see mqttstats_main.c's own + * use of it to tell "broker is down/unreachable, keep retrying" apart from + * "config is wrong, stop and tell someone" for a WBStartup-launched daemon + * with no other way to surface the difference. */ +#define MQTT_CONNECT_REFUSED (-102) + #endif /* LIBRARIES_MQTT_H */ diff --git a/src/library/mqtt.doc b/src/library/mqtt.doc index 5f98a42..efa454f 100644 --- a/src/library/mqtt.doc +++ b/src/library/mqtt.doc @@ -150,7 +150,16 @@ mqtt.library/--background-- mqtt.library/--background-- so on. See those two headers in the project's source for the exact list; from a caller's point of view the only thing that matters is that any negative return is a failure and 0 is - success. + success - with one exception worth a public name in + : MQTT_CONNECT_REFUSED (-102), which + MQTT_Connect() returns when a CONNACK actually arrived but + carried a non-zero return code (bad credentials, rejected + client id, unacceptable protocol version, or the broker + reporting itself unavailable). Unlike a timeout or transport + failure, that is the broker actively saying no - a caller + that retries on any other failure but treats this one as + terminal (and tells its user why) will behave better than one + that treats every failure alike. SEE ALSO MQTT_CreateClient, MQTT_Connect, MQTT_Publish, MQTT_Subscribe, diff --git a/tools/mkicons.py b/tools/mkicons.py index 25617c7..c97754d 100644 --- a/tools/mkicons.py +++ b/tools/mkicons.py @@ -147,11 +147,24 @@ def icon(art, do_type, default_tool=None, tooltypes=(), stack=8192): ICONS = { + # Every mqttstats ToolType (see read_config() in mqttstats_main.c) + # listed, all wrapped in parens - IconEdit's "disabled" convention: shown + # grayed out, not passed to the program, so a plain double-click without + # any editing still hits the "HOST required" error rather than silently + # connecting to the sample IP. Edit an entry in place to activate it. 'mqttstats.info': dict(art=ART_MQTTSTATS, do_type=WBTOOL, stack=8192, tooltypes=('(HOST=192.168.1.10)', + '(PORT=1883)', '(CLIENTID=midge-stats)', + '(DEVICENAME=Amiga)', + '(NODEID=)', + '(USER=)', + '(PASSWORD=)', + '(TLS=1)', + '(TLSINSECURE=1)', + '(CAFILE=)', '(INTERVAL=60)', - 'CX_PRIORITY=0')), + '(CX_PRIORITY=0)')), } diff --git a/userdocs/mqtt-library-reference.md b/userdocs/mqtt-library-reference.md index a487ff5..74dd2b5 100644 --- a/userdocs/mqtt-library-reference.md +++ b/userdocs/mqtt-library-reference.md @@ -152,7 +152,16 @@ families of negative codes are possible: so on. See those two headers in the project's source for the exact list; from a caller's point of view the only thing that matters is that any negative return is a failure and 0 is - success. + success - with one exception worth a public name in + : MQTT_CONNECT_REFUSED (-102), which + MQTT_Connect() returns when a CONNACK actually arrived but + carried a non-zero return code (bad credentials, rejected + client id, unacceptable protocol version, or the broker + reporting itself unavailable). Unlike a timeout or transport + failure, that is the broker actively saying no - a caller + that retries on any other failure but treats this one as + terminal (and tells its user why) will behave better than one + that treats every failure alike. ``` ### See Also diff --git a/userdocs/mqttstats.md b/userdocs/mqttstats.md index d4a3da0..53f9e3b 100644 --- a/userdocs/mqttstats.md +++ b/userdocs/mqttstats.md @@ -20,6 +20,8 @@ Once running, `mqttstats` connects to your broker and publishes, every | Chip RAM free | bytes | `AvailMem(MEMF_CHIP)`. | | Fast RAM free | bytes | `AvailMem(MEMF_FAST)`. | | CPU model | - | `68020`/`68030`/`68040`/`68060`, detected from `AttnFlags`. | +| Kickstart version | - | exec.library's version, e.g. `47.96` - the ROM. | +| Workbench version | - | workbench.library's version, e.g. `47.102` - the installed disk-based release, which can genuinely differ from the Kickstart ROM (a newer ROM with an older Workbench install, or vice versa). Reported as `unavailable` on the rare setup that's never had Workbench loaded at all. | It also publishes an availability topic (`online` while running, `offline` on a clean shutdown) and registers a Home Assistant MQTT Discovery config @@ -63,18 +65,35 @@ with the same `CLIENTID` would collide on both. alongside the binary) - copy both together wherever you put `mqttstats`. Open its Information window (Workbench's Icons menu, or right-click on some Workbench versions) and set at least `HOST` in the Tool Types list; -the icon ships with a few common ones already listed as comments (shown -grayed out, in parentheses) as a reminder of what's available - edit them -in place to activate. Then either double-click the icon to run it, or drag -it into `WBStartup:` to have it launch silently every boot. - -Either way, nothing appears on screen - if `HOST` is missing or a library -fails to open, `mqttstats` quits immediately with no visible indication -(diagnostics go to the serial port only, for anyone debugging under an -emulator or with a serial cable attached - see +the icon ships with every ToolType from the table above already listed, +each as a comment (shown grayed out, in parentheses) with a sample value - +edit an entry in place to activate it. Then either double-click the icon +to run it, or drag it into `WBStartup:` to have it launch silently every +boot. + +Either way, nothing appears on screen for most problems - if `HOST` is +missing or a library fails to open, `mqttstats` quits immediately with no +visible indication (diagnostics go to the serial port only, for anyone +debugging under an emulator or with a serial cable attached - see [CLI Reference](CLI-Reference.md) for the general shape of Amiga tool -diagnostics). Check Home Assistant for the expected entities to confirm -it's actually connected. +diagnostics). One exception: if the broker actively refuses the +connection (wrong credentials, rejected client id, and similarly +config-level problems), `mqttstats` pops up a one-off requester saying so +before quitting - unlike a transient network problem (see below), no +amount of retrying fixes a config problem, so it's worth actually telling +someone. Otherwise, check Home Assistant for the expected entities to +confirm it's actually connected. + +### Connecting before the network is up + +`mqttstats` retries the initial connection with capped exponential backoff +(1s, 2s, 4s, ... up to 32s, indefinitely) rather than quitting on the +first failure - deliberately, since there's no guaranteed WBStartup +ordering against your TCP/IP stack's own startup entry (Roadshow/AmiTCP/ +Miami). Dropped into `WBStartup:` alongside your stack, `mqttstats` simply +keeps trying quietly in the background until the network - and the +broker - are reachable, however long that takes. Killing it via +Commodities Exchange during this retry works the same as once connected. ### Running from a Shell @@ -92,9 +111,9 @@ Nothing needs configuring on the Home Assistant side beyond [MQTT discovery being enabled](https://www.home-assistant.io/integrations/mqtt/#configuration) (the default for HA's own MQTT integration). Once `mqttstats` connects: -1. Each of the four sensors above appears automatically under a device - named after `DEVICENAME`, grouped with any other `mqttstats` sensors - from the same Amiga. +1. Each of the sensors above appears automatically under a device named + after `DEVICENAME`, grouped with any other `mqttstats` sensors from the + same Amiga. 2. The device shows as unavailable if `mqttstats` disconnects (broker down, network dropped, `mqttstats` killed via Exchange) and available again once it reconnects or restarts. From 6e925c6949ff564dcbbbd4fbfe14e502995e50e8 Mon Sep 17 00:00:00 2001 From: Simon Dick Date: Mon, 31 Aug 2026 07:41:54 +0100 Subject: [PATCH 4/5] mqttstats: fix uptime reporting ~50 years instead of actual uptime TR_GETSYSTIME doesn't measure time since reset - it returns the same absolute, calendar-rooted system clock DateStamp() is built on (seconds since 1978-01-01, set from the battery-backed RTC at boot), just with sub-second precision. Reported as "uptime" it produced a nonsensical ~48-year value on any machine with a working clock - caught in real use. Switched to ReadEClock()'s free-running hardware tick counter, which genuinely resets to 0 at power-on and has no notion of calendar date at all, while remaining exactly as immune to the DateStamp() clock-jump hazard issue #8 documents. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QWq1CRLCQy9AaD9UtLRSM9 --- src/amiga/mqttstats_main.c | 42 +++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/src/amiga/mqttstats_main.c b/src/amiga/mqttstats_main.c index 7071678..dc520e9 100644 --- a/src/amiga/mqttstats_main.c +++ b/src/amiga/mqttstats_main.c @@ -189,16 +189,25 @@ static void read_config(STRPTR *tt, mqttstats_config *cfg, /* --- Telemetry gathering ------------------------------------------------ */ -/* timer.device's TR_GETSYSTIME reports seconds+micros since the last - * system reset - genuinely monotonic, unlike dos.library's DateStamp() - * (see tool_clock.h's own comment on issue #8). That distinction doesn't - * matter for mqtt.library or the CLI tools (multi-connection subprocess - * statics are off the table there), but this program is a standalone - * singleton (NBU_UNIQUE enforces at most one instance), so opening the - * device once at startup and keeping it for the process's lifetime is - * completely safe - there is no second instance to trample it. Used for - * both the reported "uptime" metric and this program's own interval - * scheduling (avoiding the exact clock-jump hazard issue #8 documents). */ +/* An earlier version of this file used timer.device's TR_GETSYSTIME here, + * on the mistaken premise that it reports elapsed time since the last + * system reset. It doesn't: TR_GETSYSTIME returns the *system clock* - + * the same absolute, calendar-rooted clock dos.library's DateStamp() is + * built on (seconds since 1978-01-01, set from the battery-backed RTC at + * boot if one's fitted) - just with sub-second precision. Reported as + * "uptime" it produced a nonsensical near-50-year value on any machine + * with a working clock; caught in real use, not in testing. What's + * actually wanted is `ReadEClock()`'s free-running hardware tick counter + * (see uptime_secs() below): it genuinely does reset to 0 at power-on, + * has no notion of calendar date at all, and is exactly as immune to the + * DateStamp() clock-jump hazard issue #8 documents (a live SetClock or + * timezone change can't move it) - it just doesn't ALSO track wall time, + * which this program never needed anyway. Used for both the reported + * "uptime" metric and this program's own interval scheduling. This + * program is a standalone singleton (NBU_UNIQUE enforces at most one + * instance), so opening the device once at startup and keeping it for + * the process's lifetime is completely safe - there is no second + * instance to trample it. */ static struct timerequest *g_timerreq; static struct MsgPort *g_timerport; @@ -242,12 +251,17 @@ static void close_timer(void) } } -/* Seconds since the last system reset. */ +/* Seconds since the last system reset - see this section's own banner + * comment for why this is ReadEClock(), not TR_GETSYSTIME. ReadEClock() + * is a direct library call through TimerBase (set by open_timer()'s + * OpenDevice(), any unit), not an IORequest - no DoIO() here. */ static ULONG uptime_secs(void) { - g_timerreq->tr_node.io_Command = TR_GETSYSTIME; - DoIO((struct IORequest *)g_timerreq); - return (ULONG) g_timerreq->tr_time.tv_secs; + struct EClockVal ec; + ULONG freq = ReadEClock(&ec); + unsigned long long ticks = ((unsigned long long) ec.ev_hi << 32) | ec.ev_lo; + + return freq ? (ULONG) (ticks / freq) : 0; } static const char *cpu_model(void) From 263ee6ea0f81e1c636ff0fcb1d1f6d6cb9a84a8e Mon Sep 17 00:00:00 2001 From: Simon Dick Date: Mon, 31 Aug 2026 07:45:53 +0100 Subject: [PATCH 5/5] mqttstats: reorder Commodity description so Exchange doesn't truncate it mid-word Exchange's list truncates nb_Descr on OS 3.1 well before the end, cutting "...to Home Assistant via MQTT" down to "...to Home Ass". Reordered so "Home Assistant" lands safely inside the observed ~37-character cutoff instead of running off the end of the string. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QWq1CRLCQy9AaD9UtLRSM9 --- src/amiga/mqttstats_main.c | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/amiga/mqttstats_main.c b/src/amiga/mqttstats_main.c index dc520e9..e0e9f28 100644 --- a/src/amiga/mqttstats_main.c +++ b/src/amiga/mqttstats_main.c @@ -652,7 +652,12 @@ int main(int argc, char **argv) nb.nb_Version = NB_VERSION; nb.nb_Name = (STRPTR) "mqttstats"; nb.nb_Title = (STRPTR) "midge mqttstats"; - nb.nb_Descr = (STRPTR) "Publishes Amiga telemetry to Home Assistant via MQTT"; + /* Exchange's list truncates this on OS 3.1 well before the end - + * "Home Assistant" needs to land safely inside that limit rather + * than run off the end of the string, or it gets cut mid-word + * ("...to Home Ass"). "Home Assistant" ends at character 33 here, + * comfortably inside the ~37-character cutoff observed on 3.1. */ + nb.nb_Descr = (STRPTR) "Amiga telemetry to Home Assistant via MQTT"; nb.nb_Unique = NBU_UNIQUE; nb.nb_Flags = 0; /* no window - nothing for Exchange to Show/Hide */ nb.nb_Pri = cfg.cx_priority;