Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fa9493928 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -28,5 +28,5 @@
|
|||||||
"red_led_entity_name": "Selsyn 1 Red LED",
|
"red_led_entity_name": "Selsyn 1 Red LED",
|
||||||
"green_led_entity_name": "Selsyn 1 Green LED",
|
"green_led_entity_name": "Selsyn 1 Green LED",
|
||||||
"backlight_entity_name": "Selsyn 1 Backlight",
|
"backlight_entity_name": "Selsyn 1 Backlight",
|
||||||
"ws2812_order": "GRB"
|
"backlight_unit": "%"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,7 @@
|
|||||||
"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]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -36,18 +34,14 @@
|
|||||||
"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
|
|
||||||
"ws2812_order": "GRB"
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"device": {
|
"device": {
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ class Gauge:
|
|||||||
|
|
||||||
self._current_step = 0
|
self._current_step = 0
|
||||||
self._zeroed = False
|
self._zeroed = False
|
||||||
self._last_dir = None
|
|
||||||
|
|
||||||
def zero(self):
|
def zero(self):
|
||||||
overrun = _OVERRUN_STEPS
|
overrun = _OVERRUN_STEPS
|
||||||
@@ -109,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_us(self._step_us)
|
utime.sleep_ms(self._step_us)
|
||||||
|
|
||||||
self._current_step = target_step
|
self._current_step = target_step
|
||||||
|
|
||||||
@@ -129,20 +128,6 @@ class Gauge:
|
|||||||
self._pin_step.value(0)
|
self._pin_step.value(0)
|
||||||
utime.sleep_us(delay_us)
|
utime.sleep_us(delay_us)
|
||||||
|
|
||||||
def steps_toward(self, value, limit=5, deadband=0.5):
|
|
||||||
"""Return the step delta needed to move toward value, clamped to ±limit.
|
|
||||||
|
|
||||||
deadband: If error is less than this fraction of one step, return 0 to prevent
|
|
||||||
micro-corrections due to floating-point rounding. Default 0.5 means
|
|
||||||
no movement if error < half a step.
|
|
||||||
"""
|
|
||||||
target_step = self._val_to_step(value)
|
|
||||||
delta = target_step - self._current_step
|
|
||||||
deadband_steps = deadband
|
|
||||||
if abs(delta) < deadband_steps:
|
|
||||||
return 0
|
|
||||||
return max(-limit, min(limit, delta))
|
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
return self._step_to_val(self._current_step)
|
return self._step_to_val(self._current_step)
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
gaugemqttcontinuous.py — MQTT-based gauge controller for ESP32 / MicroPython
|
gaugemqtt.py — MQTT-based gauge controller for ESP32 / MicroPython
|
||||||
|
|
||||||
Deploy these files to the ESP32:
|
Deploy these files to the ESP32:
|
||||||
gauge_vid6008.py — stepper driver
|
gauge.py — stepper driver
|
||||||
gaugemqttcontinuous.py — this file
|
gaugemqtt.py — this file
|
||||||
umqtt/simple.py — MicroPython built-in
|
umqtt/simple.py — MicroPython built-in
|
||||||
umqtt/robust.py — https://raw.githubusercontent.com/micropython/micropython-lib/master/micropython/umqtt.robust/umqtt/robust.py
|
umqtt/robust.py — https://raw.githubusercontent.com/micropython/micropython-lib/master/micropython/umqtt.robust/umqtt/robust.py
|
||||||
config.json — configuration (see below)
|
config.json — configuration (see below)
|
||||||
|
|
||||||
MQTT topics (all prefixed with mqtt_prefix from config.json):
|
MQTT topics (all prefixed with mqtt_prefix from config.json):
|
||||||
.../set ← HA publishes target value here
|
.../set ← HA publishes target value here
|
||||||
@@ -81,7 +81,6 @@ _cfg = _load_config()
|
|||||||
|
|
||||||
DEBUG = _cfg.get("debug", False)
|
DEBUG = _cfg.get("debug", False)
|
||||||
_DEBUG = DEBUG
|
_DEBUG = DEBUG
|
||||||
_WS2812_ORDER = _cfg.get("ws2812_order", "GRB").upper()
|
|
||||||
|
|
||||||
WIFI_SSID = _cfg["wifi_ssid"]
|
WIFI_SSID = _cfg["wifi_ssid"]
|
||||||
WIFI_PASSWORD = _cfg["wifi_password"]
|
WIFI_PASSWORD = _cfg["wifi_password"]
|
||||||
@@ -99,7 +98,6 @@ 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"))
|
||||||
@@ -128,8 +126,6 @@ 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:
|
||||||
@@ -146,10 +142,9 @@ 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", "%")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Gauge initialization
|
# Gauge initialization
|
||||||
@@ -194,21 +189,48 @@ 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_STATUS = f"{MQTT_PREFIX}/status"
|
||||||
T_ZERO = f"{MQTT_PREFIX}/zero"
|
T_ZERO = f"{MQTT_PREFIX}/zero"
|
||||||
|
|
||||||
|
T_DISC_GAUGE = f"homeassistant/number/{MQTT_CLIENT_ID}/config"
|
||||||
|
T_DISC_RED = f"homeassistant/switch/{MQTT_CLIENT_ID}_red/config"
|
||||||
|
T_DISC_GREEN = f"homeassistant/switch/{MQTT_CLIENT_ID}_green/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],
|
||||||
@@ -222,10 +244,12 @@ _DEVICE = {
|
|||||||
# WiFi
|
# WiFi
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_wifi_reconnect_delay_s = 5
|
||||||
_wifi_check_interval_ms = 30000
|
_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)
|
||||||
@@ -253,7 +277,7 @@ def connect_wifi(ssid, password, timeout_s=15):
|
|||||||
|
|
||||||
|
|
||||||
def check_wifi():
|
def check_wifi():
|
||||||
global _wifi_sta, _last_wifi_check
|
global _wifi_sta, _last_wifi_check, _wifi_reconnect_delay_s
|
||||||
now = utime.ticks_ms()
|
now = utime.ticks_ms()
|
||||||
if utime.ticks_diff(now, _last_wifi_check) < _wifi_check_interval_ms:
|
if utime.ticks_diff(now, _last_wifi_check) < _wifi_check_interval_ms:
|
||||||
return
|
return
|
||||||
@@ -281,32 +305,52 @@ 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 = []
|
||||||
|
leds_bl = []
|
||||||
for g in gauges:
|
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 + STATUS_LEDS_PER_GAUGE)
|
total_backlight_leds = num_gauges * BACKLIGHT_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)]
|
|
||||||
|
|
||||||
_bl_dirty_since = None
|
def _flush_backlight(client):
|
||||||
_BL_SAVE_DELAY_MS = 5000
|
for i in range(num_gauges):
|
||||||
|
gt = gauge_topics[i]
|
||||||
|
payload = {
|
||||||
def _to_pixel(r, g, b):
|
"state": "ON" if backlight_on[i] else "OFF",
|
||||||
"""Reorder RGB to match the WS2812 variant's byte order (GRB or RGB)."""
|
"color": {
|
||||||
if _WS2812_ORDER == "GRB":
|
"r": backlight_color[i][0],
|
||||||
return (g, r, b)
|
"g": backlight_color[i][1],
|
||||||
return (r, g, b)
|
"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):
|
||||||
@@ -322,19 +366,6 @@ def _mark_bl_dirty():
|
|||||||
_bl_dirty_since = utime.ticks_ms()
|
_bl_dirty_since = utime.ticks_ms()
|
||||||
|
|
||||||
|
|
||||||
def _apply_backlight(gauge_idx, r, g, b, brightness):
|
|
||||||
"""Write RGB+brightness to the physical LEDs and mark dirty."""
|
|
||||||
scale = brightness / 100
|
|
||||||
leds_per_gauge = BACKLIGHT_LEDS_PER_GAUGE + STATUS_LEDS_PER_GAUGE
|
|
||||||
base_idx = gauge_idx * leds_per_gauge
|
|
||||||
pixel = _to_pixel(int(r * scale), int(g * scale), int(b * scale))
|
|
||||||
for j in range(BACKLIGHT_LEDS_PER_GAUGE):
|
|
||||||
leds_bl[base_idx + j] = pixel
|
|
||||||
_update_status_leds(gauge_idx)
|
|
||||||
leds_bl.write()
|
|
||||||
_mark_bl_dirty()
|
|
||||||
|
|
||||||
|
|
||||||
def set_backlight_color(gauge_idx, r, g, b, brightness=None):
|
def set_backlight_color(gauge_idx, r, g, b, brightness=None):
|
||||||
global backlight_color, backlight_brightness, backlight_on
|
global backlight_color, backlight_brightness, backlight_on
|
||||||
if brightness is None:
|
if brightness is None:
|
||||||
@@ -346,7 +377,13 @@ def set_backlight_color(gauge_idx, r, g, b, brightness=None):
|
|||||||
if brightness > 0:
|
if brightness > 0:
|
||||||
backlight_brightness[gauge_idx] = brightness
|
backlight_brightness[gauge_idx] = brightness
|
||||||
backlight_on[gauge_idx] = new_on
|
backlight_on[gauge_idx] = new_on
|
||||||
_apply_backlight(gauge_idx, r, g, b, brightness)
|
|
||||||
|
scale = brightness / 100
|
||||||
|
base_idx = gauge_idx * 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.write()
|
||||||
|
_mark_bl_dirty()
|
||||||
|
|
||||||
|
|
||||||
def set_backlight_brightness(gauge_idx, brightness):
|
def set_backlight_brightness(gauge_idx, brightness):
|
||||||
@@ -359,67 +396,22 @@ def set_backlight_brightness(gauge_idx, brightness):
|
|||||||
backlight_brightness[gauge_idx] = clamped
|
backlight_brightness[gauge_idx] = clamped
|
||||||
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]
|
||||||
_apply_backlight(gauge_idx, r, g, b, clamped)
|
scale = clamped / 100
|
||||||
|
base_idx = gauge_idx * BACKLIGHT_LEDS_PER_GAUGE
|
||||||
|
for j in range(BACKLIGHT_LEDS_PER_GAUGE):
|
||||||
def _update_status_leds(gauge_idx):
|
leds_bl[base_idx + j] = (int(g * scale), int(r * scale), int(b * scale))
|
||||||
leds_per_gauge = BACKLIGHT_LEDS_PER_GAUGE + STATUS_LEDS_PER_GAUGE
|
|
||||||
base_idx = gauge_idx * leds_per_gauge + BACKLIGHT_LEDS_PER_GAUGE
|
|
||||||
|
|
||||||
g_cfg = gauges[gauge_idx]
|
|
||||||
red_color = g_cfg["ws2812_red"]
|
|
||||||
green_color = g_cfg["ws2812_green"]
|
|
||||||
|
|
||||||
if status_led_red[gauge_idx]:
|
|
||||||
leds_bl[base_idx] = _to_pixel(*red_color)
|
|
||||||
else:
|
|
||||||
leds_bl[base_idx] = (0, 0, 0)
|
|
||||||
|
|
||||||
if status_led_green[gauge_idx]:
|
|
||||||
leds_bl[base_idx + 1] = _to_pixel(*green_color)
|
|
||||||
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()
|
leds_bl.write()
|
||||||
|
_mark_bl_dirty()
|
||||||
|
|
||||||
|
|
||||||
def publish_backlight_states(client):
|
# ---------------------------------------------------------------------------
|
||||||
"""Publish current backlight state for all gauges as retained MQTT messages."""
|
# State
|
||||||
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}")
|
|
||||||
|
|
||||||
|
_last_rezero_ms = None # set to ticks_ms() in main()
|
||||||
def _flush_backlight_state():
|
client_ref = None
|
||||||
global _bl_dirty_since
|
_mqtt_connected = False
|
||||||
if _bl_dirty_since is None:
|
_last_mqtt_check = 0
|
||||||
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):
|
||||||
@@ -463,6 +455,15 @@ 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)
|
||||||
@@ -512,73 +513,12 @@ 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} ...")
|
||||||
@@ -588,22 +528,27 @@ def connect_mqtt():
|
|||||||
port=MQTT_PORT,
|
port=MQTT_PORT,
|
||||||
user=MQTT_USER,
|
user=MQTT_USER,
|
||||||
password=MQTT_PASSWORD,
|
password=MQTT_PASSWORD,
|
||||||
keepalive=30,
|
keepalive=60,
|
||||||
)
|
)
|
||||||
# Don't set last will - it might be causing issues
|
client.set_last_will(T_STATUS, b"offline", retain=True, qos=0)
|
||||||
# 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():
|
||||||
@@ -634,16 +579,23 @@ def check_mqtt():
|
|||||||
port=MQTT_PORT,
|
port=MQTT_PORT,
|
||||||
user=MQTT_USER,
|
user=MQTT_USER,
|
||||||
password=MQTT_PASSWORD,
|
password=MQTT_PASSWORD,
|
||||||
keepalive=30,
|
keepalive=60,
|
||||||
)
|
)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
_subscribe_all(client_ref)
|
|
||||||
publish_state(client_ref)
|
publish_state(client_ref)
|
||||||
publish_backlight_states(client_ref)
|
|
||||||
return True
|
return True
|
||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
log_err(f"MQTT reconnect attempt {attempt + 1} failed: {e2}")
|
log_err(f"MQTT reconnect attempt {attempt + 1} failed: {e2}")
|
||||||
@@ -653,10 +605,15 @@ 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 = _DEVICE
|
_dev_ref = {
|
||||||
|
"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]
|
||||||
@@ -681,12 +638,7 @@ def publish_discovery(client):
|
|||||||
retain=True,
|
retain=True,
|
||||||
)
|
)
|
||||||
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(
|
||||||
@@ -705,7 +657,7 @@ def publish_discovery(client):
|
|||||||
retain=True,
|
retain=True,
|
||||||
)
|
)
|
||||||
info(f"Discovery: gauge {i} red LED")
|
info(f"Discovery: gauge {i} red LED")
|
||||||
|
|
||||||
client.publish(
|
client.publish(
|
||||||
gt["led_green_disc"],
|
gt["led_green_disc"],
|
||||||
ujson.dumps(
|
ujson.dumps(
|
||||||
@@ -724,11 +676,6 @@ def publish_discovery(client):
|
|||||||
retain=True,
|
retain=True,
|
||||||
)
|
)
|
||||||
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"],
|
||||||
@@ -749,55 +696,14 @@ 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(val))
|
client.publish(gt["state"], str(round(val, 1)), retain=True)
|
||||||
|
client.publish(gt["status"], "online", retain=True)
|
||||||
|
info(f"Gauge {i} state: {val:.1f} step={g._current_step}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -812,52 +718,32 @@ 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")
|
||||||
|
|
||||||
info("Publishing state...")
|
connect_mqtt()
|
||||||
|
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()
|
||||||
except:
|
info("OTA OK flag set")
|
||||||
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Initialize variables for main loop
|
global _bl_dirty_since
|
||||||
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():
|
||||||
@@ -865,33 +751,50 @@ def main():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
client_ref.check_msg()
|
client_ref.check_msg()
|
||||||
_flush_backlight_state()
|
|
||||||
|
|
||||||
pending = [g.steps_toward(gauge_targets[i],limit=50) for i, g in enumerate(gauge_objects)]
|
now = utime.ticks_ms()
|
||||||
|
|
||||||
|
moved_any = False
|
||||||
|
for i, g in enumerate(gauge_objects):
|
||||||
|
current_target = g._val_to_step(gauge_targets[i])
|
||||||
|
if current_target != g._current_step:
|
||||||
|
direction = 1 if current_target > g._current_step else -1
|
||||||
|
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
|
||||||
for tick in range(max(abs(s) for s in pending)):
|
utime.sleep_us(delay_us)
|
||||||
for i, g in enumerate(gauge_objects):
|
|
||||||
if tick < abs(pending[i]):
|
if (
|
||||||
g.step(1 if pending[i] > 0 else -1)
|
REZERO_INTERVAL_MS > 0
|
||||||
utime.sleep_us(delay_us)
|
and utime.ticks_diff(now, gauge_last_rezero[0]) >= REZERO_INTERVAL_MS
|
||||||
else:
|
):
|
||||||
if was_moving:
|
for i, g in enumerate(gauge_objects):
|
||||||
publish_state(client_ref)
|
info(f"Auto-rezero gauge {i}")
|
||||||
was_moving = False
|
saved = gauge_targets[i]
|
||||||
utime.sleep_ms(10)
|
g.zero()
|
||||||
|
if saved > gauges[i]["min"]:
|
||||||
|
g.set(saved)
|
||||||
|
gauge_last_rezero[i] = now
|
||||||
|
publish_state(client_ref)
|
||||||
|
info("Auto-rezero complete")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|||||||
146
ota.py
146
ota.py
@@ -63,84 +63,60 @@ import utime
|
|||||||
# Default configuration — override via /ota_config.json
|
# Default configuration — override via /ota_config.json
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
GITEA_BASE = "http://git.baumann.gr" # no trailing slash
|
GITEA_BASE = "http://git.baumann.gr" # no trailing slash
|
||||||
REPO_OWNER = "adrian"
|
REPO_OWNER = "adrian"
|
||||||
REPO_NAME = "esp32-gauge"
|
REPO_NAME = "esp32-gauge"
|
||||||
REPO_FOLDER = "firmware" # folder inside repo to sync
|
REPO_FOLDER = "firmware" # folder inside repo to sync
|
||||||
REPO_BRANCH = "main"
|
REPO_BRANCH = "main"
|
||||||
API_TOKEN = None # set to string for private repos
|
API_TOKEN = None # set to string for private repos
|
||||||
|
|
||||||
WIFI_SSID = None
|
WIFI_SSID = None
|
||||||
WIFI_PASSWORD = None
|
WIFI_PASSWORD = None
|
||||||
|
|
||||||
SETTINGS_FILE = "/ota_config.json"
|
SETTINGS_FILE = "/ota_config.json"
|
||||||
MANIFEST_FILE = "/.ota_manifest.json"
|
MANIFEST_FILE = "/.ota_manifest.json"
|
||||||
OK_FLAG_FILE = "/.ota_ok"
|
OK_FLAG_FILE = "/.ota_ok"
|
||||||
OTA_MANIFEST = "ota_manifest.txt"
|
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 warn(msg):
|
|
||||||
_log("WARN", msg)
|
|
||||||
|
|
||||||
|
|
||||||
def log_err(msg):
|
|
||||||
_log("ERROR", msg)
|
|
||||||
|
|
||||||
|
def _log(level, msg): print(f"[{_ts()}] {level:5s} [OTA] {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 \
|
global GITEA_BASE, REPO_OWNER, REPO_NAME, REPO_FOLDER, REPO_BRANCH, API_TOKEN, WIFI_SSID, WIFI_PASSWORD
|
||||||
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)
|
||||||
GITEA_BASE = cfg.get("gitea_base", GITEA_BASE)
|
GITEA_BASE = cfg.get("gitea_base", GITEA_BASE)
|
||||||
REPO_OWNER = cfg.get("repo_owner", REPO_OWNER)
|
REPO_OWNER = cfg.get("repo_owner", REPO_OWNER)
|
||||||
REPO_NAME = cfg.get("repo_name", REPO_NAME)
|
REPO_NAME = cfg.get("repo_name", REPO_NAME)
|
||||||
REPO_FOLDER = cfg.get("repo_folder", REPO_FOLDER)
|
REPO_FOLDER = cfg.get("repo_folder", REPO_FOLDER)
|
||||||
REPO_BRANCH = cfg.get("repo_branch", REPO_BRANCH)
|
REPO_BRANCH = cfg.get("repo_branch", REPO_BRANCH)
|
||||||
API_TOKEN = cfg.get("api_token", API_TOKEN)
|
API_TOKEN = cfg.get("api_token", API_TOKEN)
|
||||||
WIFI_SSID = cfg.get("wifi_ssid", WIFI_SSID)
|
WIFI_SSID = cfg.get("wifi_ssid", WIFI_SSID)
|
||||||
WIFI_PASSWORD = cfg.get("wifi_password", WIFI_PASSWORD)
|
WIFI_PASSWORD = cfg.get("wifi_password", WIFI_PASSWORD)
|
||||||
info(f"Config loaded from {SETTINGS_FILE}")
|
info(f"Config loaded from {SETTINGS_FILE}")
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -148,12 +124,10 @@ 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
|
||||||
@@ -176,9 +150,11 @@ 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 = f"{GITEA_BASE}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}/branches/{REPO_BRANCH}"
|
url = (
|
||||||
|
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:
|
||||||
@@ -190,7 +166,6 @@ 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}"
|
||||||
@@ -198,24 +173,21 @@ def _fetch_manifest():
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
r = urequests.get(url, headers=_headers())
|
r = urequests.get(url, headers=_headers())
|
||||||
try:
|
if r.status_code == 200:
|
||||||
if r.status_code == 200:
|
data = r.json()
|
||||||
data = r.json()
|
|
||||||
if data.get("content"):
|
|
||||||
import ubinascii
|
|
||||||
|
|
||||||
content = ubinascii.a2b_base64(data["content"]).decode()
|
|
||||||
patterns = [line.strip() for line in content.splitlines()]
|
|
||||||
return [p for p in patterns if p and not p.startswith("#")]
|
|
||||||
else:
|
|
||||||
warn(f"Manifest not found at {OTA_MANIFEST}")
|
|
||||||
finally:
|
|
||||||
r.close()
|
r.close()
|
||||||
|
if data.get("content"):
|
||||||
|
import ubinascii
|
||||||
|
content = ubinascii.a2b_base64(data["content"]).decode()
|
||||||
|
patterns = [line.strip() for line in content.splitlines()]
|
||||||
|
return [p for p in patterns if p and not p.startswith("#")]
|
||||||
|
else:
|
||||||
|
warn(f"Manifest not found at {OTA_MANIFEST}")
|
||||||
|
r.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
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}"
|
||||||
@@ -223,7 +195,6 @@ 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:
|
||||||
@@ -238,7 +209,6 @@ 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"
|
||||||
@@ -266,7 +236,6 @@ 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:
|
||||||
@@ -274,7 +243,6 @@ 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:
|
||||||
@@ -284,7 +252,12 @@ 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():
|
||||||
|
try:
|
||||||
|
os.remove(MANIFEST_FILE)
|
||||||
|
info("Manifest wiped — full re-fetch on next update")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
def _ok_flag_exists():
|
def _ok_flag_exists():
|
||||||
try:
|
try:
|
||||||
@@ -293,14 +266,12 @@ 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.
|
||||||
@@ -312,12 +283,10 @@ 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
|
||||||
@@ -343,18 +312,18 @@ def _fetch_file_list():
|
|||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
name = entry["name"]
|
name = entry["name"]
|
||||||
|
if not name.endswith(".py"):
|
||||||
|
continue
|
||||||
for p in patterns:
|
for p in patterns:
|
||||||
p = p.rstrip("/")
|
p = p.rstrip("/")
|
||||||
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)
|
||||||
@@ -364,15 +333,12 @@ 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(
|
info(f"Checking {GITEA_BASE}/{REPO_OWNER}/{REPO_NAME}/{REPO_FOLDER} @ {REPO_BRANCH}")
|
||||||
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")
|
||||||
@@ -380,12 +346,12 @@ def _do_update(commit_sha=None):
|
|||||||
|
|
||||||
info(f"Found {len(file_list)} file(s) to sync")
|
info(f"Found {len(file_list)} file(s) to sync")
|
||||||
manifest = _load_manifest()
|
manifest = _load_manifest()
|
||||||
updated = []
|
updated = []
|
||||||
failed = []
|
failed = []
|
||||||
|
|
||||||
for entry in file_list:
|
for entry in file_list:
|
||||||
name = entry["name"]
|
name = entry["name"]
|
||||||
sha = entry["sha"]
|
sha = entry["sha"]
|
||||||
|
|
||||||
if manifest.get(name) == sha:
|
if manifest.get(name) == sha:
|
||||||
info(f" {name} up to date")
|
info(f" {name} up to date")
|
||||||
@@ -416,12 +382,10 @@ 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.
|
||||||
@@ -444,7 +408,9 @@ 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("Re-checking all files, will only download changed ones")
|
warn("Wiping manifest to force full re-fetch")
|
||||||
|
_wipe_manifest()
|
||||||
|
manifest = {}
|
||||||
else:
|
else:
|
||||||
info("OK flag present — last boot was good")
|
info("OK flag present — last boot was good")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user