diff --git a/Makefile b/Makefile index f8e514e..3da586c 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,8 @@ 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 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/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/icons/mqttstats.info b/icons/mqttstats.info new file mode 100644 index 0000000..0f286eb Binary files /dev/null and b/icons/mqttstats.info differ diff --git a/icons/mqttstats.info.png b/icons/mqttstats.info.png new file mode 100644 index 0000000..4ba93cd Binary files /dev/null and b/icons/mqttstats.info.png differ 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..e0e9f28 --- /dev/null +++ b/src/amiga/mqttstats_main.c @@ -0,0 +1,778 @@ +/* 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 +#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 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()- + * 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 ------------------------------------------------ */ + +/* 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; + +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 - 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) +{ + 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) +{ + 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"; +} + +/* 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, + 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); + + 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, + 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); + 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 ------------------------------------------------------------ + * + * 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"; + /* 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; + 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; + } + + /* 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 + * 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; + + { + 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); + 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); + + MQTT_DeleteClient(client); +cleanup_lib: + if (WorkbenchBase) + CloseLibrary(WorkbenchBase); + 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/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/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/tools/mkicons.py b/tools/mkicons.py new file mode 100644 index 0000000..c97754d --- /dev/null +++ b/tools/mkicons.py @@ -0,0 +1,221 @@ +#!/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 = { + # 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)')), +} + + +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 9c548e4..1f3a2f8 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, mqttstats.info 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/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 new file mode 100644 index 0000000..53f9e3b --- /dev/null +++ b/userdocs/mqttstats.md @@ -0,0 +1,123 @@ +# 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`. | +| 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 +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 + +`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 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). 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 + +``` +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 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.