Source code for openbricks.bluetooth_button

# SPDX-License-Identifier: MIT
"""
Short press on the BLE-toggle button toggles BLE on/off.

Wires a ``machine.Timer``-driven poll loop (default 50 ms) against a
``Button``-conformant object and calls ``openbricks.bluetooth.toggle()``
once per press-release cycle. State is persisted via NVS by the
``bluetooth`` module, so the new value survives reboots.

The same poll loop doubles as the **run indicator**: while a user
program is executing (``launcher.program_running()``), the status LED
flashes at 2 Hz instead of holding a solid colour — blue when BLE is
on, yellow when it's off, plain on/off blinking on single-colour
LEDs. When the program stops, the LED returns to its idle state
(solid state colour on RGB hubs, dark on single-colour hubs). And it
renders the **press acknowledgment**: every program-button press
flashes the LED for a moment — red for the press that starts a run,
green for the press that stops one (``notify_press()``, called by
the launcher's press detectors).

This is a different physical button from the one ``openbricks.launcher``
watches for program start/stop — the BLE toggle lives on its own
GPIO (default 38 on the S3, 5 on the classic ESP32 — see :class:`openbricks.hub.Hub`) while the program
button is on GPIO 39. Two pins → no duration-based dispatch, every
press on this pin means "flip BLE".

Usage from ``main.py``:

    from openbricks import bluetooth
    from openbricks.bluetooth_button import BluetoothToggleButton
    from openbricks.hub import ESP32DevkitHub

    bluetooth.apply_persisted_state()
    hub = ESP32DevkitHub()
    BluetoothToggleButton(hub.bluetooth_button).start()

The helper is deliberately standalone (not baked into ``Hub``) so tests
can exercise it in isolation, and so boards without a button — or
users who want to drive the toggle from something other than a
physical press — can skip it.
"""

from machine import Timer


DEFAULT_POLL_MS = 50

# BLE on → blue, off → yellow. Picked for high contrast on the WS2812;
# override via ``BluetoothToggleButton(..., color_on=..., color_off=...)``
# if you want different hues.
DEFAULT_COLOR_ON  = (0, 0, 255)       # blue
DEFAULT_COLOR_OFF = (255, 200, 0)     # yellow

# Run-indicator blink: while a program runs the LED alternates
# state-colour / off, switching phase every RUN_BLINK_MS. 250 ms per
# phase = 2 Hz — visibly busier than the solid idle colour (was
# 500 ms/1 Hz until 1.80.0; raised so "running" reads as activity at
# a glance).
RUN_BLINK_MS = 250

# Press acknowledgment: every program-button press paints the LED
# for PRESS_FLASH_MS before the normal presentation resumes — RED
# for the press that starts a run, GREEN for the press that stops
# one. The launcher's press detectors call notify_press(); the
# toggle's poll tick renders it, so the flash needs no timer of its
# own.
PRESS_FLASH_MS = 200
PRESS_COLOR_START = (255, 0, 0)     # red
PRESS_COLOR_STOP  = (0, 255, 0)     # green

_press_events = 0
_press_color = PRESS_COLOR_START


[docs] def notify_press(stop=False): """Record a program-button press. ``stop=True`` marks it as the press that stops a run (green flash); the default is a start press (red). The active :class:`BluetoothToggleButton` renders it on its next poll tick. Safe from any context — it only sets two module variables.""" global _press_events, _press_color _press_color = PRESS_COLOR_STOP if stop else PRESS_COLOR_START _press_events += 1
def _launcher_program_running(): """Default run-state probe: the launcher's module-level flag. Imported lazily so constructing a ``BluetoothToggleButton`` never drags the launcher in on hosts/tests that don't use it.""" from openbricks import launcher return launcher.program_running()
[docs] class BluetoothToggleButton: """Polls a button and toggles BLE on each press-release cycle. Optional RGB LED feedback (blue = BLE on, yellow = off). Call ``start()`` to begin polling on a ``machine.Timer``; ``stop()`` releases the timer. The toggled state persists across reboots. """ def __init__(self, button, led=None, poll_ms=DEFAULT_POLL_MS, timer_id=1, color_on=DEFAULT_COLOR_ON, color_off=DEFAULT_COLOR_OFF, program_running=None): """ Args: button: any object with a ``.pressed() -> bool`` method (the ``Button`` / ``PushButton`` from ``openbricks.hub`` both qualify). led: optional RGB-capable ``StatusLED`` (i.e. one whose ``.rgb(r, g, b)`` is implemented — the ``NeoPixelLED`` on the S3 DevKitC-1 qualifies). When provided, ``start()`` immediately colours the LED based on the current persisted BLE state (blue = on, yellow = off) and each toggle recolours it. Pass ``None`` to skip LED feedback. poll_ms: polling period. Default 50 ms (20 Hz) — well under human reaction time, negligible CPU. timer_id: ``machine.Timer`` hardware ID (0..3 on ESP32-S3). Default 1 — the reserved inventory is 0 = launcher poll, 1 = this toggle, 2 = motor_process, 3 = the launcher's stop tick (``STOP_TIMER_ID``). The previous default ``-1`` (virtual timer) was supported by older MicroPython but raises ``ValueError: invalid Timer number`` on the v1.27+ MP we vendor. color_on, color_off: ``(r, g, b)`` tuples the LED is set to when BLE is enabled / disabled. Defaults: blue / yellow. program_running: zero-arg callable returning True while a user program executes — drives the 2 Hz run-indicator blink. Defaults to ``launcher.program_running`` (the flag every exec path maintains); tests inject their own probe. """ self._button = button self._led = led self._poll_ms = int(poll_ms) self._timer_id = timer_id self._color_on = tuple(color_on) self._color_off = tuple(color_off) self._timer = None self._program_running = (program_running if program_running is not None else _launcher_program_running) # Run-indicator blink state. ``_blink_ticks`` converts the # wall-clock phase length into poll ticks (≥1 so a huge # poll_ms still blinks rather than dividing to zero). self._blink_ticks = max(1, RUN_BLINK_MS // self._poll_ms) self._blink_count = 0 self._blink_lit = True self._running_seen = False # Press-flash state: consume notify_press() events by counter # comparison (no shared flags to clear from other contexts). self._press_seen = _press_events self._press_flash_left = 0 # Stable callback object for bluetooth.add/remove_state_listener. # A bound method (``self._on_state_change``) is a fresh object on # every attribute access under MicroPython, where bound methods # compare by identity — remove() would never match. A closure # created once here keeps its identity for the toggle's lifetime. self._state_listener = ( lambda enabled: self._paint(bool(enabled))) # Edge-detection state: True from the moment we first saw the # button pressed until the subsequent release (when we fire). self._was_pressed = False # ---- lifecycle ----
[docs] def start(self): """Begin polling. Safe to call repeatedly — the second call is a no-op. On first call, paints the LED (if one was provided) to reflect the current persisted BLE state, and registers with ``bluetooth.add_state_listener`` so the LED also follows state changes made *without* the button — ``bluetooth.set_enabled`` from user code or a tool over the REPL. """ if self._timer is not None: return from openbricks import bluetooth bluetooth.add_state_listener(self._state_listener) self._apply_led_for_current_state() self._timer = Timer(self._timer_id) self._timer.init( period=self._poll_ms, mode=Timer.PERIODIC, callback=self._on_tick, )
[docs] def stop(self): """Stop polling, release the timer, and unregister the LED state listener.""" if self._timer is None: return from openbricks import bluetooth bluetooth.remove_state_listener(self._state_listener) self._timer.deinit() self._timer = None self._was_pressed = False self._blink_count = 0 self._blink_lit = True self._running_seen = False self._press_flash_left = 0
# ---- tick body ---- def _on_tick(self, _timer): try: self._on_tick_body() except KeyboardInterrupt: # Same relay as launcher._tick: a hard-button stop # interrupt that lands in this poll callback must be # re-posted, not eaten with the callback's unwind. from openbricks.launcher import _resignal_stop_interrupt _resignal_stop_interrupt() def _on_tick_body(self): if self._button.pressed(): self._was_pressed = True elif self._was_pressed: # Release after a press — fire once. self._was_pressed = False self._fire() # The red press flash outranks the run indicator for its # short window; the indicator rides every other tick — # including ticks where the button is held, so a press # mid-run doesn't freeze the blink. if self._press_flash_tick(): return self._run_indicator_tick() # ---- press acknowledgment (red flash on the program button) ---- def _press_flash_tick(self): """Render the red press-acknowledgment window. Returns True while the flash owns the LED (the run indicator pauses underneath and re-enters cleanly afterwards).""" if self._press_seen != _press_events: self._press_seen = _press_events if self._led is None: return False self._press_flash_left = max( 1, PRESS_FLASH_MS // self._poll_ms) self._press_show() return True if not self._press_flash_left: return False self._press_flash_left -= 1 if self._press_flash_left: return True # Window over: hand the LED back. Clearing _running_seen # makes the run indicator re-enter with a fresh lit phase on # this same tick; at idle, repaint the solid state colour. if self._program_running(): self._running_seen = False else: self._restore_idle_led() return False def _press_show(self): try: self._led.rgb(*_press_color) except NotImplementedError: self._led.on() # ---- run indicator (2 Hz blink while a program executes) ---- def _run_indicator_tick(self): """Blink the LED while a user program runs; restore the idle state when it stops. Lit phase re-reads the BLE state each time, so toggling BLE mid-run switches the blink colour within one phase. On single-colour LEDs the blink is plain on/off and idle is dark (matching their existing idle state — ``_paint`` never touches them).""" if self._led is None: return if not self._program_running(): if self._running_seen: self._running_seen = False self._restore_idle_led() return if not self._running_seen: # Program just started: begin the lit phase immediately so # the indicator reacts within one poll tick. self._running_seen = True self._blink_count = 0 self._blink_lit = True self._blink_show() return self._blink_count += 1 if self._blink_count >= self._blink_ticks: self._blink_count = 0 self._blink_lit = not self._blink_lit self._blink_show() def _blink_show(self): """Render the current blink phase: state colour (or plain on) when lit, off when dark.""" if not self._blink_lit: self._led.off() return from openbricks import bluetooth color = (self._color_on if bluetooth.is_enabled() else self._color_off) try: self._led.rgb(*color) except NotImplementedError: self._led.on() def _restore_idle_led(self): """Program ended: back to the idle presentation. RGB hubs show the solid BLE-state colour; single-colour LEDs go dark (their idle state — ``_paint`` no-ops on them, so ``off`` is the only way a stale lit phase gets cleaned up).""" from openbricks import bluetooth color = (self._color_on if bluetooth.is_enabled() else self._color_off) try: self._led.rgb(*color) except NotImplementedError: self._led.off() def _fire(self): # Imported inside the method so tests that don't install the BLE # fake don't explode at module-load time. In production both # imports succeed because the firmware freezes the module in. # The LED repaint rides the state listener registered in # ``start()`` — ``toggle()`` fires it. from openbricks import bluetooth bluetooth.toggle() def _apply_led_for_current_state(self): """Paint the LED to match the current persisted BLE state. Early-out with no LED so LED-less hubs skip the NVS read.""" if self._led is None: return from openbricks import bluetooth self._paint(bluetooth.is_enabled()) def _paint(self, enabled): """Paint the LED for ``enabled``, if an RGB-capable LED was provided. Silently no-ops on plain on/off LEDs (whose ``.rgb()`` raises ``NotImplementedError``) so the hub can pass ``self.led`` unconditionally without caring which variant it is.""" if self._led is None: return color = self._color_on if enabled else self._color_off try: self._led.rgb(*color) except NotImplementedError: pass