rndrsbc 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. rndrsbc-0.1.0/.gitignore +27 -0
  2. rndrsbc-0.1.0/PKG-INFO +113 -0
  3. rndrsbc-0.1.0/README.md +93 -0
  4. rndrsbc-0.1.0/assets/fonts/Roboto-Bold.ttf +0 -0
  5. rndrsbc-0.1.0/assets/fonts/Roboto-Light.ttf +0 -0
  6. rndrsbc-0.1.0/assets/fonts/Roboto-Regular.ttf +0 -0
  7. rndrsbc-0.1.0/assets/icons/weather/01d.png +0 -0
  8. rndrsbc-0.1.0/assets/icons/weather/01n.png +0 -0
  9. rndrsbc-0.1.0/assets/icons/weather/022d.png +0 -0
  10. rndrsbc-0.1.0/assets/icons/weather/022n.png +0 -0
  11. rndrsbc-0.1.0/assets/icons/weather/02d.png +0 -0
  12. rndrsbc-0.1.0/assets/icons/weather/02n.png +0 -0
  13. rndrsbc-0.1.0/assets/icons/weather/03d.png +0 -0
  14. rndrsbc-0.1.0/assets/icons/weather/04d.png +0 -0
  15. rndrsbc-0.1.0/assets/icons/weather/09d.png +0 -0
  16. rndrsbc-0.1.0/assets/icons/weather/10d.png +0 -0
  17. rndrsbc-0.1.0/assets/icons/weather/10n.png +0 -0
  18. rndrsbc-0.1.0/assets/icons/weather/11d.png +0 -0
  19. rndrsbc-0.1.0/assets/icons/weather/13d.png +0 -0
  20. rndrsbc-0.1.0/assets/icons/weather/48d.png +0 -0
  21. rndrsbc-0.1.0/assets/icons/weather/50d.png +0 -0
  22. rndrsbc-0.1.0/assets/icons/weather/51d.png +0 -0
  23. rndrsbc-0.1.0/assets/icons/weather/53d.png +0 -0
  24. rndrsbc-0.1.0/assets/icons/weather/56d.png +0 -0
  25. rndrsbc-0.1.0/assets/icons/weather/57d.png +0 -0
  26. rndrsbc-0.1.0/assets/icons/weather/71d.png +0 -0
  27. rndrsbc-0.1.0/assets/icons/weather/73d.png +0 -0
  28. rndrsbc-0.1.0/assets/icons/weather/77d.png +0 -0
  29. rndrsbc-0.1.0/assets/icons/weather/aqi.png +0 -0
  30. rndrsbc-0.1.0/assets/icons/weather/firstquarter.png +0 -0
  31. rndrsbc-0.1.0/assets/icons/weather/fullmoon.png +0 -0
  32. rndrsbc-0.1.0/assets/icons/weather/humidity.png +0 -0
  33. rndrsbc-0.1.0/assets/icons/weather/lastquarter.png +0 -0
  34. rndrsbc-0.1.0/assets/icons/weather/newmoon.png +0 -0
  35. rndrsbc-0.1.0/assets/icons/weather/pressure.png +0 -0
  36. rndrsbc-0.1.0/assets/icons/weather/sunrise.png +0 -0
  37. rndrsbc-0.1.0/assets/icons/weather/sunset.png +0 -0
  38. rndrsbc-0.1.0/assets/icons/weather/uvi.png +0 -0
  39. rndrsbc-0.1.0/assets/icons/weather/visibility.png +0 -0
  40. rndrsbc-0.1.0/assets/icons/weather/waningcrescent.png +0 -0
  41. rndrsbc-0.1.0/assets/icons/weather/waninggibbous.png +0 -0
  42. rndrsbc-0.1.0/assets/icons/weather/waxingcrescent.png +0 -0
  43. rndrsbc-0.1.0/assets/icons/weather/waxinggibbous.png +0 -0
  44. rndrsbc-0.1.0/assets/icons/weather/wind.png +0 -0
  45. rndrsbc-0.1.0/assets/icons/weather-snow.png +0 -0
  46. rndrsbc-0.1.0/config.template.json +17 -0
  47. rndrsbc-0.1.0/core/__init__.py +1 -0
  48. rndrsbc-0.1.0/core/buttons.py +126 -0
  49. rndrsbc-0.1.0/core/canvas.py +394 -0
  50. rndrsbc-0.1.0/core/color.py +69 -0
  51. rndrsbc-0.1.0/core/config_schema.py +166 -0
  52. rndrsbc-0.1.0/core/i18n.py +131 -0
  53. rndrsbc-0.1.0/core/layout.py +208 -0
  54. rndrsbc-0.1.0/core/manifest.py +129 -0
  55. rndrsbc-0.1.0/core/migrations.py +78 -0
  56. rndrsbc-0.1.0/core/oauth.py +464 -0
  57. rndrsbc-0.1.0/core/panel_health.py +259 -0
  58. rndrsbc-0.1.0/core/paths.py +213 -0
  59. rndrsbc-0.1.0/core/pipeline.py +191 -0
  60. rndrsbc-0.1.0/core/providers.py +416 -0
  61. rndrsbc-0.1.0/core/registry.py +208 -0
  62. rndrsbc-0.1.0/core/security.py +122 -0
  63. rndrsbc-0.1.0/core/telemetry.py +97 -0
  64. rndrsbc-0.1.0/core/transitions.py +59 -0
  65. rndrsbc-0.1.0/core/updates.py +173 -0
  66. rndrsbc-0.1.0/displays/__init__.py +1 -0
  67. rndrsbc-0.1.0/displays/base.py +81 -0
  68. rndrsbc-0.1.0/displays/framebuffer.py +99 -0
  69. rndrsbc-0.1.0/displays/inky.py +74 -0
  70. rndrsbc-0.1.0/displays/virtual.py +48 -0
  71. rndrsbc-0.1.0/displays/waveshare.py +156 -0
  72. rndrsbc-0.1.0/main.py +222 -0
  73. rndrsbc-0.1.0/pyproject.toml +47 -0
  74. rndrsbc-0.1.0/requirements.txt +27 -0
  75. rndrsbc-0.1.0/rndrsbc/__init__.py +7 -0
  76. rndrsbc-0.1.0/rndrsbc/__main__.py +83 -0
  77. rndrsbc-0.1.0/rndrsbc/_registry_cli.py +103 -0
  78. rndrsbc-0.1.0/rndrsbc/_update.py +99 -0
  79. rndrsbc-0.1.0/rndrsbc/doctor.py +282 -0
  80. rndrsbc-0.1.0/server/__init__.py +1 -0
  81. rndrsbc-0.1.0/server/app.py +1766 -0
  82. rndrsbc-0.1.0/server/dev_studio.py +416 -0
  83. rndrsbc-0.1.0/server/onboarding.py +374 -0
  84. rndrsbc-0.1.0/server/scheduler.py +334 -0
  85. rndrsbc-0.1.0/widgets/__init__.py +1 -0
  86. rndrsbc-0.1.0/widgets/base.py +302 -0
  87. rndrsbc-0.1.0/widgets/calendar/widget.py +281 -0
  88. rndrsbc-0.1.0/widgets/clock/widget.py +137 -0
  89. rndrsbc-0.1.0/widgets/network/widget.py +116 -0
  90. rndrsbc-0.1.0/widgets/onboarding/widget.py +117 -0
  91. rndrsbc-0.1.0/widgets/photo_frame/widget.py +245 -0
  92. rndrsbc-0.1.0/widgets/system_stats/widget.py +91 -0
  93. rndrsbc-0.1.0/widgets/weather/widget.py +716 -0
@@ -0,0 +1,27 @@
1
+ # Runtime state (self-contained mode) — gitignored, survives only locally
2
+ /data/
3
+ /config.json
4
+ /.claim_token
5
+ /live_*.png
6
+ /test_*.png
7
+ /build/
8
+ /*.egg-info/
9
+ /dist/
10
+ __pycache__/
11
+ *.pyc
12
+
13
+ # Secrets — must NEVER be committed (OAuth token store, mode 600)
14
+ /secrets/
15
+
16
+ # Python test runner cache
17
+ /.pytest_cache/
18
+
19
+ # Benchmark output artifacts (CI perf reports)
20
+ /bench/reports/
21
+
22
+ # Vendored wheels are reproducible outputs of build_vendor_deps.py from the
23
+ # pinned requirements.txt — keep the manifest, not the 32MB binaries.
24
+ /vendor/deps/_extracted/
25
+ /vendor/deps/*.whl
26
+ /vendor/deps/*.tar.gz
27
+ /vendor/deps/*.zip
rndrsbc-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.5
2
+ Name: rndrsbc
3
+ Version: 0.1.0
4
+ Summary: Native E-Paper Operating Platform for Raspberry Pi color e-paper panels.
5
+ Project-URL: Homepage, https://github.com/rndrsbc/rndrsbc
6
+ Author: rndrSBC
7
+ License: MIT
8
+ Requires-Python: >=3.9
9
+ Requires-Dist: pillow>=10.0.0
10
+ Requires-Dist: qrcode[pil]>=7.4.0
11
+ Requires-Dist: requests>=2.31.0
12
+ Requires-Dist: werkzeug>=3.0.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: build; extra == 'dev'
15
+ Requires-Dist: twine; extra == 'dev'
16
+ Provides-Extra: pi
17
+ Requires-Dist: rpi-gpio; extra == 'pi'
18
+ Requires-Dist: spidev; extra == 'pi'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # rndrSBC
22
+
23
+ > **Ultra-lightweight, high-performance native E-Paper operating platform designed for Single-Board Computers.**
24
+
25
+ ---
26
+
27
+ ### Why rndrSBC?
28
+
29
+ * **Chromium-Free**: 0MB browser bloat. No headless Chrome, no WebKit, no X11.
30
+ * **Insanely Fast**: Renders vector dashboards in **35ms–60ms** (vs. 10–15 seconds on Chrome).
31
+ * **Zero Swap & Memory Thrashing**: Runs in **< 15MB RAM** (ideal for Pi Zero W / Pi Zero 2W / Pi 3).
32
+ * **Responsive Canvas Engine**: Resolution-independent vector layout with fractional split boxes and auto-scaling typography.
33
+ * **100% Clean-Room Architecture**: Fully standalone with modular display drivers and widget system.
34
+
35
+ ---
36
+
37
+ ### Quick Start (Development / PC / Mac)
38
+
39
+ ```bash
40
+ # 1. (Optional) Vendor the runtime deps for a fully-offline copy
41
+ python3 build_vendor_deps.py
42
+
43
+ # 2. Run single-pass render test
44
+ python3 main.py --once
45
+ ```
46
+
47
+ Output is rendered directly to `live_screen.png`.
48
+
49
+ ### Run the web dashboard (any platform)
50
+
51
+ ```bash
52
+ python3 main.py 8080 # foreground
53
+ python3 main.py --daemon 8080 # background, self-detaches (no sudo)
54
+ ```
55
+
56
+ ---
57
+
58
+ ### Raspberry Pi — no `install.sh` required
59
+
60
+ rndrSBC is **relocatable**: it anchors every path (config, previews, photo
61
+ library) to its own folder, and it can load its Python deps straight from a
62
+ vendored wheel bundle. So deployment is simply:
63
+
64
+ ```bash
65
+ # Copy the project folder onto the Pi, then:
66
+ python3 main.py 8080
67
+ ```
68
+
69
+ Optional, per-target dependency bundle (needs internet once, on the Pi):
70
+
71
+ ```bash
72
+ python3 build_vendor_deps.py # detects this machine's arch
73
+ # or for a Raspberry Pi explicitly:
74
+ python3 build_vendor_deps.py --platform aarch64 # 64-bit OS / Pi 4+ / Pi 5
75
+ python3 build_vendor_deps.py --platform armv7l # 32-bit OS
76
+ ```
77
+
78
+ The resulting `vendor/deps/*.whl` files let the platform run **fully offline
79
+ with zero pip installs**. Skip this and rndrSBC will fall back to your
80
+ system's packages (or create a private `.venv` on first run).
81
+
82
+ If you want rndrSBC to start at boot (optional), use the relocatable unit:
83
+
84
+ ```bash
85
+ # edit the two placeholders in rndrSBC.service, then:
86
+ sudo cp rndrSBC.service /etc/systemd/system/
87
+ sudo systemctl daemon-reload
88
+ sudo systemctl enable --now rndrSBC
89
+ ```
90
+
91
+ > A legacy `install.sh` is still included for compatibility, but it is no
92
+ > longer required.
93
+
94
+ ### Recent Additions (this build)
95
+
96
+ **Physical Buttons (GPIO)** — `core/buttons.py`
97
+ - Wire three buttons to GPIO 17 / 27 / 22 (to GND), enabled in the dashboard.
98
+ - NEXT cycles widgets, PREV goes back, TOGGLE-QUIET pauses/restarts refreshes.
99
+ - Falls back gracefully when RPi.GPIO is absent (simulator / laptop / CI) — buttons are auto-disabled.
100
+
101
+ **CalDAV + iCal Calendar** — `widgets/calendar/widget.py`
102
+ - Calendar widget now accepts a `caldav` config (URL + basic-auth user/password) in addition to public `iCal` feed URLs.
103
+ - Fetches via HTTPS `REPORT` query, parses with the native ICS parser (no extra deps); per-date event-dot markers on the month grid.
104
+
105
+ **Localization (i18n)** — `core/i18n.py`
106
+ - New `language` global setting (English, Español, Français, Deutsch, Italiano, Português, Nederlands, Türkçe).
107
+ - Localizes the calendar weekday headers + agenda labels and the weather "Updated / Feels Like / Wind / Humidity" labels.
108
+ - Add languages in `core/i18n.py` with three dicts (`MONTHS`, `WEEKDAYS`, `LABELS`).
109
+
110
+ **Reflect-mode refresh** — `core/transitions.py` + scheduler
111
+ - `refresh_mode: "auto"` (default) uses dirty-rect partial refresh when the panel supports it, with a full-refresh recharge every 20 partials.
112
+ - `refresh_mode: "full"` opt-out forces full-frame always (dashboard Display Transitions card).
113
+
@@ -0,0 +1,93 @@
1
+ # rndrSBC
2
+
3
+ > **Ultra-lightweight, high-performance native E-Paper operating platform designed for Single-Board Computers.**
4
+
5
+ ---
6
+
7
+ ### Why rndrSBC?
8
+
9
+ * **Chromium-Free**: 0MB browser bloat. No headless Chrome, no WebKit, no X11.
10
+ * **Insanely Fast**: Renders vector dashboards in **35ms–60ms** (vs. 10–15 seconds on Chrome).
11
+ * **Zero Swap & Memory Thrashing**: Runs in **< 15MB RAM** (ideal for Pi Zero W / Pi Zero 2W / Pi 3).
12
+ * **Responsive Canvas Engine**: Resolution-independent vector layout with fractional split boxes and auto-scaling typography.
13
+ * **100% Clean-Room Architecture**: Fully standalone with modular display drivers and widget system.
14
+
15
+ ---
16
+
17
+ ### Quick Start (Development / PC / Mac)
18
+
19
+ ```bash
20
+ # 1. (Optional) Vendor the runtime deps for a fully-offline copy
21
+ python3 build_vendor_deps.py
22
+
23
+ # 2. Run single-pass render test
24
+ python3 main.py --once
25
+ ```
26
+
27
+ Output is rendered directly to `live_screen.png`.
28
+
29
+ ### Run the web dashboard (any platform)
30
+
31
+ ```bash
32
+ python3 main.py 8080 # foreground
33
+ python3 main.py --daemon 8080 # background, self-detaches (no sudo)
34
+ ```
35
+
36
+ ---
37
+
38
+ ### Raspberry Pi — no `install.sh` required
39
+
40
+ rndrSBC is **relocatable**: it anchors every path (config, previews, photo
41
+ library) to its own folder, and it can load its Python deps straight from a
42
+ vendored wheel bundle. So deployment is simply:
43
+
44
+ ```bash
45
+ # Copy the project folder onto the Pi, then:
46
+ python3 main.py 8080
47
+ ```
48
+
49
+ Optional, per-target dependency bundle (needs internet once, on the Pi):
50
+
51
+ ```bash
52
+ python3 build_vendor_deps.py # detects this machine's arch
53
+ # or for a Raspberry Pi explicitly:
54
+ python3 build_vendor_deps.py --platform aarch64 # 64-bit OS / Pi 4+ / Pi 5
55
+ python3 build_vendor_deps.py --platform armv7l # 32-bit OS
56
+ ```
57
+
58
+ The resulting `vendor/deps/*.whl` files let the platform run **fully offline
59
+ with zero pip installs**. Skip this and rndrSBC will fall back to your
60
+ system's packages (or create a private `.venv` on first run).
61
+
62
+ If you want rndrSBC to start at boot (optional), use the relocatable unit:
63
+
64
+ ```bash
65
+ # edit the two placeholders in rndrSBC.service, then:
66
+ sudo cp rndrSBC.service /etc/systemd/system/
67
+ sudo systemctl daemon-reload
68
+ sudo systemctl enable --now rndrSBC
69
+ ```
70
+
71
+ > A legacy `install.sh` is still included for compatibility, but it is no
72
+ > longer required.
73
+
74
+ ### Recent Additions (this build)
75
+
76
+ **Physical Buttons (GPIO)** — `core/buttons.py`
77
+ - Wire three buttons to GPIO 17 / 27 / 22 (to GND), enabled in the dashboard.
78
+ - NEXT cycles widgets, PREV goes back, TOGGLE-QUIET pauses/restarts refreshes.
79
+ - Falls back gracefully when RPi.GPIO is absent (simulator / laptop / CI) — buttons are auto-disabled.
80
+
81
+ **CalDAV + iCal Calendar** — `widgets/calendar/widget.py`
82
+ - Calendar widget now accepts a `caldav` config (URL + basic-auth user/password) in addition to public `iCal` feed URLs.
83
+ - Fetches via HTTPS `REPORT` query, parses with the native ICS parser (no extra deps); per-date event-dot markers on the month grid.
84
+
85
+ **Localization (i18n)** — `core/i18n.py`
86
+ - New `language` global setting (English, Español, Français, Deutsch, Italiano, Português, Nederlands, Türkçe).
87
+ - Localizes the calendar weekday headers + agenda labels and the weather "Updated / Feels Like / Wind / Humidity" labels.
88
+ - Add languages in `core/i18n.py` with three dicts (`MONTHS`, `WEEKDAYS`, `LABELS`).
89
+
90
+ **Reflect-mode refresh** — `core/transitions.py` + scheduler
91
+ - `refresh_mode: "auto"` (default) uses dirty-rect partial refresh when the panel supports it, with a full-refresh recharge every 20 partials.
92
+ - `refresh_mode: "full"` opt-out forces full-frame always (dashboard Display Transitions card).
93
+
@@ -0,0 +1,17 @@
1
+ {
2
+ "_comment": "rndrSBC default configuration. Copy to config.json (or let the deployment scaffold create it) then adjust.",
3
+ "display": {
4
+ "driver": "virtual",
5
+ "model": "epd7in3f",
6
+ "orientation": 0
7
+ },
8
+ "device": {
9
+ "name": "rndrSBC Frame",
10
+ "language": "en",
11
+ "timezone": "UTC"
12
+ },
13
+ "active_playlist": "default",
14
+ "playlists": {
15
+ "default": ["weather", "network", "calendar", "photo_frame", "system_stats"]
16
+ }
17
+ }
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,126 @@
1
+ """
2
+ rndrSBC - Physical Button Controller
3
+ Wires up to 3 GPIO push-buttons to cycles through playlist actions.
4
+
5
+ Buttons (BCM pin numbering by default, configurable in config):
6
+ BUTTON_NEXT -> advance to next widget in active playlist
7
+ BUTTON_PREV -> go back to previous widget
8
+ BUTTON_TOGGLE-> toggle between quiet-hours suspend and active refresh
9
+
10
+ The controller degrades gracefully: on platforms without RPi.GPIO
11
+ (a laptop, CI, the VirtualDisplay), it logs and no-ops so the rest
12
+ of the system runs normally.
13
+ """
14
+
15
+ import logging
16
+ import threading
17
+ import time
18
+
19
+ logger = logging.getLogger("rndrSBC.buttons")
20
+
21
+ try:
22
+ import RPi.GPIO as GPIO
23
+ _HAS_GPIO = True
24
+ except Exception:
25
+ GPIO = None
26
+ _HAS_GPIO = False
27
+
28
+
29
+ class ButtonController:
30
+ """Debounced physical button listener mapped to scheduler actions."""
31
+
32
+ DEFAULT_PINS = {
33
+ "next": 17,
34
+ "prev": 27,
35
+ "toggle": 22,
36
+ }
37
+
38
+ def __init__(self, scheduler, config: dict, pins: dict = None):
39
+ self.scheduler = scheduler
40
+ self.config = config
41
+ self.pins = dict(self.DEFAULT_PINS)
42
+ if pins:
43
+ self.pins.update({k: v for k, v in pins.items() if v is not None})
44
+ self._thread = None
45
+ self._running = False
46
+ self._last_state = {}
47
+ self._last_trigger = {}
48
+ self._debounce_ms = int(config.get("buttons", {}).get("debounce_ms", 200))
49
+ self._enabled = bool(config.get("buttons", {}).get("enabled", True))
50
+
51
+ def _on_next(self):
52
+ items = self.scheduler.active_playlist_items
53
+ if items:
54
+ nxt = (self.scheduler.current_index + 1) % len(items)
55
+ self.scheduler.trigger_render_now(nxt, force_hardware=True)
56
+ logger.info("[BUTTON] Next widget -> index %d", nxt)
57
+
58
+ def _on_prev(self):
59
+ items = self.scheduler.active_playlist_items
60
+ if items:
61
+ prv = (self.scheduler.current_index - 1) % len(items)
62
+ self.scheduler.trigger_render_now(prv, force_hardware=True)
63
+ logger.info("[BUTTON] Previous widget -> index %d", prv)
64
+
65
+ def _on_toggle(self):
66
+ current = bool(self.config.get("quiet_hours", {}).get("enabled", False))
67
+ self.config.setdefault("quiet_hours", {})["enabled"] = not current
68
+ state = "enabled" if not current else "disabled"
69
+ logger.info("[BUTTON] Quiet-hours %s", state)
70
+ if not current:
71
+ self.scheduler.trigger_render_now(self.scheduler.current_index, force_hardware=True)
72
+
73
+ def _dispatch(self, action):
74
+ try:
75
+ getattr(self, f"_on_{action}")()
76
+ except Exception as e:
77
+ logger.error(f"Button action '{action}' failed: {e}")
78
+
79
+ def _poll(self):
80
+ """Non-blocking polling loop with software debounce (works on any GPIO lib)."""
81
+ while self._running:
82
+ for action, pin in self.pins.items():
83
+ try:
84
+ state = GPIO.input(pin)
85
+ except Exception:
86
+ continue
87
+ now = time.time()
88
+ prev = self._last_state.get(action)
89
+ if prev == 1 and state == 0: # falling edge (pressed to GND)
90
+ if now - self._last_trigger.get(action, 0) >= self._debounce_ms / 1000.0:
91
+ self._last_trigger[action] = now
92
+ self._dispatch(action)
93
+ self._last_state[action] = state
94
+ time.sleep(0.02)
95
+
96
+ def start(self):
97
+ if not self._enabled:
98
+ logger.info("Buttons disabled in config.")
99
+ return
100
+ if not _HAS_GPIO:
101
+ logger.info("RPi.GPIO not available - physical buttons skipped (virtual/simulator mode).")
102
+ return
103
+ try:
104
+ GPIO.setmode(GPIO.BCM)
105
+ for action, pin in self.pins.items():
106
+ logger.info("Configuring %s button on GPIO %d", action, pin)
107
+ GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_UP)
108
+ self._last_state[action] = GPIO.input(pin)
109
+ except Exception as e:
110
+ logger.error(f"Failed to configure GPIO buttons: {e}")
111
+ return
112
+ self._running = True
113
+ self._thread = threading.Thread(target=self._poll, daemon=True, name="rndrSBC-Buttons")
114
+ self._thread.start()
115
+ logger.info("Physical button controller started.")
116
+
117
+ def stop(self):
118
+ self._running = False
119
+ if self._thread:
120
+ self._thread.join(timeout=3)
121
+ if _HAS_GPIO and self._enabled:
122
+ try:
123
+ GPIO.cleanup()
124
+ except Exception:
125
+ pass
126
+ logger.info("Button controller stopped.")