From e6e1649c4f82f893cf18f35f687d926a35536aa2 Mon Sep 17 00:00:00 2001 From: Edward Chamberlain Date: Thu, 24 Sep 2026 15:35:16 +0100 Subject: [PATCH 1/2] fix: retain plate-clear presses after request failures (#39) --- micro/app_main.py | 87 +++++++++++-- micro/bambuddy_api.py | 20 +++ micro/wifi.py | 24 ++++ tests/test_app_main.py | 247 +++++++++++++++++++++++++++++++++++++ tests/test_bambuddy_api.py | 148 ++++++++++++++++++++++ tests/test_wifi.py | 42 +++++++ 6 files changed, 555 insertions(+), 13 deletions(-) create mode 100644 tests/test_app_main.py create mode 100644 tests/test_bambuddy_api.py diff --git a/micro/app_main.py b/micro/app_main.py index 0db1efc..0138e03 100644 --- a/micro/app_main.py +++ b/micro/app_main.py @@ -15,9 +15,13 @@ # -- Runtime Flags --- PRINTER_AWAITING_PLATE_CLEAR = False PENDING_BUTTON_PRESS = False +BUTTON_PRESS_NEEDS_RECONCILIATION = False +BUTTON_PRESS_RETRY_AT_MS = None CHAMBER_LIGHT_IS_ON = True PRINTER_STATUS_UPDATE_REQUIRED = True network = None +BUTTON_PRESS_RETRY_DELAY_MS = 5_000 +AP_RETRY_INTERVAL_MS = wifi.AP_RETRY_INTERVAL_SECONDS * 1000 def should_flash_connection_failure(): @@ -27,7 +31,7 @@ def should_flash_connection_failure(): def should_flash_plate_clear(): - return PRINTER_AWAITING_PLATE_CLEAR and not PENDING_BUTTON_PRESS + return PRINTER_AWAITING_PLATE_CLEAR or PENDING_BUTTON_PRESS # Feed this from the main loop and during each bounded Wi-Fi attempt/backoff. @@ -49,12 +53,14 @@ def should_flash_plate_clear(): def IRQ_button_press(pin): global PENDING_BUTTON_PRESS global PRINTER_AWAITING_PLATE_CLEAR + global BUTTON_PRESS_NEEDS_RECONCILIATION, BUTTON_PRESS_RETRY_AT_MS - # This runs in the hardware interrupt. Keep it allocation-free so the LED - # stops flashing even while a status request is still timing out. + # Keep the interrupt allocation-free and retain one intent until confirmed. if PRINTER_AWAITING_PLATE_CLEAR: PRINTER_AWAITING_PLATE_CLEAR = False PENDING_BUTTON_PRESS = True + BUTTON_PRESS_NEEDS_RECONCILIATION = False + BUTTON_PRESS_RETRY_AT_MS = None button = gpio_button.GPIOButton( @@ -77,6 +83,7 @@ def IRQ_button_press(pin): timeout_seconds=config["wifi"]["timeout_seconds"], ) network.connect_with_fallback(watchdog_feed=watchdog.feed) +last_ap_retry_ms = time.ticks_ms() if network.is_ap_mode(): print("Wi-Fi unavailable; connect to the setup access point to update settings") flasher.on() @@ -114,31 +121,77 @@ def with_network_connection(request): def handle_pending_button_press(): - global PENDING_BUTTON_PRESS - global PRINTER_STATUS_UPDATE_REQUIRED + global PENDING_BUTTON_PRESS, BUTTON_PRESS_NEEDS_RECONCILIATION + global BUTTON_PRESS_RETRY_AT_MS + global PRINTER_AWAITING_PLATE_CLEAR, PRINTER_STATUS_UPDATE_REQUIRED + + if not PENDING_BUTTON_PRESS: + return + if ( + BUTTON_PRESS_RETRY_AT_MS is not None + and time.ticks_diff(BUTTON_PRESS_RETRY_AT_MS, time.ticks_ms()) > 0 + ): + return try: - with_network_connection( - lambda: api.clear_plate(config["printer"]["id"]) + if BUTTON_PRESS_NEEDS_RECONCILIATION: + response = with_network_connection( + lambda: api.get_printer_status(config["printer"]["id"]) + ) + apply_printer_status(response) + BUTTON_PRESS_NEEDS_RECONCILIATION = False + if not PENDING_BUTTON_PRESS: + return + + outcome = with_network_connection( + lambda: api.clear_plate_with_reconciliation(config["printer"]["id"]) + ) + if outcome["status"] is not None: + apply_printer_status(outcome["status"]) + if outcome["resolved"]: + PENDING_BUTTON_PRESS = False + PRINTER_AWAITING_PLATE_CLEAR = False + BUTTON_PRESS_NEEDS_RECONCILIATION = False + BUTTON_PRESS_RETRY_AT_MS = None + PRINTER_STATUS_UPDATE_REQUIRED = outcome["status"] is None + return + + print("Plate-clear request was not applied:", outcome["request_error"]) + BUTTON_PRESS_RETRY_AT_MS = time.ticks_add( + time.ticks_ms(), BUTTON_PRESS_RETRY_DELAY_MS ) - PRINTER_STATUS_UPDATE_REQUIRED = True except Exception as exc: print("Failed to send plate clear request:", exc) + BUTTON_PRESS_NEEDS_RECONCILIATION = True + BUTTON_PRESS_RETRY_AT_MS = time.ticks_add( + time.ticks_ms(), BUTTON_PRESS_RETRY_DELAY_MS + ) + PRINTER_STATUS_UPDATE_REQUIRED = True + - PENDING_BUTTON_PRESS = False +def apply_printer_status(response): + global PRINTER_AWAITING_PLATE_CLEAR, CHAMBER_LIGHT_IS_ON + global PENDING_BUTTON_PRESS, BUTTON_PRESS_NEEDS_RECONCILIATION + + PRINTER_AWAITING_PLATE_CLEAR = response["awaiting_plate_clear"] + CHAMBER_LIGHT_IS_ON = response["chamber_light"] + if PENDING_BUTTON_PRESS: + if PRINTER_AWAITING_PLATE_CLEAR: + BUTTON_PRESS_NEEDS_RECONCILIATION = False + else: + PENDING_BUTTON_PRESS = False + BUTTON_PRESS_NEEDS_RECONCILIATION = False def handle_printer_status_update(): - global PRINTER_AWAITING_PLATE_CLEAR, PRINTER_STATUS_UPDATE_REQUIRED - global CHAMBER_LIGHT_IS_ON + global PRINTER_STATUS_UPDATE_REQUIRED try: response = with_network_connection( lambda: api.get_printer_status(config["printer"]["id"]) ) - PRINTER_AWAITING_PLATE_CLEAR = response["awaiting_plate_clear"] - CHAMBER_LIGHT_IS_ON = response["chamber_light"] + apply_printer_status(response) print( "Printer awaiting plate clear:", @@ -197,6 +250,14 @@ def debug_status(): restart_requested = web_server is not None and web_server.poll() + if ( + network.is_ap_mode() + and time.ticks_diff(time.ticks_ms(), last_ap_retry_ms) + >= AP_RETRY_INTERVAL_MS + ): + network.retry_station_from_access_point(watchdog_feed=watchdog.feed) + last_ap_retry_ms = time.ticks_ms() + # Push button press to API if pending if not restart_requested and not network.is_ap_mode() and PENDING_BUTTON_PRESS: handle_pending_button_press() diff --git a/micro/bambuddy_api.py b/micro/bambuddy_api.py index c22220d..35be1cf 100644 --- a/micro/bambuddy_api.py +++ b/micro/bambuddy_api.py @@ -82,6 +82,26 @@ def printer_is_awaiting_plate_clear(self, printer_id): def clear_plate(self, printer_id): return self._post(f"printers/{printer_id}/clear-plate") + def clear_plate_with_reconciliation(self, printer_id): + """Keep a failed clear pending unless printer status proves it landed.""" + try: + self.clear_plate(printer_id) + return {"resolved": True, "status": None, "request_error": None} + except Exception as request_error: + status = self.get_printer_status(printer_id) + if ( + not isinstance(status, dict) + or not isinstance(status.get("awaiting_plate_clear"), bool) + ): + raise BambuddyAPIError( + "Could not verify plate-clear request outcome" + ) + return { + "resolved": not status["awaiting_plate_clear"], + "status": status, + "request_error": request_error, + } + def chamber_light_is_lit(self, printer_id): status = self.get_printer_status(printer_id) return status.get("chamber_light", False) diff --git a/micro/wifi.py b/micro/wifi.py index 3a45dd3..831367f 100644 --- a/micro/wifi.py +++ b/micro/wifi.py @@ -4,6 +4,7 @@ DEFAULT_HOSTNAME = "bambutton" RETRY_DELAY_SECONDS = 10 +AP_RETRY_INTERVAL_SECONDS = 30 DEFAULT_AP_PASSWORD = "bambutton" STA_IF = getattr(network, "STA_IF", getattr(network.WLAN, "IF_STA", 0)) AP_IF = getattr(network, "AP_IF", getattr(network.WLAN, "IF_AP", 1)) @@ -82,6 +83,29 @@ def ensure_connected(self, watchdog_feed=None, fallback_to_ap=False): return self.connect_with_fallback(watchdog_feed=watchdog_feed) return self.connect_forever(watchdog_feed=watchdog_feed) + def retry_station_from_access_point(self, watchdog_feed=None): + if not self.is_ap_mode(): + return self.is_connected() + + access_point = self.ap + try: + station = self.connect(watchdog_feed=watchdog_feed) + except Exception as exc: + print("Wi-Fi station retry failed:", exc) + try: + self.disconnect() + except Exception: + pass + return access_point + + try: + access_point.active(False) + except Exception as exc: + print("Could not disable setup access point:", exc) + return access_point + self.ap = None + return station + def disconnect(self): self.wlan.disconnect() diff --git a/tests/test_app_main.py b/tests/test_app_main.py new file mode 100644 index 0000000..55efdc8 --- /dev/null +++ b/tests/test_app_main.py @@ -0,0 +1,247 @@ +import sys +from pathlib import Path +from types import SimpleNamespace + + +APP_MAIN = Path(__file__).parents[1] / "micro" / "app_main.py" + + +class StopAppLoop(BaseException): + pass + + +class FakeAPI: + def __init__(self): + self.clear_outcomes = [] + self.statuses = [] + self.clear_calls = 0 + self.status_calls = 0 + + def clear_plate_with_reconciliation(self, printer_id): + self.clear_calls += 1 + result = self.clear_outcomes.pop(0) + if isinstance(result, BaseException): + status = self.get_printer_status(printer_id) + return { + "resolved": not status["awaiting_plate_clear"], + "status": status, + "request_error": result, + } + return result + + def get_printer_status(self, printer_id): + self.status_calls += 1 + result = self.statuses.pop(0) + if isinstance(result, BaseException): + raise result + return result + + +def load_app_main(monkeypatch, api, advance_to_ap_retry=False, recover_wifi=True): + clock = {"ms": 0, "sleeps": 0} + button_handlers = [] + + class FakeNetwork: + def __init__(self): + self.ap_mode = True + self.retry_calls = 0 + + def connect_with_fallback(self, watchdog_feed=None): + return self + + def is_ap_mode(self): + return self.ap_mode + + def is_connected(self): + return not self.ap_mode + + def ensure_connected(self, watchdog_feed=None, fallback_to_ap=False): + if self.ap_mode: + return self + return self + + def retry_station_from_access_point(self, watchdog_feed=None): + self.retry_calls += 1 + self.ap_mode = not recover_wifi + return self + + def ifconfig(self): + return ("192.168.4.1", "255.255.255.0", "192.168.4.1", "192.168.4.1") + + def mode(self): + return "access point" if self.ap_mode else "station" + + network = FakeNetwork() + config = { + "wifi": {"ssid": "shop", "password": "password", "hostname": "button", + "timeout_seconds": 1}, + "api": {"key": "key", "base_url": "http://bambuddy/api/v1", + "request_timeout_seconds": 1}, + "printer": {"id": 7, "poll_interval_seconds": 5}, + "led": {"pin": 3, "flash_interval_ms": 250}, + "button": {"pin": 4, "debounce_ms": 100, "pull": "down", + "trigger": "rising"}, + } + + class FakeWDT: + def __init__(self, timeout): + self.timeout = timeout + + def feed(self): + return None + + class FakeButton: + def __init__(self, on_press, **kwargs): + button_handlers.append(on_press) + + def start(self): + return None + + class FakeFlasher: + def __init__(self, should_flash, **kwargs): + self.should_flash = should_flash + + def start(self): + return None + + def on(self): + return None + + class FakeOTA: + def __init__(self, reset=None): + return None + + def status(self): + return {"current_version": "test"} + + def confirm_boot(self): + return True + + class FakeWebServer: + def __init__(self, **kwargs): + return None + + def poll(self): + return False + + def sleep_ms(delay): + clock["sleeps"] += 1 + if advance_to_ap_retry and clock["sleeps"] == 1: + clock["ms"] = 30_000 + else: + raise StopAppLoop() + + fake_time = SimpleNamespace( + ticks_ms=lambda: clock["ms"], + ticks_diff=lambda left, right: left - right, + ticks_add=lambda value, delta: value + delta, + sleep_ms=sleep_ms, + sleep=lambda delay: None, + ) + fake_modules = { + "machine": SimpleNamespace(WDT=FakeWDT, reset=lambda: None), + "time": fake_time, + "bambuddy_api": SimpleNamespace(BambuddyAPI=lambda *args: api), + "config_loader": SimpleNamespace(load_config=lambda: config), + "gpio_button": SimpleNamespace(GPIOButton=FakeButton), + "led_flasher": SimpleNamespace(LedFlasher=FakeFlasher), + "ota_manager": SimpleNamespace(OTAUpdateManager=FakeOTA), + "wifi": SimpleNamespace( + WiFi=lambda **kwargs: network, + DEFAULT_HOSTNAME="bambutton", + DEFAULT_AP_PASSWORD="bambutton", + AP_RETRY_INTERVAL_SECONDS=30, + ), + "periodic_timer": SimpleNamespace( + PeriodicTimer=lambda **kwargs: SimpleNamespace(start=lambda: None) + ), + "web_config": SimpleNamespace(WebConfigServer=FakeWebServer), + } + for name, module in fake_modules.items(): + monkeypatch.setitem(sys.modules, name, module) + + namespace = {"__name__": "bambutton_app_main_test"} + try: + exec(compile(APP_MAIN.read_text(), str(APP_MAIN), "exec"), namespace) + except StopAppLoop: + pass + return namespace, network, button_handlers[0], clock + + +def unresolved_outcome(): + return { + "resolved": False, + "status": {"awaiting_plate_clear": True, "chamber_light": True}, + "request_error": RuntimeError("offline"), + } + + +def test_repeated_button_presses_keep_one_pending_intent_and_led_signal(monkeypatch): + api = FakeAPI() + namespace, _, press, _ = load_app_main(monkeypatch, api) + namespace["PRINTER_AWAITING_PLATE_CLEAR"] = True + + press(None) + press(None) + + assert namespace["PENDING_BUTTON_PRESS"] is True + assert namespace["should_flash_plate_clear"]() is True + + +def test_failed_request_stays_pending_then_recovers_without_losing_press(monkeypatch): + api = FakeAPI() + api.clear_outcomes = [unresolved_outcome(), { + "resolved": True, "status": None, "request_error": None, + }] + namespace, network, press, clock = load_app_main(monkeypatch, api) + namespace["PRINTER_AWAITING_PLATE_CLEAR"] = True + press(None) + network.ap_mode = False + + namespace["handle_pending_button_press"]() + namespace["handle_pending_button_press"]() + + assert namespace["PENDING_BUTTON_PRESS"] is True + assert api.clear_calls == 1 + assert namespace["should_flash_plate_clear"]() is True + + clock["ms"] += 5_001 + namespace["handle_pending_button_press"]() + + assert namespace["PENDING_BUTTON_PRESS"] is False + assert namespace["PRINTER_AWAITING_PLATE_CLEAR"] is False + assert api.clear_calls == 2 + + +def test_unknown_outcome_is_reconciled_before_any_retry(monkeypatch): + api = FakeAPI() + api.clear_outcomes = [RuntimeError("connection lost")] + api.statuses = [RuntimeError("status unavailable"), { + "awaiting_plate_clear": False, "chamber_light": False, + }] + namespace, network, press, clock = load_app_main(monkeypatch, api) + namespace["PRINTER_AWAITING_PLATE_CLEAR"] = True + press(None) + network.ap_mode = False + + namespace["handle_pending_button_press"]() + assert namespace["PENDING_BUTTON_PRESS"] is True + assert namespace["BUTTON_PRESS_NEEDS_RECONCILIATION"] is True + + clock["ms"] += 5_001 + namespace["handle_pending_button_press"]() + + assert namespace["PENDING_BUTTON_PRESS"] is False + assert api.clear_calls == 1 + assert api.status_calls == 2 + + +def test_setup_ap_retries_saved_station_connection(monkeypatch): + api = FakeAPI() + api.statuses = [{"awaiting_plate_clear": False, "chamber_light": False}] + namespace, network, _, _ = load_app_main( + monkeypatch, api, advance_to_ap_retry=True, recover_wifi=True + ) + + assert network.retry_calls == 1 + assert network.is_ap_mode() is False diff --git a/tests/test_bambuddy_api.py b/tests/test_bambuddy_api.py new file mode 100644 index 0000000..3b4b07a --- /dev/null +++ b/tests/test_bambuddy_api.py @@ -0,0 +1,148 @@ +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +MICRO_DIR = Path(__file__).parents[1] / "micro" + + +def load_bambuddy_api(): + previous_api = sys.modules.get("api") + previous_requests = sys.modules.get("urequests") + sys.modules["urequests"] = SimpleNamespace() + try: + api_spec = importlib.util.spec_from_file_location("api", MICRO_DIR / "api.py") + api_module = importlib.util.module_from_spec(api_spec) + sys.modules["api"] = api_module + api_spec.loader.exec_module(api_module) + + bambuddy_spec = importlib.util.spec_from_file_location( + "bambuddy_api_test_module", MICRO_DIR / "bambuddy_api.py" + ) + bambuddy_module = importlib.util.module_from_spec(bambuddy_spec) + bambuddy_spec.loader.exec_module(bambuddy_module) + return bambuddy_module.BambuddyAPI, bambuddy_module.BambuddyAPIError + finally: + if previous_api is None: + sys.modules.pop("api", None) + else: + sys.modules["api"] = previous_api + if previous_requests is None: + sys.modules.pop("urequests", None) + else: + sys.modules["urequests"] = previous_requests + + +BambuddyAPI, BambuddyAPIError = load_bambuddy_api() + + +def failure(error): + def raise_error(printer_id): + raise error + + return raise_error + + +def make_api(monkeypatch, clear, status=None): + api = BambuddyAPI("key", "http://bambuddy/api/v1") + monkeypatch.setattr(api, "clear_plate", clear) + if status is not None: + monkeypatch.setattr(api, "get_printer_status", status) + return api + + +def test_successful_plate_clear_does_not_issue_a_status_request(monkeypatch): + calls = [] + api = make_api( + monkeypatch, + lambda printer_id: calls.append(("clear", printer_id)), + lambda printer_id: calls.append(("status", printer_id)), + ) + + outcome = api.clear_plate_with_reconciliation(7) + + assert outcome == {"resolved": True, "status": None, "request_error": None} + assert calls == [("clear", 7)] + + +def test_rejected_clear_keeps_the_press_when_status_still_awaits_clear(monkeypatch): + api = make_api( + monkeypatch, + failure(RuntimeError("request rejected")), + lambda printer_id: {"awaiting_plate_clear": True, "chamber_light": True}, + ) + + outcome = api.clear_plate_with_reconciliation(7) + + assert outcome["resolved"] is False + assert outcome["status"]["awaiting_plate_clear"] is True + assert str(outcome["request_error"]) == "request rejected" + + +def test_lost_response_is_resolved_without_a_duplicate_clear(monkeypatch): + calls = [] + + def lost_response(printer_id): + calls.append(("clear", printer_id)) + raise TimeoutError("response lost after server applied request") + + def status(printer_id): + calls.append(("status", printer_id)) + return {"awaiting_plate_clear": False, "chamber_light": False} + + api = make_api(monkeypatch, lost_response, status) + + outcome = api.clear_plate_with_reconciliation(7) + + assert outcome["resolved"] is True + assert outcome["status"]["awaiting_plate_clear"] is False + assert calls == [("clear", 7), ("status", 7)] + + +def test_press_can_be_retried_after_the_api_recovers(monkeypatch): + clear_results = [RuntimeError("offline"), None] + calls = [] + + def clear(printer_id): + calls.append(("clear", printer_id)) + result = clear_results.pop(0) + if result: + raise result + + api = make_api( + monkeypatch, + clear, + lambda printer_id: {"awaiting_plate_clear": True, "chamber_light": False}, + ) + + first = api.clear_plate_with_reconciliation(7) + second = api.clear_plate_with_reconciliation(7) + + assert first["resolved"] is False + assert second["resolved"] is True + assert calls == [("clear", 7), ("clear", 7)] + + +def test_status_failure_does_not_assume_clear_succeeded(monkeypatch): + api = make_api( + monkeypatch, + failure(RuntimeError("offline")), + failure(RuntimeError("status unavailable")), + ) + + with pytest.raises(RuntimeError, match="status unavailable"): + api.clear_plate_with_reconciliation(7) + + +def test_malformed_reconciliation_status_fails_closed(monkeypatch): + api = make_api( + monkeypatch, + failure(RuntimeError("offline")), + lambda printer_id: {"chamber_light": True}, + ) + + with pytest.raises(BambuddyAPIError, match="verify plate-clear"): + api.clear_plate_with_reconciliation(7) diff --git a/tests/test_wifi.py b/tests/test_wifi.py index f502719..98dc5c5 100644 --- a/tests/test_wifi.py +++ b/tests/test_wifi.py @@ -249,3 +249,45 @@ def test_setup_access_point_uses_configured_hostname(monkeypatch): "essid": "bambutton-ABCD", "password": "bambutton", }) in events + + +def test_station_retry_restores_wifi_when_saved_network_returns(monkeypatch): + events = [] + wifi_module = load_wifi_module( + monkeypatch, + events, + connect_results=[False, True], + tick_increment_ms=10_001, + ) + wifi = wifi_module.WiFi("ssid", "password", timeout_seconds=10) + wifi.connect_with_fallback() + access_point = wifi.ap + + result = wifi.retry_station_from_access_point() + + assert result is wifi.wlan + assert access_point is not wifi.ap + assert access_point.interface == 1 + assert wifi.is_ap_mode() is False + assert wifi.is_connected() is True + + +def test_station_retry_keeps_setup_access_point_when_network_is_still_down( + monkeypatch, +): + events = [] + wifi_module = load_wifi_module( + monkeypatch, + events, + connect_results=[False, False], + tick_increment_ms=10_001, + ) + wifi = wifi_module.WiFi("ssid", "password", timeout_seconds=10) + wifi.connect_with_fallback() + setup_access_point = wifi.ap + + access_point = wifi.retry_station_from_access_point() + + assert access_point is setup_access_point + assert wifi.is_ap_mode() is True + assert wifi.ap is setup_access_point From b3a68091398ab59e3220a24d471dcb87b9b4ded1 Mon Sep 17 00:00:00 2001 From: Ed Date: Thu, 24 Sep 2026 20:26:15 +0100 Subject: [PATCH 2/2] test: keep plate-clear branch compatible with startup gate --- micro/app_main.py | 2 +- tests/test_app_main.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/micro/app_main.py b/micro/app_main.py index 0138e03..d6c5982 100644 --- a/micro/app_main.py +++ b/micro/app_main.py @@ -83,10 +83,10 @@ def IRQ_button_press(pin): timeout_seconds=config["wifi"]["timeout_seconds"], ) network.connect_with_fallback(watchdog_feed=watchdog.feed) -last_ap_retry_ms = time.ticks_ms() if network.is_ap_mode(): print("Wi-Fi unavailable; connect to the setup access point to update settings") flasher.on() +last_ap_retry_ms = time.ticks_ms() # -- Initialize API client -- api = bambuddy_api.BambuddyAPI( diff --git a/tests/test_app_main.py b/tests/test_app_main.py index 55efdc8..048ad3a 100644 --- a/tests/test_app_main.py +++ b/tests/test_app_main.py @@ -142,7 +142,10 @@ def sleep_ms(delay): "machine": SimpleNamespace(WDT=FakeWDT, reset=lambda: None), "time": fake_time, "bambuddy_api": SimpleNamespace(BambuddyAPI=lambda *args: api), - "config_loader": SimpleNamespace(load_config=lambda: config), + "config_loader": SimpleNamespace( + load_config=lambda: config, + is_runtime_config_ready=lambda loaded: True, + ), "gpio_button": SimpleNamespace(GPIOButton=FakeButton), "led_flasher": SimpleNamespace(LedFlasher=FakeFlasher), "ota_manager": SimpleNamespace(OTAUpdateManager=FakeOTA),