Experiment: 2 gauges

This commit is contained in:
2026-04-10 23:27:59 +02:00
parent 5fc1f0ab7e
commit 8ee2d3155d
2 changed files with 218 additions and 97 deletions

50
config.multi.example.json Normal file
View File

@@ -0,0 +1,50 @@
{
"wifi_ssid": "YourWiFiSSID",
"wifi_password": "YourWiFiPassword",
"mqtt_broker": "mqtt.example.com",
"mqtt_port": 1883,
"mqtt_user": "mqtt_user",
"mqtt_password": "mqtt_password",
"mqtt_client_id": "selsyn_multi",
"mqtt_prefix": "home/panels/chernobyl1",
"gauges": [
{
"name": "Gauge 1",
"pins": [12, 13],
"mode": "stepdir",
"min": 0,
"max": 7300,
"step_us": 200,
"entity_name": "Power 1",
"unit": "W"
},
{
"name": "Gauge 2",
"pins": [14, 15],
"mode": "stepdir",
"min": 0,
"max": 100,
"step_us": 200,
"entity_name": "Temperature",
"unit": "C"
}
],
"leds": {
"red_pin": 33,
"green_pin": 32,
"backlight_pin": 23
},
"device": {
"name": "Selsyn Multi Gauge",
"model": "Chernobyl Selsyn-inspired gauge",
"manufacturer": "AdeBaumann",
"area": "Control Panels"
},
"microsteps_per_second": 600,
"heartbeat_ms": 10000,
"rezero_interval_ms": 3600000
}

View File

@@ -83,34 +83,100 @@ MQTT_USER = _cfg["mqtt_user"]
MQTT_PASSWORD = _cfg["mqtt_password"] MQTT_PASSWORD = _cfg["mqtt_password"]
MQTT_CLIENT_ID = _cfg["mqtt_client_id"] MQTT_CLIENT_ID = _cfg["mqtt_client_id"]
MQTT_PREFIX = _cfg["mqtt_prefix"] MQTT_PREFIX = _cfg["mqtt_prefix"]
GAUGE_PINS = tuple(_cfg.get("gauge_pins", [12, 13]))
GAUGE_MODE = _cfg.get("gauge_mode", "stepdir")
GAUGE_MIN = float(_cfg.get("gauge_min", 0))
GAUGE_MAX = float(_cfg.get("gauge_max", 7300))
GAUGE_STEP_US = int(_cfg.get("gauge_step_us", 200))
MICROSTEPS_PER_SECOND = 600 # microsteps per second (adjustable)
MICROSTEPS_PER_SECOND = int(_cfg.get("microsteps_per_second", 600))
HEARTBEAT_MS = int(_cfg.get("heartbeat_ms", 10000)) HEARTBEAT_MS = int(_cfg.get("heartbeat_ms", 10000))
REZERO_INTERVAL_MS = int(_cfg.get("rezero_interval_ms", 3600000)) REZERO_INTERVAL_MS = int(_cfg.get("rezero_interval_ms", 3600000))
LED_RED_PIN = int(_cfg.get("led_red_pin", 33))
LED_GREEN_PIN = int(_cfg.get("led_green_pin", 32)) LED_RED_PIN = int(_cfg.get("leds", {}).get("red_pin", _cfg.get("led_red_pin", 33)))
LED_BL_PIN = int(_cfg.get("led_bl_pin", 23)) LED_GREEN_PIN = int(
DEVICE_NAME = _cfg.get("device_name", "Selsyn 1") _cfg.get("leds", {}).get("green_pin", _cfg.get("led_green_pin", 32))
DEVICE_MODEL = _cfg.get("device_model", "Chernobyl Selsyn-inspired gauge") )
DEVICE_MFR = _cfg.get("device_manufacturer", "AdeBaumann") LED_BL_PIN = int(_cfg.get("leds", {}).get("backlight_pin", _cfg.get("led_bl_pin", 23)))
DEVICE_AREA = _cfg.get("device_area", "Control Panels")
GAUGE_ENTITY = _cfg.get("gauge_entity_name", "Selsyn 1 Power") device_cfg = _cfg.get("device", {})
GAUGE_UNIT = _cfg.get("gauge_unit", "W") DEVICE_NAME = device_cfg.get("name", _cfg.get("device_name", "Selsyn Multi"))
RED_ENTITY = _cfg.get("red_led_entity_name", "Selsyn 1 Red LED") DEVICE_MODEL = device_cfg.get(
GREEN_ENTITY = _cfg.get("green_led_entity_name", "Selsyn 1 Green LED") "model", _cfg.get("device_model", "Chernobyl Selsyn-inspired gauge")
BL_ENTITY = _cfg.get("backlight_entity_name", "Selsyn 1 Backlight") )
DEVICE_MFR = device_cfg.get(
"manufacturer", _cfg.get("device_manufacturer", "AdeBaumann")
)
DEVICE_AREA = device_cfg.get("area", _cfg.get("device_area", "Control Panels"))
gauges = []
if "gauges" in _cfg:
for i, g in enumerate(_cfg["gauges"]):
gauges.append(
{
"id": i,
"name": g.get("name", f"Gauge {i + 1}"),
"pins": tuple(g.get("pins", [12, 13])),
"mode": g.get("mode", "stepdir"),
"min": float(g.get("min", 0)),
"max": float(g.get("max", 100)),
"step_us": int(g.get("step_us", 200)),
"entity_name": g.get("entity_name", f"Gauge {i + 1}"),
"unit": g.get("unit", ""),
}
)
else:
gauges.append(
{
"id": 0,
"name": "Gauge 1",
"pins": tuple(_cfg.get("gauge_pins", [12, 13])),
"mode": _cfg.get("gauge_mode", "stepdir"),
"min": float(_cfg.get("gauge_min", 0)),
"max": float(_cfg.get("gauge_max", 7300)),
"step_us": int(_cfg.get("gauge_step_us", 200)),
"entity_name": _cfg.get("gauge_entity_name", "Selsyn 1 Power"),
"unit": _cfg.get("gauge_unit", "W"),
}
)
BL_ENTITY = _cfg.get("backlight_entity_name", "Selsyn Backlight")
BL_UNIT = _cfg.get("backlight_unit", "%") BL_UNIT = _cfg.get("backlight_unit", "%")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Topics # Gauge initialization
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
gauge_objects = []
for g in gauges:
gauge_objects.append(
Gauge(
pins=g["pins"],
mode=g["mode"],
min_val=g["min"],
max_val=g["max"],
step_us=g["step_us"],
)
)
info(
f"Gauge {g['id']}: {g['name']} pins={g['pins']} mode={g['mode']} range=[{g['min']}, {g['max']}]"
)
gauge_targets = [g["min"] for g in gauges] # target value per gauge
gauge_last_rezero = [utime.ticks_ms() for _ in gauges]
# ---------------------------------------------------------------------------
# Topics (per-gauge)
# ---------------------------------------------------------------------------
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",
}
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"
T_STATUS = f"{MQTT_PREFIX}/status" T_STATUS = f"{MQTT_PREFIX}/status"
@@ -333,12 +399,29 @@ def on_message(topic, payload):
payload = payload.decode().strip() payload = payload.decode().strip()
info(f"MQTT rx {topic} {payload}") info(f"MQTT rx {topic} {payload}")
for i, gt in enumerate(gauge_topics):
if topic == gt["zero"]:
info(f"Zero command received for gauge {i}")
gauge_objects[i].zero()
gauge_last_rezero[i] = utime.ticks_ms()
info(f"Zero complete gauge {i}")
return
if topic == gt["set"]:
g = gauges[i]
try:
gauge_targets[i] = max(g["min"], min(g["max"], float(payload)))
info(f"Gauge {i} target → {gauge_targets[i]:.1f}")
except ValueError:
warn(f"Invalid set value for gauge {i}: '{payload}'")
return
if topic == T_ZERO: if topic == T_ZERO:
global _last_rezero_ms for i, g in enumerate(gauge_objects):
info("Zero command received") info(f"Zeroing all gauges")
gauge.zero() g.zero()
_last_rezero_ms = utime.ticks_ms() gauge_last_rezero[i] = utime.ticks_ms()
info("Zero complete") info("All gauges zeroed")
return return
if topic == T_LED_RED: if topic == T_LED_RED:
@@ -394,14 +477,6 @@ def on_message(topic, payload):
info(f"Backlight → {color_hex} @ {brightness}%") info(f"Backlight → {color_hex} @ {brightness}%")
return return
if topic == T_SET:
global _target_value
try:
_target_value = max(GAUGE_MIN, min(GAUGE_MAX, float(payload)))
info(f"New target → {_target_value:.1f}")
except ValueError:
warn(f"Invalid set value: '{payload}'")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# MQTT connect + discovery # MQTT connect + discovery
@@ -428,6 +503,9 @@ def connect_mqtt():
client.subscribe(T_LED_RED) client.subscribe(T_LED_RED)
client.subscribe(T_LED_GREEN) client.subscribe(T_LED_GREEN)
client.subscribe(T_LED_BL) client.subscribe(T_LED_BL)
for gt in gauge_topics:
client.subscribe(gt["set"])
client.subscribe(gt["zero"])
_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 return client
@@ -475,6 +553,9 @@ def check_mqtt():
client_ref.subscribe(T_LED_RED) client_ref.subscribe(T_LED_RED)
client_ref.subscribe(T_LED_GREEN) client_ref.subscribe(T_LED_GREEN)
client_ref.subscribe(T_LED_BL) client_ref.subscribe(T_LED_BL)
for gt in gauge_topics:
client_ref.subscribe(gt["set"])
client_ref.subscribe(gt["zero"])
_mqtt_connected = True _mqtt_connected = True
info("MQTT reconnected!") info("MQTT reconnected!")
publish_discovery(client_ref) publish_discovery(client_ref)
@@ -489,36 +570,37 @@ def check_mqtt():
def publish_discovery(client): def publish_discovery(client):
"""Publish all HA MQTT discovery payloads using short-form keys to stay under 512 bytes.""" """Publish all HA MQTT discovery payloads for gauges and LEDs."""
# Full device block only on first payload; subsequent use identifiers-only ref
_dev_ref = {"identifiers": [MQTT_CLIENT_ID]} _dev_ref = {"identifiers": [MQTT_CLIENT_ID]}
for i, g in enumerate(gauges):
gt = gauge_topics[i]
client.publish( client.publish(
T_DISC_GAUGE, gt["disc"],
ujson.dumps( ujson.dumps(
{ {
"name": GAUGE_ENTITY, "name": g["entity_name"],
"unique_id": MQTT_CLIENT_ID, "unique_id": f"{MQTT_CLIENT_ID}_g{i}",
"cmd_t": T_SET, "cmd_t": gt["set"],
"stat_t": T_STATE, "stat_t": gt["state"],
"avty_t": T_STATUS, "avty_t": gt["status"],
"min": GAUGE_MIN, "min": g["min"],
"max": GAUGE_MAX, "max": g["max"],
"step": 1, "step": 1,
"unit_of_meas": GAUGE_UNIT, "unit_of_meas": g["unit"],
"icon": "mdi:gauge", "icon": "mdi:gauge",
"dev": _DEVICE, "dev": _dev_ref,
} }
), ),
retain=True, retain=True,
) )
info("Discovery: gauge") info(f"Discovery: gauge {i} ({g['name']})")
client.publish( client.publish(
T_DISC_RED, T_DISC_RED,
ujson.dumps( ujson.dumps(
{ {
"name": RED_ENTITY, "name": "Red LED",
"uniq_id": f"{MQTT_CLIENT_ID}_led_red", "uniq_id": f"{MQTT_CLIENT_ID}_led_red",
"cmd_t": T_LED_RED, "cmd_t": T_LED_RED,
"stat_t": T_LED_RED_STATE, "stat_t": T_LED_RED_STATE,
@@ -537,7 +619,7 @@ def publish_discovery(client):
T_DISC_GREEN, T_DISC_GREEN,
ujson.dumps( ujson.dumps(
{ {
"name": GREEN_ENTITY, "name": "Green LED",
"uniq_id": f"{MQTT_CLIENT_ID}_led_green", "uniq_id": f"{MQTT_CLIENT_ID}_led_green",
"cmd_t": T_LED_GREEN, "cmd_t": T_LED_GREEN,
"stat_t": T_LED_GREEN_STATE, "stat_t": T_LED_GREEN_STATE,
@@ -573,10 +655,12 @@ def publish_discovery(client):
def publish_state(client): def publish_state(client):
val = gauge.get() for i, g in enumerate(gauge_objects):
client.publish(T_STATE, str(round(val, 1)), retain=True) gt = gauge_topics[i]
client.publish(T_STATUS, "online", retain=True) val = g.get()
info(f"State published value={val:.1f} step={gauge._current_step}") 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}")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -591,11 +675,12 @@ def main():
connect_wifi(WIFI_SSID, WIFI_PASSWORD) connect_wifi(WIFI_SSID, WIFI_PASSWORD)
info("Zeroing gauge on startup ...") info("Zeroing gauges on startup ...")
gauge.zero() for i, g in enumerate(gauge_objects):
g.zero()
info(f"Zeroed gauge {i}")
info("Zero complete") info("Zero complete")
# umqtt.robust handles reconnection automatically — just connect once
connect_mqtt() connect_mqtt()
publish_discovery(client_ref) publish_discovery(client_ref)
publish_state(client_ref) publish_state(client_ref)
@@ -611,13 +696,9 @@ def main():
except ImportError: except ImportError:
pass pass
global _last_rezero_ms, _bl_dirty_since global _bl_dirty_since
last_heartbeat = utime.ticks_ms() last_heartbeat = utime.ticks_ms()
_last_rezero_ms = utime.ticks_ms()
target_step = gauge._val_to_step(_target_value)
# Period at which to publish state updates during movement
MOVE_STATE_INTERVAL_MS = 500 MOVE_STATE_INTERVAL_MS = 500
last_move_state = utime.ticks_ms() last_move_state = utime.ticks_ms()
@@ -633,45 +714,39 @@ def main():
now = utime.ticks_ms() now = utime.ticks_ms()
# Continuously move towards target at constant speed moved_any = False
current_target = gauge._val_to_step(_target_value) for i, g in enumerate(gauge_objects):
moved = False current_target = g._val_to_step(gauge_targets[i])
if current_target != gauge._current_step: if current_target != g._current_step:
direction = 1 if current_target > gauge._current_step else -1 direction = 1 if current_target > g._current_step else -1
gauge.step(direction) g.step(direction)
moved = True moved_any = True
# Publish state during movement at intervals
if ( if (
moved moved_any
and utime.ticks_diff(now, last_move_state) >= MOVE_STATE_INTERVAL_MS and utime.ticks_diff(now, last_move_state) >= MOVE_STATE_INTERVAL_MS
): ):
publish_state(client_ref) publish_state(client_ref)
last_move_state = now last_move_state = now
# Sleep to achieve constant speed if moved_any:
if moved or (
current_target == gauge._current_step
and gauge._current_step != gauge._val_to_step(_target_value)
):
delay_us = 1_000_000 // MICROSTEPS_PER_SECOND delay_us = 1_000_000 // MICROSTEPS_PER_SECOND
utime.sleep_us(delay_us) utime.sleep_us(delay_us)
# Periodic auto-rezero (disabled when interval is 0)
if ( if (
REZERO_INTERVAL_MS > 0 REZERO_INTERVAL_MS > 0
and utime.ticks_diff(now, _last_rezero_ms) >= REZERO_INTERVAL_MS and utime.ticks_diff(now, gauge_last_rezero[0]) >= REZERO_INTERVAL_MS
): ):
info("Auto-rezero triggered") for i, g in enumerate(gauge_objects):
saved = _target_value info(f"Auto-rezero gauge {i}")
gauge.zero() saved = gauge_targets[i]
if saved > GAUGE_MIN: g.zero()
gauge.set(saved) if saved > gauges[i]["min"]:
g.set(saved)
gauge_last_rezero[i] = now
publish_state(client_ref) publish_state(client_ref)
_last_rezero_ms = now info("Auto-rezero complete")
info(f"Auto-rezero complete, restored to {saved:.1f}")
# Retain backlight state via MQTT after settling
if ( if (
_bl_dirty_since is not None _bl_dirty_since is not None
and utime.ticks_diff(now, _bl_dirty_since) >= _BL_SAVE_DELAY_MS and utime.ticks_diff(now, _bl_dirty_since) >= _BL_SAVE_DELAY_MS
@@ -679,12 +754,8 @@ def main():
_flush_backlight(client_ref) _flush_backlight(client_ref)
_bl_dirty_since = None _bl_dirty_since = None
# Heartbeat
if utime.ticks_diff(now, last_heartbeat) >= HEARTBEAT_MS: if utime.ticks_diff(now, last_heartbeat) >= HEARTBEAT_MS:
publish_state(client_ref) publish_state(client_ref)
info(
f"step={gauge._current_step} phase={gauge._phase} target_step={gauge._val_to_step(_target_value)} expected_phase={gauge._current_step % 4}"
)
last_heartbeat = now last_heartbeat = now
except Exception as e: except Exception as e: