From 58475386bab4185934784b9d26f5644410e38174 Mon Sep 17 00:00:00 2001 From: "James Bateman (hp-laptop)" Date: Fri, 19 Jun 2026 10:16:50 +0100 Subject: [PATCH] Butterfly EDFA Rack bridge: seed + EDFA over WiFi/MQTT to Home Assistant ESP32 firmware bridging the 1550 nm MOPA (Koheron CTL200 seed + Agiltron M511 two-stage EDFA) to MQTT/HA as a single device. Telemetry and control for both instruments verified end-to-end: MQTT command -> seed lasing -> booster ramp to 27 dBm (~515 mW) at the documented 2500 mA operating point. - PlatformIO/Arduino project, project-local venv, Makefile upload flow - Koheron ASCII driver on UART2; Agiltron binary 0x55AA driver on UART1 - HA discovery (sensors, binary_sensors, switches, numbers); LWT availability - Static IP, firmware current clamps on seed and booster pump --- .gitignore | 5 + Makefile | 41 +++++ README.md | 159 ++++++++++++++++++ include/config.h | 65 ++++++++ include/secrets.example.h | 15 ++ platformio.ini | 25 +++ src/agiltron.cpp | 146 +++++++++++++++++ src/agiltron.h | 83 ++++++++++ src/koheron.cpp | 65 ++++++++ src/koheron.h | 33 ++++ src/main.cpp | 330 ++++++++++++++++++++++++++++++++++++++ 11 files changed, 967 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 include/config.h create mode 100644 include/secrets.example.h create mode 100644 platformio.ini create mode 100644 src/agiltron.cpp create mode 100644 src/agiltron.h create mode 100644 src/koheron.cpp create mode 100644 src/koheron.h create mode 100644 src/main.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1e5e7c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +.pio/ +include/secrets.h +*.pyc +__pycache__/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0ebaa43 --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +# Koheron CTL200 -> WiFi/MQTT bridge on ESP32. +# +# All commands run PlatformIO from the project-local venv, so you never need +# `pio` on your PATH. Just `make`, `make upload`, `make monitor`. + +VENV := .venv +PIO := $(VENV)/bin/pio +PORT ?= /dev/ttyUSB0 +ENV := esp32dev + +.PHONY: help build upload monitor flash clean detect erase venv + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}' + +build: $(PIO) ## Compile firmware + $(PIO) run -e $(ENV) + +upload: $(PIO) ## Compile + flash to the ESP32 + $(PIO) run -e $(ENV) -t upload --upload-port $(PORT) + +monitor: $(PIO) ## Open serial monitor (Ctrl-C to exit) + $(PIO) device monitor --port $(PORT) -b 115200 + +flash: upload monitor ## Upload then immediately open the monitor + +detect: $(PIO) ## Identify the chip on $(PORT) + $(VENV)/bin/python -m esptool --port $(PORT) chip_id + +erase: $(PIO) ## Erase entire flash (factory reset) + $(VENV)/bin/python -m esptool --port $(PORT) erase_flash + +clean: $(PIO) ## Remove build artifacts + $(PIO) run -e $(ENV) -t clean + +# Recreate the venv if it is missing (e.g. fresh checkout). +venv $(PIO): + python3 -m venv $(VENV) + $(VENV)/bin/pip install --upgrade pip + $(VENV)/bin/pip install platformio esptool diff --git a/README.md b/README.md new file mode 100644 index 0000000..db37388 --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# koheron-esp32-bridge + +ESP32 firmware that bridges the **Butterfly EDFA Rack** — a 1550 nm MOPA: +a [Koheron CTL200](https://www.koheron.com/photonics/ctl200-digital-laser-controller/) +driving a DFB seed into an Agiltron M511 EYDFA power amplifier (output up to +~1 W / 30 dBm) — to WiFi/MQTT for monitoring and control from Home Assistant +(broker `192.168.0.102:1883`, anonymous). + +The whole rack is presented as a **single HA device** (`butterfly_edfa_rack`). +Topics, mirroring the lab's EDFA→MQTT convention: + +``` +lab/laser/butterfly_edfa_rack/availability online | offline (LWT) +lab/laser/butterfly_edfa_rack/diag/ ESP/network diagnostics +lab/laser/butterfly_edfa_rack/seed/ Koheron CTL200 (increment 3) +lab/laser/butterfly_edfa_rack/amp/ Agiltron M511 (increment 4) +lab/laser/butterfly_edfa_rack/set/<...> commands (increment 5) +``` +HA discovery configs are published under `homeassistant/...`, all sharing one +`device` block so entities group under the single rack device. + +## Optical architecture + +The rack is a **MOPA** (master-oscillator power-amplifier) at 1550 nm: + +``` + Koheron CTL200 Agiltron M511 EYDFA (two-stage) + ┌──────────────┐ ┌─────────────────────────────────────┐ + │ DFB1550LP │ fibre │ pre-amp stage ──► booster stage │ fibre + │ seed diode ├─────────►│ (pump diode A) (pump diode B, ├────────► output + │ + TEC │ │ ~170 mA, ~16 dBm "pump-1") │ (up to ~1 W + └──────────────┘ └─────────────────────────────────────┘ / 30 dBm) + ilaser, lason input PD preamp PD output-1 PD +``` + +**The M511 has two internal pump diodes**, and the firmware reads both: + +- **Pre-amp pump** — runs semi-independently at ~170 mA, lifts the seed to + ~16 dBm (`amp/preamp_current_ma`, `amp/preamp_power_dbm`). +- **Booster pump = "pump-1"** — the controllable output stage + (`amp/pump1_current_ma`, set via `set/amp/pump1_current_ma`). +- **"Pump-2"** is a second booster channel that this unit leaves at 0 mA + regardless of settings (per `Lab/Equipment/Lasers/Agiltron EDFA`); we don't + drive it. It is read but not exposed as a control. + +Run the booster in **ACC (constant-current)** mode — APC stepping quantises the +output. Below transparency the booster erbium fibre *absorbs* the pre-amp +output, so output stays at the PD floor (−60 dBm) until the pump is high +enough. Measured 2026-06-17 with ~6 mW (7.8 dBm) seed into the EDFA: + +| Pump-1 | Output-1 | note | +|-------:|---------:|------| +| 500–900 mA | −60 dBm | below transparency, signal absorbed | +| 1200 mA | 21.0 dBm | booster turns on | +| 1500 mA | 23.3 dBm | | +| 2000 mA | 25.9 dBm | | +| 2500 mA | 27.1 dBm | documented operating point, ~515 mW | + +Pump current overshoots ~2× briefly on each step up and settles in 2–4 s, so +**ramp gradually** rather than jumping straight to the setpoint. + +> **Safety:** this is a Class 4, ~1 W, invisible 1550 nm source. The firmware +> clamps pump-1 to `PUMP_CURRENT_MAX_MA` (3000 mA) and seed current to +> `SEED_CURRENT_MAX_MA`, but those are not interlocks — treat the output as +> hazardous unless it terminates in a power meter / beam dump / closed system. + +## Hardware + +- ESP32-D0WD-V3 dev board (WROOM-32 class), CP2102N USB bridge → `/dev/ttyUSB0`, + MAC `b0:cb:d8:8f:ba:10`. +- The ESP32 USB port is used **only** for flashing/console (UART0). +- Two instrument links on separate hardware UARTs: + + | ESP32 | Instrument | Protocol | + |----------------|-------------------------------------|----------| + | GPIO17 (TX2) → | Koheron CTL200 RX (terminal block) | ASCII, 115200 8N1, `\r\n`, `>>` prompt | + | GPIO16 (RX2) ← | Koheron CTL200 TX | | + | GPIO26 (TX1) → | Agiltron M511 RX (P2 pin 3) | binary 0x55AA packets, 115200 8N1 | + | GPIO25 (RX1) ← | Agiltron M511 TX (P2 pin 7) | | + | GND | both grounds | | + + All links are 3V3 logic (CTL200 is 5V-tolerant). UART1 is remapped off its + default flash-clashing pins. + +> **Network:** the firmware pins a static IP `192.168.0.150` (gateway `.1`, +> /24) — see `USE_STATIC_IP` in `include/config.h`. This avoids the clash with +> the BKtel EDFA Pi on `.101` that a DHCP lease originally caused. `.150` is +> outside any device documented in the HA notes; move it if your DHCP pool +> overlaps. + +## Toolchain + +PlatformIO (Arduino framework), pinned in a project-local `.venv` so nothing +is installed globally. + +```bash +make # show targets +make build # compile +make upload # compile + flash (PORT=/dev/ttyUSB0 by default) +make monitor # serial console at 115200 +make detect # identify the chip on the port +``` + +A fresh checkout self-bootstraps the venv on first `make`. + +## Configuration + +Copy `include/secrets.example.h` → `include/secrets.h` (git-ignored) and fill +in WiFi + MQTT details. Needed from increment 2 onward. + +## Increment roadmap + +- [x] **1 — toolchain check.** Blink + serial heartbeat; confirms build/upload/monitor. +- [x] **2 — WiFi + MQTT + HA discovery.** Joins WiFi, connects to broker with an + LWT availability topic, publishes diagnostic entities (uptime, RSSI, IP). +- [x] **3 — Seed telemetry (read-only).** UART2 → Koheron `lason`/`ilaser`/`vlaser`/ + `rtset`/`rtact`/`itec`/`vtec`/`tboard`/`iphd`/`err`; published under `seed/...` + as HA sensors. Verified live against CTL200 fw v0.21 (echo + value + `>>` + framing, parsed correctly). +- [x] **4 — Amp telemetry (read-only).** UART1 → Agiltron `get_status` (binary + 0x55AA, ported from `edfa.py`); publishes module/pre-amp temp, pre-amp & + pump-1 currents, input/pre-amp/output dBm, warnings under `amp/...`. + Verified against the live unit (SN Y4022708, PN EDFA-1C51113331111, + device ID 0x0000006F). `amp/pump_enabled` reads the authoritative + `get_settings` field. +- [x] **5 — control.** `set/...` for seed (`lason`, `ilaser`) and amp (pump + on/off, pump-1 ACC current) as HA switch/number entities, with firmware + current clamps. Verified end-to-end: MQTT command → seed lasing → booster + ramp to 27 dBm. (Future: seed `rtset`/`tecon`, amp APC power, Koheron `save`.) + +## MQTT interface + +State topics retained under `lab/laser/butterfly_edfa_rack/`: + +| Topic suffix | Meaning | +|--------------|---------| +| `availability` | `online` / `offline` (LWT) | +| `diag/{uptime,rssi,ip}` | ESP / network diagnostics | +| `seed/link` | Koheron reachable? | +| `seed/laser_enabled` | seed laser on (1/0) | +| `seed/current_ma`, `seed/voltage_v` | seed drive | +| `seed/rt_setpoint_ohm`, `seed/rt_actual_ohm` | thermistor setpoint / actual | +| `seed/tec_current_a`, `seed/tec_voltage_v`, `seed/board_temp_c` | seed TEC / board | +| `seed/photodiode_ma`, `seed/error` | seed monitor PD, CTL200 error code | +| `amp/link` | Agiltron reachable? | +| `amp/module_temp_c`, `amp/preamp_temp_c` | EDFA temperatures | +| `amp/preamp_current_ma`, `amp/preamp_power_dbm` | pre-amp stage | +| `amp/pump1_current_ma`, `amp/pump1_set_ma` | booster pump actual / setpoint | +| `amp/input_power_dbm`, `amp/output1_power_dbm`, `amp/output2_power_dbm` | optical powers | +| `amp/pump_enabled`, `amp/warnings` | booster state, decoded warnings | + +Command topics (publish to `lab/laser/butterfly_edfa_rack/set/`): + +| Topic suffix | Payload | Action | +|--------------|---------|--------| +| `seed/laser` | `1` / `0` | Koheron `lason` | +| `seed/current_ma` | mA | Koheron `ilaser` (clamped ≤ `SEED_CURRENT_MAX_MA`) | +| `amp/pump` | `1` / `0` | Agiltron pump on / off | +| `amp/pump1_current_ma` | mA | set ACC mode + pump-1 current (clamped ≤ `PUMP_CURRENT_MAX_MA`) | diff --git a/include/config.h b/include/config.h new file mode 100644 index 0000000..9bed98d --- /dev/null +++ b/include/config.h @@ -0,0 +1,65 @@ +// Compile-time configuration for the Butterfly EDFA Rack bridge. +#pragma once +#include "secrets.h" + +// ── MQTT topics ───────────────────────────────────────────────────── +// One HA device, one topic base. Sub-units namespaced under it: +// /availability online | offline (LWT) +// /diag/ ESP/network diagnostics +// /seed/ Koheron CTL200 (increment 3) +// /amp/ Agiltron M511 EYDFA (increment 4) +// /set/<...> command topics (increment 5) +#define TOPIC_BASE "lab/laser/" DEVICE_NAME +#define TOPIC_AVAIL TOPIC_BASE "/availability" +#define DISCOVERY_PREFIX "homeassistant" + +#define AVAIL_ONLINE "online" +#define AVAIL_OFFLINE "offline" + +#define TOPIC_CMD TOPIC_BASE "/set" // subscribe /set/# + +// ── Agiltron M511 identity & safety limits ────────────────────────── +// SN Y4022708 / PN EDFA-1C51113331111 -> device ID 0x0000006F (edfa.py). +// Startup reads SN/PN and logs them so the ID is confirmed before control. +#define AGILTRON_DEV_ID 0x00, 0x00, 0x00, 0x6F + +// HA number sliders are capped here. The hardware allows 8000 mA pump, but +// the lab notes report ~2500 mA pump-1 -> ~27 dBm (~515 mW). Keep a margin. +#define SEED_CURRENT_MAX_MA 300 // CTL200 ilmax default is 1000; seed DFB is low-power +#define PUMP_CURRENT_MAX_MA 3000 // firmware clamps amp pump-1 to this + +// ── Serial links to the instruments (wired in later increments) ───── +// UART0 stays the USB console. The classic ESP32 has 3 hardware UARTs. +// +// Koheron CTL200 (ASCII, 115200 8N1, "\r\n", ">>" prompt) -> UART2 +// ESP32 GPIO17 (TX2) -> CTL200 RX, GPIO16 (RX2) <- CTL200 TX, GND +// +// Agiltron M511 (binary 0x55AA packets, 115200 8N1) -> UART1 +// ESP32 GPIO26 (TX1) -> M511 RX(pin3), GPIO25 (RX1) <- M511 TX(pin7), GND +// (UART1 default pins clash with SPI flash on WROOM, so remap to 26/25.) +#define KOHERON_UART_NUM 2 +#define KOHERON_TX_PIN 17 +#define KOHERON_RX_PIN 16 +#define KOHERON_BAUD 115200 + +#define AGILTRON_UART_NUM 1 +#define AGILTRON_TX_PIN 26 +#define AGILTRON_RX_PIN 25 +#define AGILTRON_BAUD 115200 + +// ── Static IP ─────────────────────────────────────────────────────── +// Pinned to .150 (free as of 2026-06-17) to avoid the DHCP clash with the +// BKtel EDFA Pi on .101. Comment out USE_STATIC_IP to fall back to DHCP. +#define USE_STATIC_IP +#define STATIC_IP_OCTETS 192, 168, 0, 150 +#define GATEWAY_OCTETS 192, 168, 0, 1 +#define SUBNET_OCTETS 255, 255, 255, 0 +#define DNS_OCTETS 192, 168, 0, 1 + +// ── Timing ────────────────────────────────────────────────────────── +#define PUBLISH_INTERVAL_MS 2000 // telemetry cadence +#define MQTT_RECONNECT_MS 5000 + +// PubSubClient's default 256-byte buffer is too small for discovery +// payloads that embed the device block; bump it. +#define MQTT_BUFFER_SIZE 1024 diff --git a/include/secrets.example.h b/include/secrets.example.h new file mode 100644 index 0000000..67ab259 --- /dev/null +++ b/include/secrets.example.h @@ -0,0 +1,15 @@ +// Copy to secrets.h and fill in. secrets.h is git-ignored. +#pragma once + +#define WIFI_SSID "your-ssid" +#define WIFI_PASSWORD "your-password" + +#define MQTT_BROKER "192.168.0.102" // HA / mosquitto host +#define MQTT_PORT 1883 +// Broker allows anonymous; leave user/pass empty unless that changes. +#define MQTT_USER "" +#define MQTT_PASSWORD "" + +// Logical unit slug -> topics live under lab/laser//... +#define DEVICE_NAME "butterfly_edfa_rack" +#define DEVICE_FRIENDLY "Butterfly EDFA Rack" diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..e01fdf1 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,25 @@ +; PlatformIO project for the Koheron CTL200 -> WiFi/MQTT bridge on ESP32. +; Board: ESP32-D0WD-V3 (WROOM-32 class) on a CP2102N USB bridge. + +[env:esp32dev] +platform = espressif32 +board = esp32dev +framework = arduino + +; CP2102N enumerates as /dev/ttyUSB0 on this PC. Override at the command line +; with `pio run -t upload --upload-port /dev/ttyXXX` if it moves. +upload_port = /dev/ttyUSB0 +monitor_port = /dev/ttyUSB0 + +; The ESP32's own USB console. The Koheron link is a *separate* hardware UART +; (see src/main.cpp) so the two never collide. +monitor_speed = 115200 +upload_speed = 460800 + +; MQTT client. PubSubClient is small and battle-tested. +lib_deps = + knolleary/PubSubClient@^2.8 + +; Keep WiFi/MQTT credentials out of source control: see include/secrets.h. +build_flags = + -DCORE_DEBUG_LEVEL=3 diff --git a/src/agiltron.cpp b/src/agiltron.cpp new file mode 100644 index 0000000..6026a5a --- /dev/null +++ b/src/agiltron.cpp @@ -0,0 +1,146 @@ +#include "agiltron.h" + +uint8_t Agiltron::checksum(const uint8_t *p, size_t n) { + uint32_t s = 0; + for (size_t i = 0; i < n; i++) s += p[i]; + return ((0xFF - (s & 0xFF)) + 0x01) & 0xFF; +} + +size_t Agiltron::buildPacket(uint8_t *out, uint8_t cmd, const uint8_t *data, uint8_t len) { + size_t i = 0; + out[i++] = 0x55; + out[i++] = 0xAA; + for (int k = 0; k < 4; k++) out[i++] = _devId[k]; + out[i++] = cmd; + out[i++] = len; + for (uint8_t k = 0; k < len; k++) out[i++] = data[k]; + // checksum covers everything from dev_id onward (i.e. skip the 0x55 0xAA) + out[i] = checksum(out + 2, i - 2); + i++; + return i; +} + +size_t Agiltron::transact(uint8_t cmd, const uint8_t *data, uint8_t len, + uint8_t *buf, size_t bufLen, uint32_t timeoutMs) { + uint8_t pkt[64]; + size_t n = buildPacket(pkt, cmd, data, len); + + while (_ser.available()) _ser.read(); // flush stale + _ser.write(pkt, n); + _ser.flush(); + + size_t got = 0; + uint32_t start = millis(), lastByte = start; + const uint32_t IDLE_GAP = 30; // binary reply is short and prompt + while (millis() - start < timeoutMs) { + while (_ser.available() && got < bufLen) { + buf[got++] = (uint8_t)_ser.read(); + lastByte = millis(); + } + if (got && (millis() - lastByte) > IDLE_GAP) break; + delay(2); + } + return got; +} + +bool Agiltron::validate(const uint8_t *buf, size_t n, uint8_t expectCmd) { + if (n < 8) return false; + if (buf[0] != 0xAA || buf[1] != 0x55) return false; + if (buf[6] != expectCmd) return false; + return true; +} + +AmpStatus Agiltron::getStatus() { + AmpStatus st; + uint8_t r[64]; + size_t n = transact(0x2F, nullptr, 0, r, sizeof(r)); + if (!validate(r, n, 0x2F) || n < 32) { _linkOk = false; return st; } + _linkOk = true; + + st.module_temp_c = u16(r[10], r[11]) / 10.0f; + st.preamp_temp_c = u16(r[12], r[13]) / 10.0f; + st.preamp_current_ma = u16(r[14], r[15]) / 10.0f; + st.tec_current_ma = u16(r[16], r[17]) / 10.0f; + st.pump1_current_ma = u16(r[18], r[19]); + st.pump2_current_ma = u16(r[20], r[21]); + st.input_power_dbm = s16(r[22], r[23]) / 100.0f; + st.preamp_power_dbm = s16(r[24], r[25]) / 100.0f; + st.output1_power_dbm = s16(r[26], r[27]) / 100.0f; + st.output2_power_dbm = s16(r[28], r[29]) / 100.0f; + st.warn_byte = r[31]; + st.valid = true; + return st; +} + +int Agiltron::getPumpEnabled() { + uint8_t r[64]; + size_t n = transact(0x2E, nullptr, 0, r, sizeof(r)); + if (!validate(r, n, 0x2E) || n < 10) return -1; + return (u16(r[8], r[9]) == 0) ? 1 : 0; // edfa.py: ==0 means enabled +} + +String Agiltron::getSn() { + uint8_t r[64]; + size_t n = transact(0x1F, nullptr, 0, r, sizeof(r)); + if (!validate(r, n, 0x1F)) return String(); + String s; + for (size_t i = 8; i + 1 < n; i++) { // r[8 .. n-2], drop checksum + char c = (char)r[i]; + if (c >= 32 && c < 127) s += c; + } + s.trim(); + return s; +} + +String Agiltron::getPn() { + uint8_t r[64]; + size_t n = transact(0x1E, nullptr, 0, r, sizeof(r)); + if (!validate(r, n, 0x1E)) return String(); + String s; + for (size_t i = 8; i + 1 < n; i++) { + char c = (char)r[i]; + if (c >= 32 && c < 127) s += c; + } + s.trim(); + return s; +} + +bool Agiltron::setPump1Current(int ma) { + if (ma < 0 || ma > MAX_PUMP_CURRENT_MA) return false; + uint8_t d[2] = {(uint8_t)((ma >> 8) & 0xFF), (uint8_t)(ma & 0xFF)}; + return cmdAck(0x23, d, 2); +} + +bool Agiltron::cmdAck(uint8_t cmd, const uint8_t *data, uint8_t len) { + uint8_t r[64]; + size_t n = transact(cmd, data, len, r, sizeof(r)); + bool ok = validate(r, n, cmd); + _linkOk = ok || _linkOk; + return ok; +} + +String Agiltron::warningsString(uint8_t w) { + String out; + auto add = [&](const char *m) { if (out.length()) out += ", "; out += m; }; + // bit set (=1) means warning + if (w & (1 << 7)) add("Overall warning"); + if (w & (1 << 3)) add("Pump current"); + if (w & (1 << 2)) add("Device temperature"); + if (w & (1 << 1)) add("Input LOS"); + if (w & (1 << 0)) add("Output LOS"); + // bit clear (=0) means warning + if (!(w & (1 << 6))) add("Pump is OFF"); + if (!(w & (1 << 5))) add("TEC current"); + if (!(w & (1 << 4))) add("Pump temperature"); + if (out.length() == 0) out = "none"; + return out; +} + +void Agiltron::rawProbe(uint8_t cmd, const uint8_t *data, uint8_t len) { + uint8_t r[64]; + size_t n = transact(cmd, data, len, r, sizeof(r)); + Serial.printf("[agiltron] probe cmd 0x%02x -> %u bytes:\n", cmd, (unsigned)n); + Serial.print(" hex:"); + for (size_t i = 0; i < n; i++) Serial.printf(" %02x", r[i]); + Serial.println(); +} diff --git a/src/agiltron.h b/src/agiltron.h new file mode 100644 index 0000000..b955a04 --- /dev/null +++ b/src/agiltron.h @@ -0,0 +1,83 @@ +// Agiltron M511 EYDFA driver --- binary 0x55AA packet protocol. +// +// Ported from the lab's edfa.py (Agiltron EDFA/edfa.py). Packet layout: +// 0x55 0xAA | dev_id[4] | cmd | len | data[len] | checksum +// checksum = ((0xFF - (sum(bytes from dev_id..end of data) & 0xFF)) + 1) & 0xFF +// Reply starts 0xAA 0x55, with the command id echoed at byte[6]. 115200 8N1. +// +// Scalings (from edfa.py, corrected against the docs): +// optical power = signed int16 / 100 (dBm) +// pump currents = raw mA +// temps, preamp/TEC currents = / 10 +#pragma once +#include + +struct AmpStatus { + bool valid = false; + float module_temp_c; + float preamp_temp_c; + float preamp_current_ma; + float tec_current_ma; + int pump1_current_ma; + int pump2_current_ma; + float input_power_dbm; + float preamp_power_dbm; + float output1_power_dbm; + float output2_power_dbm; + uint8_t warn_byte; +}; + +class Agiltron { +public: + static const uint8_t ACC = 0x01; // constant current + static const uint8_t APC = 0x00; // constant power + static const int MAX_PUMP_CURRENT_MA = 8000; + + Agiltron(HardwareSerial &serial, int8_t rxPin, int8_t txPin, uint32_t baud, + const uint8_t devId[4]) + : _ser(serial), _rx(rxPin), _tx(txPin), _baud(baud) { + memcpy(_devId, devId, 4); + } + + void begin() { _ser.begin(_baud, SERIAL_8N1, _rx, _tx); } + + bool linkOk() const { return _linkOk; } + + // Queries + AmpStatus getStatus(); + String getSn(); + String getPn(); + // Authoritative pump-enable from get_settings (0x2E): byte[8:9]==0 => enabled. + // Returns -1 on no/invalid reply, 0 = disabled, 1 = enabled. + int getPumpEnabled(); + + // Control (return true if the device ACKed) + bool pumpOn() { return cmdAck(0x20, (const uint8_t[]){0x00,0x00}, 2); } + bool pumpOff() { return cmdAck(0x20, (const uint8_t[]){0x00,0x01}, 2); } + bool setPump1Mode(uint8_t mode) { uint8_t d[2]={0x00,mode}; return cmdAck(0x21,d,2); } + bool setPump1Current(int ma); + + // Decode the warning byte into a comma-separated list (matches edfa.py). + static String warningsString(uint8_t warnByte); + + // Dump raw reply bytes for a command to the USB console (first-wiring aid). + void rawProbe(uint8_t cmd, const uint8_t *data, uint8_t len); + +private: + HardwareSerial &_ser; + int8_t _rx, _tx; + uint32_t _baud; + uint8_t _devId[4]; + bool _linkOk = false; + + uint8_t checksum(const uint8_t *p, size_t n); + size_t buildPacket(uint8_t *out, uint8_t cmd, const uint8_t *data, uint8_t len); + // Send cmd, read reply into buf (max bufLen). Returns bytes read. + size_t transact(uint8_t cmd, const uint8_t *data, uint8_t len, + uint8_t *buf, size_t bufLen, uint32_t timeoutMs = 300); + bool validate(const uint8_t *buf, size_t n, uint8_t expectCmd); + bool cmdAck(uint8_t cmd, const uint8_t *data, uint8_t len); + + static uint16_t u16(uint8_t msb, uint8_t lsb) { return (uint16_t)msb * 256 + lsb; } + static int16_t s16(uint8_t msb, uint8_t lsb) { return (int16_t)((msb << 8) | lsb); } +}; diff --git a/src/koheron.cpp b/src/koheron.cpp new file mode 100644 index 0000000..d8fe90b --- /dev/null +++ b/src/koheron.cpp @@ -0,0 +1,65 @@ +#include "koheron.h" + +// Read everything the device sends until there's an ~80 ms idle gap (end of +// reply) or the overall timeout elapses. +String Koheron::readRaw(uint32_t timeoutMs) { + String buf; + uint32_t start = millis(); + uint32_t lastByte = start; + const uint32_t IDLE_GAP = 80; + while (millis() - start < timeoutMs) { + while (_ser.available()) { + buf += (char)_ser.read(); + lastByte = millis(); + } + if (buf.length() && (millis() - lastByte) > IDLE_GAP) break; + delay(2); + } + return buf; +} + +String Koheron::query(const char *cmd, uint32_t timeoutMs) { + while (_ser.available()) _ser.read(); // flush stale input + _ser.print(cmd); + _ser.print("\r\n"); + + String raw = readRaw(timeoutMs); + if (raw.length() == 0) { + _linkOk = false; + return String(); + } + _linkOk = true; + + // Split into lines; drop ">>" prompts, the echoed command, and blanks. + // Return the last surviving line (the value). + String value; + int start = 0; + while (start < (int)raw.length()) { + int nl = raw.indexOf('\n', start); + String line = (nl < 0) ? raw.substring(start) : raw.substring(start, nl); + line.replace(">", ""); // strip prompt chars + line.trim(); // drop \r and surrounding whitespace + if (line.length() && line != cmd) value = line; + if (nl < 0) break; + start = nl + 1; + } + return value; +} + +void Koheron::rawProbe(const char *cmd, uint32_t timeoutMs) { + while (_ser.available()) _ser.read(); + _ser.print(cmd); + _ser.print("\r\n"); + String raw = readRaw(timeoutMs); + Serial.printf("[koheron] probe \"%s\" -> %u bytes:\n", cmd, raw.length()); + Serial.print(" hex:"); + for (size_t i = 0; i < raw.length(); i++) Serial.printf(" %02x", (uint8_t)raw[i]); + Serial.println(); + Serial.print(" asc: "); + for (size_t i = 0; i < raw.length(); i++) { + char c = raw[i]; + Serial.print((c >= 32 && c < 127) ? c : '.'); + } + Serial.println(); + Serial.printf(" parsed value: \"%s\"\n", query(cmd, timeoutMs).c_str()); +} diff --git a/src/koheron.h b/src/koheron.h new file mode 100644 index 0000000..0f5fa9d --- /dev/null +++ b/src/koheron.h @@ -0,0 +1,33 @@ +// Koheron CTL200 driver --- ASCII serial protocol. +// +// The CTL200 is a line-oriented terminal: send "\r\n", it replies with +// the value (and may echo the command and a ">>" prompt). We strip the echo +// and prompt and return the value. 115200 8N1. +#pragma once +#include + +class Koheron { +public: + Koheron(HardwareSerial &serial, int8_t rxPin, int8_t txPin, uint32_t baud) + : _ser(serial), _rx(rxPin), _tx(txPin), _baud(baud) {} + + void begin() { _ser.begin(_baud, SERIAL_8N1, _rx, _tx); } + + // Send a command, return the cleaned value line (echo + ">>" removed). + // Empty string means no/garbled response. + String query(const char *cmd, uint32_t timeoutMs = 250); + + // Dump the raw bytes of a reply (hex + ascii) to the USB console, so the + // exact framing (echo? prompt placement?) can be confirmed on first wiring. + void rawProbe(const char *cmd, uint32_t timeoutMs = 500); + + bool linkOk() const { return _linkOk; } + +private: + HardwareSerial &_ser; + int8_t _rx, _tx; + uint32_t _baud; + bool _linkOk = false; + + String readRaw(uint32_t timeoutMs); // read until idle gap or timeout +}; diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..0f7baf8 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,330 @@ +// Koheron CTL200 + Agiltron M511 -> WiFi/MQTT bridge --- ESP32 firmware. +// +// INCREMENT 3: seed (Koheron CTL200) read-only telemetry on UART2, published +// under lab/laser/butterfly_edfa_rack/seed/... with HA discovery. Builds on +// increment 2 (WiFi + MQTT + diagnostics). Amp (Agiltron) and control come +// in increments 4-5. +// +// On boot the firmware raw-probes the Koheron `version` command and dumps the +// reply (hex+ascii) to the USB console, so the exact framing can be confirmed +// the first time the CTL200 is wired to GPIO16/17. See include/config.h. + +#include +#include +#include +#include "config.h" +#include "koheron.h" +#include "agiltron.h" + +static WiFiClient net; +static PubSubClient mqtt(net); +static Koheron koheron(Serial2, KOHERON_RX_PIN, KOHERON_TX_PIN, KOHERON_BAUD); +static const uint8_t kAmpDevId[4] = {AGILTRON_DEV_ID}; +static Agiltron agiltron(Serial1, AGILTRON_RX_PIN, AGILTRON_TX_PIN, AGILTRON_BAUD, kAmpDevId); + +// Probe each instrument link once per boot to reveal its framing. +#define KOHERON_RAW_PROBE 1 +#define AGILTRON_PROBE 1 + +// ── HA discovery helpers ──────────────────────────────────────────── + +static String deviceBlock() { + return String("\"device\":{") + + "\"identifiers\":[\"" DEVICE_NAME "\"]," + + "\"name\":\"" DEVICE_FRIENDLY "\"," + + "\"manufacturer\":\"Koheron + Agiltron\"," + + "\"model\":\"CTL200 seed + M511 EYDFA\"," + + "\"sw_version\":\"" __DATE__ " " __TIME__ "\"}"; +} + +// Common availability + device tail shared by every discovery payload. +static String commonTail(const char *objectId) { + return String("\"availability_topic\":\"" TOPIC_AVAIL "\",") + + "\"payload_available\":\"" AVAIL_ONLINE "\"," + + "\"payload_not_available\":\"" AVAIL_OFFLINE "\"," + + "\"unique_id\":\"" DEVICE_NAME "_" + objectId + "\"," + + deviceBlock() + "}"; +} + +static void publishConfig(const char *component, const char *objectId, const String &payload) { + String topic = String(DISCOVERY_PREFIX) + "/" + component + "/" + DEVICE_NAME + "/" + objectId + "/config"; + if (!mqtt.publish(topic.c_str(), (const uint8_t *)payload.c_str(), payload.length(), true)) + Serial.printf(" ! discovery publish failed: %s (len %u)\n", objectId, payload.length()); +} + +// A numeric/text sensor. stateSub is the topic suffix under TOPIC_BASE. +static void sensorDiscovery(const char *objectId, const char *name, const char *stateSub, + const char *unit, const char *devClass, const char *icon, + bool diagnostic) { + String p = String("{\"name\":\"") + name + "\"," + + "\"state_topic\":\"" TOPIC_BASE "/" + stateSub + "\","; + if (unit && *unit) p += String("\"unit_of_measurement\":\"") + unit + "\","; + if (devClass && *devClass) p += String("\"device_class\":\"") + devClass + "\","; + if (icon && *icon) p += String("\"icon\":\"") + icon + "\","; + if (diagnostic) p += "\"entity_category\":\"diagnostic\","; + p += commonTail(objectId); + publishConfig("sensor", objectId, p); +} + +static void binaryDiscovery(const char *objectId, const char *name, const char *stateSub, + const char *payloadOn, const char *payloadOff, + const char *devClass, bool diagnostic) { + String p = String("{\"name\":\"") + name + "\"," + + "\"state_topic\":\"" TOPIC_BASE "/" + stateSub + "\"," + + "\"payload_on\":\"" + payloadOn + "\"," + + "\"payload_off\":\"" + payloadOff + "\","; + if (devClass && *devClass) p += String("\"device_class\":\"") + devClass + "\","; + if (diagnostic) p += "\"entity_category\":\"diagnostic\","; + p += commonTail(objectId); + publishConfig("binary_sensor", objectId, p); +} + +// A switch with state + command topics. +static void switchDiscovery(const char *objectId, const char *name, const char *stateSub, + const char *cmdSub, const char *payloadOn, const char *payloadOff, + const char *icon) { + String p = String("{\"name\":\"") + name + "\"," + + "\"state_topic\":\"" TOPIC_BASE "/" + stateSub + "\"," + + "\"command_topic\":\"" TOPIC_CMD "/" + cmdSub + "\"," + + "\"payload_on\":\"" + payloadOn + "\",\"payload_off\":\"" + payloadOff + "\"," + + "\"state_on\":\"" + payloadOn + "\",\"state_off\":\"" + payloadOff + "\","; + if (icon && *icon) p += String("\"icon\":\"") + icon + "\","; + p += commonTail(objectId); + publishConfig("switch", objectId, p); +} + +// A number (slider) with state + command topics. +static void numberDiscovery(const char *objectId, const char *name, const char *stateSub, + const char *cmdSub, float vmin, float vmax, float step, + const char *unit, const char *icon) { + String p = String("{\"name\":\"") + name + "\"," + + "\"state_topic\":\"" TOPIC_BASE "/" + stateSub + "\"," + + "\"command_topic\":\"" TOPIC_CMD "/" + cmdSub + "\"," + + "\"min\":" + String(vmin, 0) + ",\"max\":" + String(vmax, 0) + + ",\"step\":" + String(step, 0) + ",\"mode\":\"box\","; + if (unit && *unit) p += String("\"unit_of_measurement\":\"") + unit + "\","; + if (icon && *icon) p += String("\"icon\":\"") + icon + "\","; + p += commonTail(objectId); + publishConfig("number", objectId, p); +} + +static void publishDiscovery() { + Serial.println("Publishing HA discovery..."); + // Diagnostics (ESP / network) + sensorDiscovery("uptime", "Uptime", "diag/uptime", "s", "duration", nullptr, true); + sensorDiscovery("rssi", "WiFi Signal", "diag/rssi", "dBm", "signal_strength", nullptr, true); + sensorDiscovery("ip", "IP Address", "diag/ip", nullptr, nullptr, "mdi:ip-network", true); + + // Seed (Koheron CTL200) + binaryDiscovery("seed_link", "Seed Link", "seed/link", AVAIL_ONLINE, AVAIL_OFFLINE, "connectivity", true); + binaryDiscovery("seed_laser_enabled", "Seed Laser Enabled", "seed/laser_enabled", "1", "0", nullptr, false); + sensorDiscovery("seed_current", "Seed Current", "seed/current_ma", "mA", "current", "mdi:current-dc", false); + sensorDiscovery("seed_voltage", "Seed Voltage", "seed/voltage_v", "V", "voltage", nullptr, false); + sensorDiscovery("seed_rt_setpoint", "Seed Thermistor Setpoint", "seed/rt_setpoint_ohm", "Ω", nullptr, "mdi:resistor", false); + sensorDiscovery("seed_rt_actual", "Seed Thermistor", "seed/rt_actual_ohm", "Ω", nullptr, "mdi:resistor", false); + sensorDiscovery("seed_tec_current", "Seed TEC Current", "seed/tec_current_a", "A", "current", nullptr, false); + sensorDiscovery("seed_tec_voltage", "Seed TEC Voltage", "seed/tec_voltage_v", "V", "voltage", nullptr, false); + sensorDiscovery("seed_board_temp", "Seed Board Temperature", "seed/board_temp_c", "°C", "temperature", nullptr, false); + sensorDiscovery("seed_photodiode", "Seed Photodiode Current", "seed/photodiode_ma", "mA", nullptr, "mdi:current-dc", false); + sensorDiscovery("seed_error", "Seed Error Code", "seed/error", nullptr, nullptr, "mdi:alert-circle", true); + + // Seed controls + switchDiscovery("seed_laser", "Seed Laser", "seed/laser_enabled", "seed/laser", "1", "0", "mdi:laser-pointer"); + numberDiscovery("seed_set_current", "Seed Set Current", "seed/current_ma", "seed/current_ma", + 0, SEED_CURRENT_MAX_MA, 1, "mA", "mdi:current-dc"); + + // Amp (Agiltron M511) + binaryDiscovery("amp_link", "Amp Link", "amp/link", AVAIL_ONLINE, AVAIL_OFFLINE, "connectivity", true); + sensorDiscovery("amp_module_temp", "Amp Module Temperature", "amp/module_temp_c", "°C", "temperature", nullptr, false); + sensorDiscovery("amp_preamp_temp", "Amp Pre-amp Temperature", "amp/preamp_temp_c", "°C", "temperature", nullptr, true); + sensorDiscovery("amp_preamp_current", "Amp Pre-amp Current", "amp/preamp_current_ma", "mA", "current", nullptr, true); + sensorDiscovery("amp_tec_current", "Amp TEC Current", "amp/tec_current_ma", "mA", "current", nullptr, true); + sensorDiscovery("amp_pump1_current", "Amp Pump-1 Current", "amp/pump1_current_ma", "mA", "current", "mdi:current-dc", false); + sensorDiscovery("amp_input_power", "Amp Input Power", "amp/input_power_dbm", "dBm", nullptr, "mdi:arrow-right-bold", false); + sensorDiscovery("amp_preamp_power", "Amp Pre-amp Power", "amp/preamp_power_dbm", "dBm", nullptr, "mdi:arrow-right-bold", true); + sensorDiscovery("amp_output_power", "Output Power", "amp/output1_power_dbm", "dBm", nullptr, "mdi:arrow-left-bold", false); + sensorDiscovery("amp_warnings", "Amp Warnings", "amp/warnings", nullptr, nullptr, "mdi:alert", true); + + // Amp controls + switchDiscovery("amp_pump", "Amp Pump", "amp/pump_enabled", "amp/pump", "1", "0", "mdi:flash"); + numberDiscovery("amp_set_pump_current", "Amp Pump Current", "amp/pump1_set_ma", "amp/pump1_current_ma", + 0, PUMP_CURRENT_MAX_MA, 10, "mA", "mdi:current-dc"); +} + +// ── State publishing ──────────────────────────────────────────────── + +static void publishDiag() { + String base = String(TOPIC_BASE) + "/diag/"; + mqtt.publish((base + "uptime").c_str(), String(millis() / 1000).c_str(), true); + mqtt.publish((base + "rssi").c_str(), String(WiFi.RSSI()).c_str(), true); + mqtt.publish((base + "ip").c_str(), WiFi.localIP().toString().c_str(), true); +} + +// Each seed value is the raw string the CTL200 returns (already numeric text). +struct SeedPoint { const char *cmd; const char *sub; }; +static const SeedPoint seedPoints[] = { + {"lason", "seed/laser_enabled"}, + {"ilaser", "seed/current_ma"}, + {"vlaser", "seed/voltage_v"}, + {"rtset", "seed/rt_setpoint_ohm"}, + {"rtact", "seed/rt_actual_ohm"}, + {"itec", "seed/tec_current_a"}, + {"vtec", "seed/tec_voltage_v"}, + {"tboard", "seed/board_temp_c"}, + {"iphd", "seed/photodiode_ma"}, + {"err", "seed/error"}, +}; + +static void publishSeed() { + bool any = false; + for (const auto &pt : seedPoints) { + String v = koheron.query(pt.cmd); + if (v.length()) { + mqtt.publish((String(TOPIC_BASE) + "/" + pt.sub).c_str(), v.c_str(), true); + any = true; + } + mqtt.loop(); // keep MQTT serviced between (potentially slow) UART round-trips + } + mqtt.publish(TOPIC_BASE "/seed/link", any ? AVAIL_ONLINE : AVAIL_OFFLINE, true); +} + +static void pub(const char *sub, const String &v) { + mqtt.publish((String(TOPIC_BASE) + "/" + sub).c_str(), v.c_str(), true); +} + +static void publishAmp() { + AmpStatus s = agiltron.getStatus(); + mqtt.publish(TOPIC_BASE "/amp/link", s.valid ? AVAIL_ONLINE : AVAIL_OFFLINE, true); + if (!s.valid) return; + pub("amp/module_temp_c", String(s.module_temp_c, 1)); + pub("amp/preamp_temp_c", String(s.preamp_temp_c, 1)); + pub("amp/preamp_current_ma", String(s.preamp_current_ma, 1)); + pub("amp/tec_current_ma", String(s.tec_current_ma, 1)); + pub("amp/pump1_current_ma", String(s.pump1_current_ma)); + pub("amp/input_power_dbm", String(s.input_power_dbm, 2)); + pub("amp/preamp_power_dbm", String(s.preamp_power_dbm, 2)); + pub("amp/output1_power_dbm", String(s.output1_power_dbm, 2)); + pub("amp/output2_power_dbm", String(s.output2_power_dbm, 2)); + pub("amp/warnings", Agiltron::warningsString(s.warn_byte)); + int pe = agiltron.getPumpEnabled(); // authoritative, from get_settings + if (pe >= 0) pub("amp/pump_enabled", pe ? "1" : "0"); +} + +// ── Command handling ──────────────────────────────────────────────── + +static void onMqtt(char *topic, byte *payload, unsigned int len) { + String t(topic), p; + for (unsigned int i = 0; i < len; i++) p += (char)payload[i]; + p.trim(); + Serial.printf("[cmd] %s = %s\n", topic, p.c_str()); + + if (t == TOPIC_CMD "/seed/laser") { + bool on = (p == "1" || p.equalsIgnoreCase("on") || p.equalsIgnoreCase(AVAIL_ONLINE)); + koheron.query(on ? "lason 1" : "lason 0"); + pub("seed/laser_enabled", on ? "1" : "0"); + + } else if (t == TOPIC_CMD "/seed/current_ma") { + float ma = p.toFloat(); + if (ma < 0) ma = 0; + if (ma > SEED_CURRENT_MAX_MA) ma = SEED_CURRENT_MAX_MA; // firmware clamp + koheron.query((String("ilaser ") + String(ma, 1)).c_str()); + pub("seed/current_ma", String(ma, 3)); + + } else if (t == TOPIC_CMD "/amp/pump") { + bool on = (p == "1" || p.equalsIgnoreCase("on") || p.equalsIgnoreCase(AVAIL_ONLINE)); + bool ok = on ? agiltron.pumpOn() : agiltron.pumpOff(); + Serial.printf("[cmd] amp pump %s -> %s\n", on ? "ON" : "OFF", ok ? "ack" : "NO-ACK"); + pub("amp/pump_enabled", on ? "1" : "0"); + + } else if (t == TOPIC_CMD "/amp/pump1_current_ma") { + int ma = (int)p.toFloat(); + if (ma < 0) ma = 0; + if (ma > PUMP_CURRENT_MAX_MA) ma = PUMP_CURRENT_MAX_MA; // firmware clamp + agiltron.setPump1Mode(Agiltron::ACC); // current-control mode (lab note: avoid APC steps) + bool ok = agiltron.setPump1Current(ma); + Serial.printf("[cmd] amp pump1 = %d mA -> %s\n", ma, ok ? "ack" : "NO-ACK"); + pub("amp/pump1_set_ma", String(ma)); + } +} + +// ── Connectivity ──────────────────────────────────────────────────── + +static void connectWiFi() { + Serial.printf("WiFi: connecting to \"%s\"", WIFI_SSID); + WiFi.mode(WIFI_STA); + WiFi.setHostname(DEVICE_NAME); +#ifdef USE_STATIC_IP + IPAddress ip(STATIC_IP_OCTETS), gw(GATEWAY_OCTETS), sn(SUBNET_OCTETS), dns(DNS_OCTETS); + if (!WiFi.config(ip, gw, sn, dns)) Serial.print(" [static cfg failed]"); +#endif + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); + while (WiFi.status() != WL_CONNECTED) { delay(400); Serial.print("."); } + Serial.printf(" ok, IP %s, RSSI %d dBm\n", WiFi.localIP().toString().c_str(), WiFi.RSSI()); +} + +static bool connectMQTT() { + Serial.printf("MQTT: connecting to %s:%d ... ", MQTT_BROKER, MQTT_PORT); + bool ok = mqtt.connect(DEVICE_NAME, MQTT_USER, MQTT_PASSWORD, + TOPIC_AVAIL, 0, true, AVAIL_OFFLINE); + if (ok) { + Serial.println("ok"); + mqtt.publish(TOPIC_AVAIL, AVAIL_ONLINE, true); + mqtt.subscribe(TOPIC_CMD "/#"); + publishDiscovery(); + publishDiag(); + } else { + Serial.printf("failed, rc=%d\n", mqtt.state()); + } + return ok; +} + +// ── Arduino entry points ──────────────────────────────────────────── + +void setup() { + Serial.begin(115200); + delay(200); + Serial.println("\n=== Butterfly EDFA Rack bridge — increment 3 (seed telemetry) ==="); + Serial.printf("Device: %s (%s)\n", DEVICE_FRIENDLY, DEVICE_NAME); + + koheron.begin(); + agiltron.begin(); +#if KOHERON_RAW_PROBE + Serial.printf("Seed probe on UART%d (RX=%d, TX=%d):\n", + KOHERON_UART_NUM, KOHERON_RX_PIN, KOHERON_TX_PIN); + koheron.rawProbe("version"); + if (!koheron.linkOk()) + Serial.println(" (no reply — Koheron not wired yet? RX/TX swapped? GND?)"); +#endif +#if AGILTRON_PROBE + Serial.printf("Amp probe on UART%d (RX=%d, TX=%d):\n", + AGILTRON_UART_NUM, AGILTRON_RX_PIN, AGILTRON_TX_PIN); + String sn = agiltron.getSn(), pn = agiltron.getPn(); + Serial.printf(" SN=\"%s\" PN=\"%s\"\n", sn.c_str(), pn.c_str()); + if (sn.length() == 0) + Serial.println(" (no reply — Agiltron not wired? RX/TX swapped? wrong dev ID? GND?)"); +#endif + + connectWiFi(); + mqtt.setServer(MQTT_BROKER, MQTT_PORT); + mqtt.setBufferSize(MQTT_BUFFER_SIZE); + mqtt.setCallback(onMqtt); + connectMQTT(); +} + +void loop() { + if (WiFi.status() != WL_CONNECTED) connectWiFi(); + + if (!mqtt.connected()) { + static uint32_t lastTry = 0; + if (millis() - lastTry >= MQTT_RECONNECT_MS) { lastTry = millis(); connectMQTT(); } + } else { + mqtt.loop(); + static uint32_t lastPub = 0; + if (millis() - lastPub >= PUBLISH_INTERVAL_MS) { + lastPub = millis(); + publishDiag(); + publishSeed(); + publishAmp(); + } + } +}