49 Commits

Author SHA1 Message Date
1a8b47382c Discovery readded for every boot with different strategy 2026-04-13 21:59:54 +02:00
865e6c2bf8 _DEVICE unfied 2026-04-13 21:55:03 +02:00
297f0050e3 Message loops unified 2026-04-13 21:49:48 +02:00
7d4301d15b Step sequence optimized 2026-04-13 21:28:43 +02:00
93d9591624 Step sequence optimized 2026-04-13 21:24:50 +02:00
41cd7bd24f Backlight retention re-added 2026-04-13 21:15:31 +02:00
4b40f18fd3 Local routine for subscribe_all fixed, us/ms fixed 2026-04-13 21:09:59 +02:00
42d3193b75 Just fiddling with pointer smoothness and status report 2026-04-13 20:01:08 +02:00
701edd3477 Discovery problems 2026-04-13 02:11:41 +02:00
00d5ece4e1 Discovery problems 2026-04-13 02:09:18 +02:00
79964ed46a Discovery problems 2026-04-13 02:07:06 +02:00
bc8cb2c670 Discovery problems 2026-04-13 02:03:22 +02:00
b9c3659bdc Discovery problems 2026-04-13 01:59:47 +02:00
a4fc63530b Discovery problems 2026-04-13 01:56:30 +02:00
3ec4c841bb Discovery problems 2026-04-13 01:52:49 +02:00
2850eed870 Discovery problems 2026-04-13 01:51:15 +02:00
b54356b524 Discovery problems 2026-04-13 01:48:43 +02:00
2e9927d16e Discovery problems 2026-04-13 01:47:02 +02:00
d29470254b Discovery problems 2026-04-13 01:44:33 +02:00
009fbfcd84 Discovery problems 2026-04-13 01:38:19 +02:00
8f6f57a079 Discovery problems 2026-04-13 01:37:40 +02:00
80bacb4b39 Discovery problems 2026-04-13 01:27:15 +02:00
fe0432341e Removing troubleshooting artifacts 2026-04-13 01:12:49 +02:00
7f75022238 Removing troubleshooting artifacts 2026-04-13 01:06:43 +02:00
0925876242 Removing troubleshooting artifacts 2026-04-13 01:03:43 +02:00
638ec1de44 Removing troubleshooting artifacts 2026-04-13 01:02:28 +02:00
f5d5c916b4 Trying to fix HA 2026-04-13 00:52:33 +02:00
dcd8bfda4c Fix MQTT 2-gauge issue: publish discovery only on first boot via flag file, add resetdiscovery topic, remove disco, clean up DEBUG prints and duplicate code 2026-04-13 00:38:41 +02:00
7d42a9e8d9 WS2812 troubleshooting 2026-04-12 20:56:22 +02:00
5e36ed4d0e WS2812 troubleshooting 2026-04-12 20:53:35 +02:00
470fc49114 WS2812 troubleshooting 2026-04-12 20:52:03 +02:00
f02eff1e19 WS2812 troubleshooting 2026-04-12 20:51:00 +02:00
bc6249b02c WS2812 troubleshooting 2026-04-12 20:48:51 +02:00
cf493a1f26 WS2812 troubleshooting 2026-04-12 20:47:02 +02:00
4b096de052 WS2812 troubleshooting 2026-04-12 20:45:44 +02:00
7a5df131bd WS2812 troubleshooting 2026-04-12 20:43:38 +02:00
b7d2971f48 Revert "Last Will now per gauge"
This reverts commit 7fb3b5e1fd.
2026-04-12 17:55:56 +02:00
0bc41c14ef Referenced local variable before assignment 2026-04-12 17:54:46 +02:00
67d417ef0c Referenced local variable before assignment 2026-04-12 17:50:42 +02:00
7db5bd6688 Referenced local variable before assignment 2026-04-12 17:48:07 +02:00
b06d677a36 Referenced local variable before assignment 2026-04-12 17:28:34 +02:00
119dcff281 Referenced local variable before assignment 2026-04-12 17:25:26 +02:00
bd32879989 function d added 2026-04-12 17:18:15 +02:00
76c58833dc function d added 2026-04-12 17:13:15 +02:00
7fb3b5e1fd Last Will now per gauge 2026-04-12 01:32:23 +02:00
9c875a2bb8 Reverted, just fixed backlight flushing 2026-04-11 23:40:50 +02:00
db3798dc07 OTA now checks files by default instead of overwriting 2026-04-11 23:32:49 +02:00
25aa09298c Removed duplicate gauge initialisation routine from MQTT 2026-04-11 21:46:02 +02:00
fc12d92d8d Status LEDs on control rod number plate added 2026-04-11 21:40:08 +02:00
6 changed files with 340 additions and 187 deletions

BIN
3d_model/Box for one.stl Normal file

Binary file not shown.

BIN
3d_model/case_test.stl Normal file

Binary file not shown.

View File

@@ -20,7 +20,9 @@
"unit": "W", "unit": "W",
"leds": { "leds": {
"red_pin": 33, "red_pin": 33,
"green_pin": 32 "green_pin": 32,
"ws2812_red": [255, 0, 0],
"ws2812_green": [0, 255, 0]
} }
}, },
{ {
@@ -34,14 +36,17 @@
"unit": "C", "unit": "C",
"leds": { "leds": {
"red_pin": 21, "red_pin": 21,
"green_pin": 20 "green_pin": 20,
"ws2812_red": [255, 0, 0],
"ws2812_green": [0, 255, 0]
} }
} }
], ],
"backlight": { "backlight": {
"pin": 23, "pin": 23,
"num_leds_per_gauge": 3 "num_leds_per_gauge": 3,
"num_status_leds_per_gauge": 2
}, },
"device": { "device": {

View File

@@ -108,7 +108,7 @@ class Gauge:
else: else:
for _ in range(abs(delta)): for _ in range(abs(delta)):
self._step(1 if delta > 0 else -1) self._step(1 if delta > 0 else -1)
utime.sleep_ms(self._step_us) utime.sleep_us(self._step_us)
self._current_step = target_step self._current_step = target_step

View File

@@ -23,6 +23,7 @@ Serial log format: [HH:MM:SS.mmm] LEVEL message
import network import network
import utime import utime
import ujson import ujson
import urandom
from umqtt.robust import MQTTClient from umqtt.robust import MQTTClient
from machine import Pin from machine import Pin
from neopixel import NeoPixel from neopixel import NeoPixel
@@ -98,6 +99,7 @@ REZERO_INTERVAL_MS = int(_cfg.get("rezero_interval_ms", 3600000))
backlight_cfg = _cfg.get("backlight", {}) backlight_cfg = _cfg.get("backlight", {})
BACKLIGHT_PIN = int(backlight_cfg.get("pin", _cfg.get("led_bl_pin", 23))) BACKLIGHT_PIN = int(backlight_cfg.get("pin", _cfg.get("led_bl_pin", 23)))
BACKLIGHT_LEDS_PER_GAUGE = int(backlight_cfg.get("num_leds_per_gauge", 3)) BACKLIGHT_LEDS_PER_GAUGE = int(backlight_cfg.get("num_leds_per_gauge", 3))
STATUS_LEDS_PER_GAUGE = int(backlight_cfg.get("num_status_leds_per_gauge", 2))
device_cfg = _cfg.get("device", {}) device_cfg = _cfg.get("device", {})
DEVICE_NAME = device_cfg.get("name", _cfg.get("device_name", "Selsyn Multi")) DEVICE_NAME = device_cfg.get("name", _cfg.get("device_name", "Selsyn Multi"))
@@ -126,6 +128,8 @@ if "gauges" in _cfg:
"unit": g.get("unit", ""), "unit": g.get("unit", ""),
"red_pin": int(led_cfg.get("red_pin", 33)), "red_pin": int(led_cfg.get("red_pin", 33)),
"green_pin": int(led_cfg.get("green_pin", 32)), "green_pin": int(led_cfg.get("green_pin", 32)),
"ws2812_red": tuple(led_cfg.get("ws2812_red", [255, 0, 0])),
"ws2812_green": tuple(led_cfg.get("ws2812_green", [0, 255, 0])),
} }
) )
else: else:
@@ -142,6 +146,8 @@ else:
"unit": _cfg.get("gauge_unit", "W"), "unit": _cfg.get("gauge_unit", "W"),
"red_pin": int(_cfg.get("led_red_pin", 33)), "red_pin": int(_cfg.get("led_red_pin", 33)),
"green_pin": int(_cfg.get("led_green_pin", 32)), "green_pin": int(_cfg.get("led_green_pin", 32)),
"ws2812_red": tuple(_cfg.get("ws2812_red", [255, 0, 0])),
"ws2812_green": tuple(_cfg.get("ws2812_green", [0, 255, 0])),
} }
) )
BL_UNIT = _cfg.get("backlight_unit", "%") BL_UNIT = _cfg.get("backlight_unit", "%")
@@ -189,18 +195,17 @@ def make_gauge_topics(prefix, gauge_id):
"led_red_disc": f"homeassistant/switch/{MQTT_CLIENT_ID}_g{gauge_id}_red/config", "led_red_disc": f"homeassistant/switch/{MQTT_CLIENT_ID}_g{gauge_id}_red/config",
"led_green_disc": f"homeassistant/switch/{MQTT_CLIENT_ID}_g{gauge_id}_green/config", "led_green_disc": f"homeassistant/switch/{MQTT_CLIENT_ID}_g{gauge_id}_green/config",
"led_bl_disc": f"homeassistant/light/{MQTT_CLIENT_ID}_g{gauge_id}_bl/config", "led_bl_disc": f"homeassistant/light/{MQTT_CLIENT_ID}_g{gauge_id}_bl/config",
"status_red": f"{prefix}/gauge{gauge_id}/status_led/red/set",
"status_green": f"{prefix}/gauge{gauge_id}/status_led/green/set",
"status_red_state": f"{prefix}/gauge{gauge_id}/status_led/red/state",
"status_green_state": f"{prefix}/gauge{gauge_id}/status_led/green/state",
"status_red_disc": f"homeassistant/switch/{MQTT_CLIENT_ID}_g{gauge_id}_status_red/config",
"status_green_disc": f"homeassistant/switch/{MQTT_CLIENT_ID}_g{gauge_id}_status_green/config",
} }
gauge_topics = [make_gauge_topics(MQTT_PREFIX, g["id"]) for g in gauges] gauge_topics = [make_gauge_topics(MQTT_PREFIX, g["id"]) for g in gauges]
T_SET = f"{MQTT_PREFIX}/set"
T_STATE = f"{MQTT_PREFIX}/state"
T_STATUS = f"{MQTT_PREFIX}/status"
T_ZERO = f"{MQTT_PREFIX}/zero"
gauge_topics = [make_gauge_topics(MQTT_PREFIX, g["id"]) for g in gauges]
T_SET = f"{MQTT_PREFIX}/set" T_SET = f"{MQTT_PREFIX}/set"
T_STATE = f"{MQTT_PREFIX}/state" T_STATE = f"{MQTT_PREFIX}/state"
@@ -213,25 +218,6 @@ T_DISC_GREEN = f"homeassistant/switch/{MQTT_CLIENT_ID}_green/config"
T_DISC_BL = f"homeassistant/light/{MQTT_CLIENT_ID}_bl/config" T_DISC_BL = f"homeassistant/light/{MQTT_CLIENT_ID}_bl/config"
def make_gauge_topics(prefix, gauge_id):
return {
"set": f"{prefix}/gauge{gauge_id}/set",
"state": f"{prefix}/gauge{gauge_id}/state",
"status": f"{prefix}/gauge{gauge_id}/status",
"zero": f"{prefix}/gauge{gauge_id}/zero",
"disc": f"homeassistant/number/{MQTT_CLIENT_ID}_g{gauge_id}/config",
"led_red": f"{prefix}/gauge{gauge_id}/led/red/set",
"led_green": f"{prefix}/gauge{gauge_id}/led/green/set",
"led_bl": f"{prefix}/gauge{gauge_id}/led/backlight/set",
"led_red_state": f"{prefix}/gauge{gauge_id}/led/red/state",
"led_green_state": f"{prefix}/gauge{gauge_id}/led/green/state",
"led_bl_state": f"{prefix}/gauge{gauge_id}/led/backlight/state",
"led_red_disc": f"homeassistant/switch/{MQTT_CLIENT_ID}_g{gauge_id}_red/config",
"led_green_disc": f"homeassistant/switch/{MQTT_CLIENT_ID}_g{gauge_id}_green/config",
"led_bl_disc": f"homeassistant/light/{MQTT_CLIENT_ID}_g{gauge_id}_bl/config",
}
_DEVICE = { _DEVICE = {
"identifiers": [MQTT_CLIENT_ID], "identifiers": [MQTT_CLIENT_ID],
"name": DEVICE_NAME, "name": DEVICE_NAME,
@@ -249,7 +235,6 @@ _wifi_check_interval_ms = 30000
_last_wifi_check = 0 _last_wifi_check = 0
_wifi_sta = None _wifi_sta = None
def connect_wifi(ssid, password, timeout_s=15): def connect_wifi(ssid, password, timeout_s=15):
global _wifi_sta global _wifi_sta
_wifi_sta = network.WLAN(network.STA_IF) _wifi_sta = network.WLAN(network.STA_IF)
@@ -305,10 +290,6 @@ def check_wifi():
log_err(f"WiFi reconnect failed: {e}") log_err(f"WiFi reconnect failed: {e}")
# ---------------------------------------------------------------------------
# LEDs (per gauge)
# ---------------------------------------------------------------------------
num_gauges = len(gauges) num_gauges = len(gauges)
leds_red = [] leds_red = []
leds_green = [] leds_green = []
@@ -317,40 +298,18 @@ for g in gauges:
leds_red.append(Pin(g["red_pin"], Pin.OUT, value=0)) leds_red.append(Pin(g["red_pin"], Pin.OUT, value=0))
leds_green.append(Pin(g["green_pin"], Pin.OUT, value=0)) leds_green.append(Pin(g["green_pin"], Pin.OUT, value=0))
total_backlight_leds = num_gauges * BACKLIGHT_LEDS_PER_GAUGE total_backlight_leds = num_gauges * (BACKLIGHT_LEDS_PER_GAUGE + STATUS_LEDS_PER_GAUGE)
leds_bl = NeoPixel(Pin(BACKLIGHT_PIN), total_backlight_leds) leds_bl = NeoPixel(Pin(BACKLIGHT_PIN), total_backlight_leds)
_backlight_color = (0, 0, 0)
_backlight_brightness = 100
_backlight_on = False
_bl_dirty_since = None
_BL_SAVE_DELAY_MS = 5000
backlight_color = [(0, 0, 0) for _ in range(num_gauges)] backlight_color = [(0, 0, 0) for _ in range(num_gauges)]
backlight_brightness = [100 for _ in range(num_gauges)] backlight_brightness = [100 for _ in range(num_gauges)]
backlight_on = [False for _ in range(num_gauges)] backlight_on = [False for _ in range(num_gauges)]
status_led_red = [False for _ in range(num_gauges)]
status_led_green = [False for _ in range(num_gauges)]
def _flush_backlight(client): _bl_dirty_since = None
for i in range(num_gauges): _BL_SAVE_DELAY_MS = 5000
gt = gauge_topics[i]
payload = {
"state": "ON" if backlight_on[i] else "OFF",
"color": {
"r": backlight_color[i][0],
"g": backlight_color[i][1],
"b": backlight_color[i][2],
},
"brightness": int(backlight_brightness[i] * 2.55),
}
client.publish(
f"{gt['set'].replace('/set', '/backlight/state')}",
ujson.dumps(payload),
retain=True,
)
info(
f"Gauge {i} backlight: {payload['state']} {backlight_color[i]} @ {backlight_brightness[i]}%"
)
def _backlight_changed(gauge_idx, new_color, new_on, new_brightness): def _backlight_changed(gauge_idx, new_color, new_on, new_brightness):
@@ -379,9 +338,11 @@ def set_backlight_color(gauge_idx, r, g, b, brightness=None):
backlight_on[gauge_idx] = new_on backlight_on[gauge_idx] = new_on
scale = brightness / 100 scale = brightness / 100
base_idx = gauge_idx * BACKLIGHT_LEDS_PER_GAUGE leds_per_gauge = BACKLIGHT_LEDS_PER_GAUGE + STATUS_LEDS_PER_GAUGE
base_idx = gauge_idx * leds_per_gauge
for j in range(BACKLIGHT_LEDS_PER_GAUGE): for j in range(BACKLIGHT_LEDS_PER_GAUGE):
leds_bl[base_idx + j] = (int(g * scale), int(r * scale), int(b * scale)) leds_bl[base_idx + j] = (int(g * scale), int(r * scale), int(b * scale))
_update_status_leds(gauge_idx)
leds_bl.write() leds_bl.write()
_mark_bl_dirty() _mark_bl_dirty()
@@ -397,21 +358,73 @@ def set_backlight_brightness(gauge_idx, brightness):
backlight_on[gauge_idx] = new_on backlight_on[gauge_idx] = new_on
r, g, b = backlight_color[gauge_idx] r, g, b = backlight_color[gauge_idx]
scale = clamped / 100 scale = clamped / 100
base_idx = gauge_idx * BACKLIGHT_LEDS_PER_GAUGE leds_per_gauge = BACKLIGHT_LEDS_PER_GAUGE + STATUS_LEDS_PER_GAUGE
base_idx = gauge_idx * leds_per_gauge
for j in range(BACKLIGHT_LEDS_PER_GAUGE): for j in range(BACKLIGHT_LEDS_PER_GAUGE):
leds_bl[base_idx + j] = (int(g * scale), int(r * scale), int(b * scale)) leds_bl[base_idx + j] = (int(g * scale), int(r * scale), int(b * scale))
_update_status_leds(gauge_idx)
leds_bl.write() leds_bl.write()
_mark_bl_dirty() _mark_bl_dirty()
# --------------------------------------------------------------------------- def _update_status_leds(gauge_idx):
# State leds_per_gauge = BACKLIGHT_LEDS_PER_GAUGE + STATUS_LEDS_PER_GAUGE
# --------------------------------------------------------------------------- base_idx = gauge_idx * leds_per_gauge + BACKLIGHT_LEDS_PER_GAUGE
_last_rezero_ms = None # set to ticks_ms() in main() g_cfg = gauges[gauge_idx]
client_ref = None red_color = g_cfg["ws2812_red"]
_mqtt_connected = False green_color = g_cfg["ws2812_green"]
_last_mqtt_check = 0
if status_led_red[gauge_idx]:
leds_bl[base_idx] = (red_color[1], red_color[0], red_color[2])
else:
leds_bl[base_idx] = (0, 0, 0)
if status_led_green[gauge_idx]:
leds_bl[base_idx + 1] = (green_color[1], green_color[0], green_color[2])
else:
leds_bl[base_idx + 1] = (0, 0, 0)
def set_status_led(gauge_idx, led_type, state):
global status_led_red, status_led_green
if led_type == "red":
status_led_red[gauge_idx] = state
elif led_type == "green":
status_led_green[gauge_idx] = state
_update_status_leds(gauge_idx)
leds_bl.write()
def publish_backlight_states(client):
"""Publish current backlight state for all gauges as retained MQTT messages."""
for i in range(num_gauges):
gt = gauge_topics[i]
r, g, b = backlight_color[i]
brightness = backlight_brightness[i]
state = {
"state": "ON" if backlight_on[i] else "OFF",
"color_mode": "rgb",
"brightness": int(brightness * 2.55),
"color": {"r": r, "g": g, "b": b},
}
try:
client.publish(gt["led_bl_state"], ujson.dumps(state), retain=True)
except Exception as e:
log_err(f"Backlight state publish failed for gauge {i}: {e}")
def _flush_backlight_state():
global _bl_dirty_since
if _bl_dirty_since is None:
return
if utime.ticks_diff(utime.ticks_ms(), _bl_dirty_since) < _BL_SAVE_DELAY_MS:
return
if client_ref is None:
return
publish_backlight_states(client_ref)
_bl_dirty_since = None
info("Backlight state flushed to MQTT")
def _publish(topic, payload, retain=False): def _publish(topic, payload, retain=False):
@@ -432,6 +445,7 @@ def _publish(topic, payload, retain=False):
def on_message(topic, payload): def on_message(topic, payload):
global backlight_brightness, backlight_color
if client_ref is None: if client_ref is None:
return return
topic = topic.decode() topic = topic.decode()
@@ -455,15 +469,6 @@ def on_message(topic, payload):
warn(f"Invalid set value for gauge {i}: '{payload}'") warn(f"Invalid set value for gauge {i}: '{payload}'")
return return
if topic == T_ZERO:
for i, g in enumerate(gauge_objects):
info(f"Zeroing all gauges")
g.zero()
gauge_last_rezero[i] = utime.ticks_ms()
info("All gauges zeroed")
return
for i, gt in enumerate(gauge_topics):
if topic == gt["led_red"]: if topic == gt["led_red"]:
state = payload.upper() == "ON" state = payload.upper() == "ON"
leds_red[i].value(1 if state else 0) leds_red[i].value(1 if state else 0)
@@ -513,12 +518,73 @@ def on_message(topic, payload):
info(f"Gauge {i} backlight → #{r:02x}{g:02x}{b:02x} @ {brightness}%") info(f"Gauge {i} backlight → #{r:02x}{g:02x}{b:02x} @ {brightness}%")
return return
if topic == gt["status_red"]:
state = payload.upper() == "ON"
set_status_led(i, "red", state)
_publish(gt["status_red_state"], "ON" if state else "OFF", retain=True)
info(f"Gauge {i} status red → {'ON' if state else 'OFF'}")
return
if topic == gt["status_green"]:
state = payload.upper() == "ON"
set_status_led(i, "green", state)
_publish(gt["status_green_state"], "ON" if state else "OFF", retain=True)
info(f"Gauge {i} status green → {'ON' if state else 'OFF'}")
return
if topic == T_ZERO:
for i, g in enumerate(gauge_objects):
g.zero()
gauge_last_rezero[i] = utime.ticks_ms()
info("All gauges zeroed")
return
if topic == T_SET:
try:
data = ujson.loads(payload)
if isinstance(data, dict):
for i, val in enumerate(data.values()):
if i < len(gauges):
g = gauges[i]
gauge_targets[i] = max(g["min"], min(g["max"], float(val)))
info(f"Gauge {i} target → {gauge_targets[i]:.1f}")
else:
val = float(payload)
for i in range(len(gauges)):
gauge_targets[i] = max(gauges[i]["min"], min(gauges[i]["max"], val))
info(f"All gauges target → {val:.1f}")
except Exception:
try:
val = float(payload)
for i in range(len(gauges)):
gauge_targets[i] = max(gauges[i]["min"], min(gauges[i]["max"], val))
info(f"All gauges target → {val:.1f}")
except:
warn(f"Invalid set value: '{payload}'")
return
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# MQTT connect + discovery # MQTT connect + discovery
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _subscribe_all(c):
c.subscribe(f"{MQTT_PREFIX}/set")
c.subscribe(f"{MQTT_PREFIX}/zero")
for i in range(num_gauges):
prefix = f"{MQTT_PREFIX}/gauge{i}"
c.subscribe(f"{prefix}/set")
c.subscribe(f"{prefix}/zero")
c.subscribe(f"{prefix}/led/red/set")
c.subscribe(f"{prefix}/led/green/set")
c.subscribe(f"{prefix}/led/backlight/set")
c.subscribe(f"{prefix}/status_led/red/set")
c.subscribe(f"{prefix}/status_led/green/set")
def connect_mqtt(): def connect_mqtt():
global client_ref, _mqtt_connected global client_ref, _mqtt_connected
info(f"Connecting to MQTT broker {MQTT_BROKER}:{MQTT_PORT} ...") info(f"Connecting to MQTT broker {MQTT_BROKER}:{MQTT_PORT} ...")
@@ -528,27 +594,22 @@ def connect_mqtt():
port=MQTT_PORT, port=MQTT_PORT,
user=MQTT_USER, user=MQTT_USER,
password=MQTT_PASSWORD, password=MQTT_PASSWORD,
keepalive=60, keepalive=30,
) )
client.set_last_will(T_STATUS, b"offline", retain=True, qos=0) # Don't set last will - it might be causing issues
# client.set_last_will(T_STATUS, b"offline", retain=True, qos=0)
client.set_callback(on_message) client.set_callback(on_message)
client.connect() client.connect()
client_ref = client client_ref = client
client.subscribe(T_SET)
client.subscribe(T_ZERO)
for gt in gauge_topics:
client.subscribe(gt["set"])
client.subscribe(gt["zero"])
client.subscribe(gt["led_red"])
client.subscribe(gt["led_green"])
client.subscribe(gt["led_bl"])
_mqtt_connected = True _mqtt_connected = True
info(f"MQTT connected client_id={MQTT_CLIENT_ID}") info(f"MQTT connected client_id={MQTT_CLIENT_ID}")
return client
_mqtt_check_interval_ms = 30000 _mqtt_check_interval_ms = 30000
_last_mqtt_check = 0 _last_mqtt_check = 0
client_ref = None
_mqtt_connected = False
def check_mqtt(): def check_mqtt():
@@ -584,14 +645,7 @@ def check_mqtt():
client_ref.set_last_will(T_STATUS, b"offline", retain=True, qos=0) client_ref.set_last_will(T_STATUS, b"offline", retain=True, qos=0)
client_ref.set_callback(on_message) client_ref.set_callback(on_message)
client_ref.connect() client_ref.connect()
client_ref.subscribe(T_SET) _subscribe_all(client_ref)
client_ref.subscribe(T_ZERO)
for gt in gauge_topics:
client_ref.subscribe(gt["set"])
client_ref.subscribe(gt["zero"])
client_ref.subscribe(gt["led_red"])
client_ref.subscribe(gt["led_green"])
client_ref.subscribe(gt["led_bl"])
_mqtt_connected = True _mqtt_connected = True
info("MQTT reconnected!") info("MQTT reconnected!")
publish_discovery(client_ref) publish_discovery(client_ref)
@@ -605,15 +659,10 @@ def check_mqtt():
return False return False
def publish_discovery(client): def publish_discovery(client):
"""Publish all HA MQTT discovery payloads for gauges and LEDs.""" """Publish all HA MQTT discovery payloads for gauges and LEDs."""
_dev_ref = { _dev_ref = _DEVICE
"identifiers": [MQTT_CLIENT_ID],
"name": DEVICE_NAME,
"model": DEVICE_MODEL,
"manufacturer": DEVICE_MFR,
"suggested_area": DEVICE_AREA,
}
for i, g in enumerate(gauges): for i, g in enumerate(gauges):
gt = gauge_topics[i] gt = gauge_topics[i]
@@ -639,6 +688,11 @@ def publish_discovery(client):
) )
info(f"Discovery: gauge {i} ({g['name']})") info(f"Discovery: gauge {i} ({g['name']})")
# Process MQTT messages between gauges
for _ in range(5):
client.check_msg()
utime.sleep_ms(10)
client.publish( client.publish(
gt["led_red_disc"], gt["led_red_disc"],
ujson.dumps( ujson.dumps(
@@ -677,6 +731,11 @@ def publish_discovery(client):
) )
info(f"Discovery: gauge {i} green LED") info(f"Discovery: gauge {i} green LED")
# Process MQTT messages
for _ in range(5):
client.check_msg()
utime.sleep_ms(10)
client.publish( client.publish(
gt["led_bl_disc"], gt["led_bl_disc"],
ujson.dumps( ujson.dumps(
@@ -696,14 +755,55 @@ def publish_discovery(client):
) )
info(f"Discovery: gauge {i} backlight") info(f"Discovery: gauge {i} backlight")
client.publish(
gt["status_red_disc"],
ujson.dumps(
{
"name": f"{g['name']} Status Red",
"uniq_id": f"{MQTT_CLIENT_ID}_g{i}_status_red",
"cmd_t": gt["status_red"],
"stat_t": gt["status_red_state"],
"pl_on": "ON",
"pl_off": "OFF",
"icon": "mdi:led-on",
"dev": _dev_ref,
"ret": True,
}
),
retain=True,
)
info(f"Discovery: gauge {i} status red")
client.publish(
gt["status_green_disc"],
ujson.dumps(
{
"name": f"{g['name']} Status Green",
"uniq_id": f"{MQTT_CLIENT_ID}_g{i}_status_green",
"cmd_t": gt["status_green"],
"stat_t": gt["status_green_state"],
"pl_on": "ON",
"pl_off": "OFF",
"icon": "mdi:led-on",
"dev": _dev_ref,
"ret": True,
}
),
retain=True,
)
info(f"Discovery: gauge {i} status green")
# Process between gauges to avoid MQTT blocking
for _ in range(5):
client.check_msg()
utime.sleep_ms(10)
def publish_state(client): def publish_state(client):
for i, g in enumerate(gauge_objects): for i, g in enumerate(gauge_objects):
gt = gauge_topics[i] gt = gauge_topics[i]
val = g.get() val = g.get()
client.publish(gt["state"], str(round(val, 1)), retain=True) client.publish(gt["state"], str(val))
client.publish(gt["status"], "online", retain=True)
info(f"Gauge {i} state: {val:.1f} step={g._current_step}")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -718,32 +818,52 @@ def main():
connect_wifi(WIFI_SSID, WIFI_PASSWORD) connect_wifi(WIFI_SSID, WIFI_PASSWORD)
# Connect MQTT (no subscriptions yet — keeps broker silent during discovery)
connect_mqtt()
# Publish discovery — broker has nothing to send back yet
info("Publishing discovery...")
publish_discovery(client_ref)
# Subscribe now — retained messages will start arriving from here
_subscribe_all(client_ref)
info("Draining initial retained messages...")
for _ in range(50):
client_ref.check_msg()
utime.sleep_ms(20)
# Now initialize gauges
info("Zeroing gauges on startup ...") info("Zeroing gauges on startup ...")
for i, g in enumerate(gauge_objects): for i, g in enumerate(gauge_objects):
g.zero() g.zero()
info(f"Zeroed gauge {i}") info(f"Zeroed gauge {i}")
info("Zero complete") info("Zero complete")
connect_mqtt() info("Publishing state...")
publish_discovery(client_ref)
publish_state(client_ref) publish_state(client_ref)
utime.sleep_ms(50)
for _ in range(5):
client_ref.check_msg()
utime.sleep_ms(20)
info("Entering main loop") info("Entering main loop")
info("-" * 48) info("-" * 48)
try: try:
import ota import ota
ota.mark_ok() ota.mark_ok()
info("OTA OK flag set") except:
except ImportError:
pass pass
global _bl_dirty_since # Initialize variables for main loop
last_heartbeat = utime.ticks_ms() last_heartbeat = utime.ticks_ms()
now = 0
was_moving = False
while True: while True:
try: try:
now = utime.ticks_ms()
check_wifi() check_wifi()
if not check_mqtt(): if not check_mqtt():
@@ -752,49 +872,35 @@ def main():
client_ref.check_msg() client_ref.check_msg()
now = utime.ticks_ms() pending = []
moved_any = False
for i, g in enumerate(gauge_objects): for i, g in enumerate(gauge_objects):
current_target = g._val_to_step(gauge_targets[i]) delta = g._val_to_step(gauge_targets[i]) - g._current_step
if current_target != g._current_step: steps = max(-5, min(5, delta))
direction = 1 if current_target > g._current_step else -1 pending.append(steps)
steps_to_move = current_target - g._current_step
steps_to_move = max(-5, min(5, steps_to_move))
for _ in range(abs(steps_to_move)):
g.step(direction)
moved_any = True
moved_any = any(s != 0 for s in pending)
if moved_any: if moved_any:
was_moving = True
delay_us = 1_000_000 // MICROSTEPS_PER_SECOND delay_us = 1_000_000 // MICROSTEPS_PER_SECOND
utime.sleep_us(delay_us) for tick in range(max(abs(s) for s in pending)):
if (
REZERO_INTERVAL_MS > 0
and utime.ticks_diff(now, gauge_last_rezero[0]) >= REZERO_INTERVAL_MS
):
for i, g in enumerate(gauge_objects): for i, g in enumerate(gauge_objects):
info(f"Auto-rezero gauge {i}") if tick < abs(pending[i]):
saved = gauge_targets[i] g.step(1 if pending[i] > 0 else -1)
g.zero() utime.sleep_us(delay_us)
if saved > gauges[i]["min"]: else:
g.set(saved) if was_moving:
gauge_last_rezero[i] = now
publish_state(client_ref) publish_state(client_ref)
info("Auto-rezero complete") was_moving = False
utime.sleep_ms(10)
if (
_bl_dirty_since is not None
and utime.ticks_diff(now, _bl_dirty_since) >= _BL_SAVE_DELAY_MS
):
_flush_backlight(client_ref)
_bl_dirty_since = None
if utime.ticks_diff(now, last_heartbeat) >= HEARTBEAT_MS: if utime.ticks_diff(now, last_heartbeat) >= HEARTBEAT_MS:
info(f"Heartbeat: {gauge_targets}")
publish_state(client_ref) publish_state(client_ref)
last_heartbeat = now last_heartbeat = now
except Exception as e: except Exception as e:
import sys
sys.print_exception(e)
log_err(f"Main loop error: {e} — continuing") log_err(f"Main loop error: {e} — continuing")
utime.sleep_ms(100) utime.sleep_ms(100)

72
ota.py
View File

@@ -82,31 +82,55 @@ OTA_MANIFEST = "ota_manifest.txt"
# Logging # Logging
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _ts(): def _ts():
ms = utime.ticks_ms() ms = utime.ticks_ms()
return f"{(ms // 3600000) % 24:02d}:{(ms // 60000) % 60:02d}:{(ms // 1000) % 60:02d}.{ms % 1000:03d}" return f"{(ms // 3600000) % 24:02d}:{(ms // 60000) % 60:02d}:{(ms // 1000) % 60:02d}.{ms % 1000:03d}"
def _log(level, msg): print(f"[{_ts()}] {level:5s} [OTA] {msg}")
def info(msg): _log("INFO", msg) def _log(level, msg):
def warn(msg): _log("WARN", msg) print(f"[{_ts()}] {level:5s} [OTA] {msg}")
def log_err(msg): _log("ERROR", msg)
def info(msg):
_log("INFO", msg)
def warn(msg):
_log("WARN", msg)
def log_err(msg):
_log("ERROR", msg)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# HTTP helpers # HTTP helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _headers(): def _headers():
h = {"Accept": "application/json"} h = {"Accept": "application/json"}
if API_TOKEN: if API_TOKEN:
h["Authorization"] = f"token {API_TOKEN}" h["Authorization"] = f"token {API_TOKEN}"
return h return h
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Config loader # Config loader
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def load_config(): def load_config():
global GITEA_BASE, REPO_OWNER, REPO_NAME, REPO_FOLDER, REPO_BRANCH, API_TOKEN, WIFI_SSID, WIFI_PASSWORD global \
GITEA_BASE, \
REPO_OWNER, \
REPO_NAME, \
REPO_FOLDER, \
REPO_BRANCH, \
API_TOKEN, \
WIFI_SSID, \
WIFI_PASSWORD
try: try:
with open(SETTINGS_FILE) as f: with open(SETTINGS_FILE) as f:
cfg = ujson.load(f) cfg = ujson.load(f)
@@ -124,10 +148,12 @@ def load_config():
except Exception as e: except Exception as e:
warn(f"Config parse error: {e} — using defaults") warn(f"Config parse error: {e} — using defaults")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _match_pattern(name, pattern): def _match_pattern(name, pattern):
if "*" not in pattern: if "*" not in pattern:
return name == pattern return name == pattern
@@ -150,11 +176,9 @@ def _match_pattern(name, pattern):
i += 1 i += 1
return i == n and j == m return i == n and j == m
def _fetch_commit_sha(): def _fetch_commit_sha():
url = ( url = f"{GITEA_BASE}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}/branches/{REPO_BRANCH}"
f"{GITEA_BASE}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}"
f"/branches/{REPO_BRANCH}"
)
try: try:
r = urequests.get(url, headers=_headers()) r = urequests.get(url, headers=_headers())
if r.status_code == 200: if r.status_code == 200:
@@ -166,6 +190,7 @@ def _fetch_commit_sha():
log_err(f"Failed to fetch commit: {e}") log_err(f"Failed to fetch commit: {e}")
return None return None
def _fetch_manifest(): def _fetch_manifest():
url = ( url = (
f"{GITEA_BASE}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}" f"{GITEA_BASE}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}"
@@ -178,6 +203,7 @@ def _fetch_manifest():
r.close() r.close()
if data.get("content"): if data.get("content"):
import ubinascii import ubinascii
content = ubinascii.a2b_base64(data["content"]).decode() content = ubinascii.a2b_base64(data["content"]).decode()
patterns = [line.strip() for line in content.splitlines()] patterns = [line.strip() for line in content.splitlines()]
return [p for p in patterns if p and not p.startswith("#")] return [p for p in patterns if p and not p.startswith("#")]
@@ -188,6 +214,7 @@ def _fetch_manifest():
log_err(f"Failed to fetch manifest: {e}") log_err(f"Failed to fetch manifest: {e}")
return None return None
def _fetch_dir(path): def _fetch_dir(path):
url = ( url = (
f"{GITEA_BASE}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}" f"{GITEA_BASE}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}"
@@ -195,6 +222,7 @@ def _fetch_dir(path):
) )
return _api_get(url) return _api_get(url)
def _api_get(url): def _api_get(url):
"""GET a URL and return parsed JSON, or None on failure.""" """GET a URL and return parsed JSON, or None on failure."""
try: try:
@@ -209,6 +237,7 @@ def _api_get(url):
log_err(f"GET {url} failed: {e}") log_err(f"GET {url} failed: {e}")
return None return None
def _download(url, dest_path): def _download(url, dest_path):
"""Download url to dest_path. Returns True on success.""" """Download url to dest_path. Returns True on success."""
tmp = dest_path + ".tmp" tmp = dest_path + ".tmp"
@@ -236,6 +265,7 @@ def _download(url, dest_path):
pass pass
return False return False
def _load_manifest(): def _load_manifest():
try: try:
with open(MANIFEST_FILE) as f: with open(MANIFEST_FILE) as f:
@@ -243,6 +273,7 @@ def _load_manifest():
except Exception: except Exception:
return {} return {}
def _save_manifest(manifest, commit_sha=None): def _save_manifest(manifest, commit_sha=None):
try: try:
with open(MANIFEST_FILE, "w") as f: with open(MANIFEST_FILE, "w") as f:
@@ -252,6 +283,7 @@ def _save_manifest(manifest, commit_sha=None):
except Exception as e: except Exception as e:
warn(f"Could not save manifest: {e}") warn(f"Could not save manifest: {e}")
def _wipe_manifest(): def _wipe_manifest():
try: try:
os.remove(MANIFEST_FILE) os.remove(MANIFEST_FILE)
@@ -259,6 +291,7 @@ def _wipe_manifest():
except OSError: except OSError:
pass pass
def _ok_flag_exists(): def _ok_flag_exists():
try: try:
os.stat(OK_FLAG_FILE) os.stat(OK_FLAG_FILE)
@@ -266,12 +299,14 @@ def _ok_flag_exists():
except OSError: except OSError:
return False return False
def _clear_ok_flag(): def _clear_ok_flag():
try: try:
os.remove(OK_FLAG_FILE) os.remove(OK_FLAG_FILE)
except OSError: except OSError:
pass pass
def mark_ok(): def mark_ok():
""" """
Call this from main.py after successful startup. Call this from main.py after successful startup.
@@ -283,10 +318,12 @@ def mark_ok():
except Exception as e: except Exception as e:
warn(f"Could not write OK flag: {e}") warn(f"Could not write OK flag: {e}")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Core update logic # Core update logic
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _fetch_file_list(): def _fetch_file_list():
""" """
Returns list of {name, sha, download_url} dicts based on the Returns list of {name, sha, download_url} dicts based on the
@@ -319,11 +356,13 @@ def _fetch_file_list():
if _match_pattern(name, p) or _match_pattern(entry["path"], p): if _match_pattern(name, p) or _match_pattern(entry["path"], p):
if entry["path"] not in visited: if entry["path"] not in visited:
visited.add(entry["path"]) visited.add(entry["path"])
files.append({ files.append(
{
"name": entry["path"], "name": entry["path"],
"sha": entry["sha"], "sha": entry["sha"],
"download_url": entry["download_url"], "download_url": entry["download_url"],
}) }
)
break break
root = _fetch_dir(REPO_FOLDER) root = _fetch_dir(REPO_FOLDER)
@@ -333,12 +372,15 @@ def _fetch_file_list():
fetch_matching(root, manifest_patterns) fetch_matching(root, manifest_patterns)
return files return files
def _do_update(commit_sha=None): def _do_update(commit_sha=None):
""" """
Fetch file list, download changed files, update manifest. Fetch file list, download changed files, update manifest.
Returns True if all succeeded (or nothing needed updating). Returns True if all succeeded (or nothing needed updating).
""" """
info(f"Checking {GITEA_BASE}/{REPO_OWNER}/{REPO_NAME}/{REPO_FOLDER} @ {REPO_BRANCH}") info(
f"Checking {GITEA_BASE}/{REPO_OWNER}/{REPO_NAME}/{REPO_FOLDER} @ {REPO_BRANCH}"
)
file_list = _fetch_file_list() file_list = _fetch_file_list()
if file_list is None: if file_list is None:
log_err("Could not fetch file list — skipping update") log_err("Could not fetch file list — skipping update")
@@ -382,10 +424,12 @@ def _do_update(commit_sha=None):
return True return True
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Public entry point # Public entry point
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def update(): def update():
""" """
Main entry point. Call from boot.py before importing application code. Main entry point. Call from boot.py before importing application code.
@@ -408,9 +452,7 @@ def update():
if not ok_flag: if not ok_flag:
warn("OK flag missing — last boot may have failed") warn("OK flag missing — last boot may have failed")
warn("Wiping manifest to force full re-fetch") warn("Re-checking all files, will only download changed ones")
_wipe_manifest()
manifest = {}
else: else:
info("OK flag present — last boot was good") info("OK flag present — last boot was good")