From e706029c545614fbc9dff7fc57872a1abd2c7097 Mon Sep 17 00:00:00 2001 From: gbradham Date: Tue, 20 Oct 2026 14:16:42 -0500 Subject: [PATCH] feat(maimu): add an Off time to the Maimu node for duty-cycled pulses The node's Period is relabelled On (ms), still saved as period_ms so existing files open unchanged, and gains Off (ms). Off 0 keeps the original symmetric toggle; any other value sends "p,," through the device's write action, e.g. On 10 / Off 40 = 20 Hz with 10 ms pulses. Needs Maimu firmware 0.1.0+; older firmware ignores the 'p' command rather than misreading it. --- plugins/glider-maimu/src/glider_maimu/node.py | 43 +++++++++++++++++-- plugins/glider-maimu/tests/test_node.py | 27 ++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/plugins/glider-maimu/src/glider_maimu/node.py b/plugins/glider-maimu/src/glider_maimu/node.py index e6ac9f81..8affe288 100644 --- a/plugins/glider-maimu/src/glider_maimu/node.py +++ b/plugins/glider-maimu/src/glider_maimu/node.py @@ -23,6 +23,7 @@ MODES = (MODE_ON, MODE_OFF, MODE_PULSE) DEFAULT_PERIOD_MS = 500 +DEFAULT_OFF_MS = 0 # 0 = same as On: the original symmetric toggle DEFAULT_DURATION_S = 10 @@ -58,15 +59,30 @@ class MaimuNode(HardwareNode): "help": "On and Off latch; Pulse runs a train and stops on its own.", }, { + # Still saved as period_ms so existing files open unchanged: with + # Off at 0 it is the original toggle period, i.e. on time = off time. "key": "period_ms", - "label": "Period (ms)", + "label": "On (ms)", "type": "int", "default": DEFAULT_PERIOD_MS, "min": 1, "max": 3_600_000, "help": ( - "Pulse mode only. On/off toggle period in milliseconds -- a " - "period, not a frequency. 500 ms toggles about once a second." + "Pulse mode only. How long each pulse stays on. With Off at 0 " + "the light toggles every On ms. 20 Hz with 10 ms pulses: On 10, Off 40." + ), + }, + { + "key": "off_ms", + "label": "Off (ms)", + "type": "int", + "default": DEFAULT_OFF_MS, + "min": 0, + "max": 3_600_000, + "help": ( + "Pulse mode only. Gap between pulses; 0 = same as On. An Off " + "different from On needs Maimu firmware 0.1.0+ -- older " + "firmware ignores the command and stays dark." ), }, { @@ -109,6 +125,7 @@ def __init__(self): super().__init__() self._mode = MODE_PULSE self._period_ms = DEFAULT_PERIOD_MS + self._off_ms = DEFAULT_OFF_MS self._duration_s = DEFAULT_DURATION_S @property @@ -130,6 +147,14 @@ def period_ms(self) -> int: def period_ms(self, value: Any) -> None: self._period_ms = int(value) + @property + def off_ms(self) -> int: + return self._off_ms + + @off_ms.setter + def off_ms(self, value: Any) -> None: + self._off_ms = int(value) + @property def duration_s(self) -> int: return self._duration_s @@ -140,7 +165,15 @@ def duration_s(self, value: Any) -> None: async def hardware_operation(self) -> None: """Send the configured command, then trigger the exec output.""" - if self._mode == MODE_PULSE: + off_ms = self._off_ms or self._period_ms + if self._mode == MODE_PULSE and off_ms != self._period_ms: + # "p,," (firmware 0.1.0+). The 'p' makes + # older firmware ignore it rather than misread "10,40,10" as a + # 10 ms toggle for 40 s. + await self._device.execute_action( + "write", f"p{self._period_ms}", off_ms, self._duration_s + ) + elif self._mode == MODE_PULSE: await self._device.execute_action(MODE_PULSE, self._period_ms, self._duration_s) else: await self._device.execute_action(self._mode) @@ -151,6 +184,7 @@ def get_state(self) -> dict[str, Any]: state = super().get_state() state["mode"] = self._mode state["period_ms"] = self._period_ms + state["off_ms"] = self._off_ms state["duration_s"] = self._duration_s return state @@ -162,4 +196,5 @@ def set_state(self, state: dict[str, Any]) -> None: mode = str(state.get("mode", MODE_PULSE)).strip().lower() self._mode = mode if mode in MODES else MODE_PULSE self._period_ms = int(state.get("period_ms", DEFAULT_PERIOD_MS)) + self._off_ms = int(state.get("off_ms", DEFAULT_OFF_MS)) self._duration_s = int(state.get("duration_s", DEFAULT_DURATION_S)) diff --git a/plugins/glider-maimu/tests/test_node.py b/plugins/glider-maimu/tests/test_node.py index 6faf88f6..3ce2257b 100644 --- a/plugins/glider-maimu/tests/test_node.py +++ b/plugins/glider-maimu/tests/test_node.py @@ -64,6 +64,33 @@ async def test_pulse_passes_period_and_duration(): assert node._device.calls == [("pulse", (250, 5))] +async def test_off_zero_keeps_the_original_toggle(): + node = _node(mode="pulse", period_ms=25, duration_s=5) + node.off_ms = 0 + await node.hardware_operation() + assert node._device.calls == [("pulse", (25, 5))] + + +async def test_off_sends_the_duty_cycle_command(): + """10 ms on / 40 ms off is 20 Hz; firmware 0.1.0 parses "p,,".""" + node = _node(mode="pulse", period_ms=10, duration_s=10) + node.off_ms = 40 + await node.hardware_operation() + assert node._device.calls == [("write", ("p10", 40, 10))] + + +def test_off_round_trips_and_old_files_default_to_symmetric(): + node = _node(mode="pulse", period_ms=10, duration_s=10) + node.off_ms = 40 + restored = MaimuNode() + restored.set_state(node.get_state()) + assert (restored.period_ms, restored.off_ms) == (10, 40) + + old_file = MaimuNode() + old_file.set_state({"mode": "pulse", "period_ms": 25, "duration_s": 5}) + assert old_file.off_ms == 0 + + async def test_on_calls_bare_action(): node = _node(mode="on") await node.hardware_operation()