From ace5770b3a27ccc90b0a616cc0adb4fc3e7b032a Mon Sep 17 00:00:00 2001 From: Jimmy Date: Thu, 11 Jun 2026 22:55:28 +1200 Subject: [PATCH] refactor(api): complete fastapi migration and related features Finish native FastAPI controllers, drop vendored microdot, and add Wi-Fi driver runtime, beat SSE, simulated BPM, sequence playback improvements, bridge ESP-NOW sources, UI updates, and tests. Co-authored-by: Cursor --- Pipfile | 2 +- Pipfile.lock | 20 +- bridge-ethernet/src/espnow_radio.py | 49 + bridge-ethernet/src/espnow_wire.py | 7 + db/palette.json | 2 +- db/preset.json | 2 +- db/profile.json | 2 +- lib/microdot/__init__.py | 2 - lib/microdot/helpers.py | 8 - lib/microdot/microdot.py | 1450 ----------------- lib/microdot/session.py | 225 --- lib/microdot/utemplate.py | 70 - lib/microdot/websocket.py | 231 --- lib/utemplate/__init__.py | 0 lib/utemplate/compiled.py | 14 - lib/utemplate/recompile.py | 21 - lib/utemplate/source.py | 188 --- scripts/migrate_controllers_native_fastapi.py | 146 ++ src/app_factory.py | 156 +- src/controllers/device.py | 203 +-- src/controllers/group.py | 118 +- src/controllers/led_tool.py | 127 +- src/controllers/palette.py | 54 +- src/controllers/pattern.py | 223 +-- src/controllers/preset.py | 153 +- src/controllers/profile.py | 133 +- src/controllers/scene.py | 56 +- src/controllers/sequence.py | 190 +-- src/controllers/settings.py | 65 +- src/controllers/wifi_bridge.py | 138 +- src/controllers/zone.py | 185 +-- src/fastapi_app.py | 130 +- src/http_responses.py | 84 + src/http_session.py | 117 ++ src/microdot_asgi.py | 84 - src/models/sequence.py | 11 - src/settings.py | 22 + src/static/app.js | 2 +- src/static/audio.js | 339 +++- src/static/devices.js | 213 +-- src/static/groups.js | 32 +- src/static/patterns.js | 8 +- src/static/presets.js | 23 +- src/static/sequences.js | 156 +- src/static/style.css | 59 +- src/static/zones.js | 2 +- src/templates/index.html | 66 +- src/util/audio_detector.py | 91 +- src/util/beat_status_broadcaster.py | 156 ++ src/util/bpm_limits.py | 40 + src/util/device_status_broadcaster.py | 60 +- src/util/driver_delivery.py | 277 +++- src/util/sequence_playback.py | 459 ++++-- src/util/wifi_driver_runtime.py | 248 +++ tests/README.md | 6 +- tests/api_server.py | 5 +- tests/beat_detect.py | 32 +- tests/conftest.py | 5 +- tests/models/test_sequence.py | 3 - tests/test_audio_device_select.py | 94 +- tests/test_audio_reset_tracking.py | 48 +- tests/test_audio_sse.py | 34 + tests/test_beat_detect_ioi.py | 11 + tests/test_bpm_limits.py | 45 + tests/test_bridge_envelope.py | 18 +- tests/test_browser.py | 32 +- tests/test_driver_delivery_wifi.py | 461 ++++++ tests/test_endpoints_pytest.py | 37 + tests/test_main_old.py | 16 - tests/test_sequence_pending_start.py | 170 +- tests/test_simulated_beat_continuity.py | 156 ++ tests/test_simulated_sequence_switch.py | 580 +++++++ tests/web.py | 355 +--- 73 files changed, 4540 insertions(+), 4487 deletions(-) create mode 100644 bridge-ethernet/src/espnow_radio.py create mode 100644 bridge-ethernet/src/espnow_wire.py delete mode 100644 lib/microdot/__init__.py delete mode 100644 lib/microdot/helpers.py delete mode 100644 lib/microdot/microdot.py delete mode 100644 lib/microdot/session.py delete mode 100644 lib/microdot/utemplate.py delete mode 100644 lib/microdot/websocket.py delete mode 100644 lib/utemplate/__init__.py delete mode 100644 lib/utemplate/compiled.py delete mode 100644 lib/utemplate/recompile.py delete mode 100644 lib/utemplate/source.py create mode 100644 scripts/migrate_controllers_native_fastapi.py create mode 100644 src/http_responses.py create mode 100644 src/http_session.py delete mode 100644 src/microdot_asgi.py create mode 100644 src/util/beat_status_broadcaster.py create mode 100644 src/util/bpm_limits.py create mode 100644 src/util/wifi_driver_runtime.py create mode 100644 tests/test_audio_sse.py create mode 100644 tests/test_bpm_limits.py create mode 100644 tests/test_driver_delivery_wifi.py delete mode 100644 tests/test_main_old.py create mode 100644 tests/test_simulated_beat_continuity.py create mode 100644 tests/test_simulated_sequence_switch.py diff --git a/Pipfile b/Pipfile index d1280a6..7da6536 100644 --- a/Pipfile +++ b/Pipfile @@ -13,8 +13,8 @@ watchfiles = "*" requests = "*" selenium = "*" adafruit-ampy = "*" -microdot = "*" fastapi = "*" +python-multipart = "*" websockets = "*" httpx = "*" numpy = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 8a2ee6b..3763e63 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "b6783f8de9b1f98387fccab1d9fed190f2acbf66f0f188e43fe91f28a6950ad1" + "sha256": "898e7932e8decb3f1b5e1fd620883f2727cbd2f1c1295d8cd559105172d814cb" }, "pipfile-spec": 6, "requires": { @@ -597,15 +597,6 @@ "markers": "python_version >= '3.7'", "version": "==0.1.2" }, - "microdot": { - "hashes": [ - "sha256:206c52870e3b1d5e6d387802e2ed0afae8c4598c80542a21e3efe377efc128c8", - "sha256:7bb9a69fa97a47d8fe07e61d9dd405804744132ca52d26705cf1173431ff7f4b" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==2.6.2" - }, "mpremote": { "hashes": [ "sha256:2df2a50f3c8098cae8c732dbf2541e7e58185e7896513b45d05196901e049334", @@ -901,6 +892,15 @@ "markers": "python_version >= '3.10'", "version": "==1.2.2" }, + "python-multipart": { + "hashes": [ + "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", + "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23" + ], + "index": "pypi", + "markers": "python_version >= '3.10'", + "version": "==0.0.32" + }, "pyyaml": { "hashes": [ "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", diff --git a/bridge-ethernet/src/espnow_radio.py b/bridge-ethernet/src/espnow_radio.py new file mode 100644 index 0000000..f40f508 --- /dev/null +++ b/bridge-ethernet/src/espnow_radio.py @@ -0,0 +1,49 @@ +"""Wi-Fi radio for ESP-NOW only (hidden AP locks channel).""" + +import time + +import network + +from settings import WIFI_CHANNEL_DEFAULT + + +def _channel(settings): + try: + return max(1, min(11, int(settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT)))) + except (TypeError, ValueError): + return WIFI_CHANNEL_DEFAULT + + +def init_espnow_radio(settings): + ch = _channel(settings) + name = settings.get("name") or "bridge" + password = settings.get("ap_password") or "" + + network.WLAN(network.STA_IF).active(False) + network.WLAN(network.AP_IF).active(False) + time.sleep_ms(100) + + ap = network.WLAN(network.AP_IF) + ap.active(True) + time.sleep_ms(50) + if password: + try: + ap.config(essid=name, password=password, channel=ch, hidden=True) + except TypeError: + ap.config(essid=name, channel=ch) + else: + try: + ap.config(essid=name, channel=ch, hidden=True) + except TypeError: + ap.config(essid=name, channel=ch) + + sta = network.WLAN(network.STA_IF) + sta.active(True) + sta.config(pm=network.WLAN.PM_NONE) + try: + sta.config(channel=ch) + except Exception: + pass + + print("espnow radio ch", ch) + return ch diff --git a/bridge-ethernet/src/espnow_wire.py b/bridge-ethernet/src/espnow_wire.py new file mode 100644 index 0000000..9893e77 --- /dev/null +++ b/bridge-ethernet/src/espnow_wire.py @@ -0,0 +1,7 @@ +"""WebSocket uplink framing (Pi ↔ bridge).""" + +BROADCAST_MAC = b"\xff\xff\xff\xff\xff\xff" + + +def pack_ws_uplink(peer, espnow_packet): + return bytes([0]) + peer + espnow_packet diff --git a/db/palette.json b/db/palette.json index 23edbdb..a98cb3a 100644 --- a/db/palette.json +++ b/db/palette.json @@ -1 +1 @@ -{"1":["#FF0000","#00FF00","#0000FF","#FFFF00","#FF00FF","#00FFFF","#FFFFFF","#000000","#050500"],"2":[],"3":[],"4":[],"5":[],"6":[],"7":["#FF0000","#00FF00","#0000FF","#FFFF00","#FF00FF","#00FFFF","#FFFFFF","#000000"],"8":[],"9":[],"10":[],"11":[],"12":["#890b0b","#0b8935"],"13":[],"14":["#E8F4FF","#9ECFFF","#5080C8","#FFFFFF","#B0DCFF","#0A1520","#FF8020","#071018"]} \ No newline at end of file +{"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000", "#050500"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"], "13": [], "14": ["#E8F4FF", "#9ECFFF", "#5080C8", "#FFFFFF", "#B0DCFF", "#0A1520", "#FF8020", "#071018"], "15": []} \ No newline at end of file diff --git a/db/preset.json b/db/preset.json index 5f3b074..a0a9dd4 100644 --- a/db/preset.json +++ b/db/preset.json @@ -1 +1 @@ -{"1": {"name": "on", "pattern": "on", "colors": ["#FFFFFF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "2": {"name": "off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "3": {"name": "rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 100, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "4": {"name": "transition", "pattern": "transition", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 255, "delay": 300, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#000000", "manual_beat_n": 1}, "5": {"name": "chase", "pattern": "chase", "colors": ["#FFFF00", "#FF00FF"], "brightness": 128, "delay": 200, "auto": false, "n1": 30, "n2": 30, "n3": 30, "n4": 30, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [3, 4], "manual_beat_n": 1, "background": "#000000", "background_palette_ref": null, "mode": 0}, "6": {"name": "pulse", "pattern": "pulse", "colors": ["#FF00FF"], "brightness": 255, "delay": 1000, "auto": false, "n1": 100, "n2": 0, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [4], "background_color": "#ec0909", "background_palette_ref": 8, "manual_beat_n": 1, "background": "#050500"}, "7": {"name": "circle", "pattern": "circle", "colors": ["#FFA500", "#800080"], "brightness": 255, "delay": 200, "auto": true, "n1": 2, "n2": 10, "n3": 2, "n4": 5, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "8": {"name": "blink", "pattern": "blink", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 1000, "auto": false, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, null]}, "9": {"name": "warm white", "pattern": "on", "colors": ["#FFF5E6"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "10": {"name": "cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "11": {"name": "red", "pattern": "on", "colors": ["#FF0000"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "12": {"name": "blue", "pattern": "on", "colors": ["#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "13": {"name": "rainbow slow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "14": {"name": "pulse slow", "pattern": "pulse", "colors": ["#FF6600"], "brightness": 255, "delay": 800, "auto": true, "n1": 2000, "n2": 1000, "n3": 2000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "15": {"name": "blink red green", "pattern": "blink", "colors": ["#FF0000", "#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "30": {"name": "rainbow slow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "31": {"name": "DJ Rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "32": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "33": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "34": {"name": "DJ Rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "35": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "36": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "37": {"name": "tranistion2", "pattern": "transition", "colors": ["#FF0000", "#FFFF00", "#FF00FF"], "brightness": 128, "delay": 1000, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [0, 3, 4]}, "38": {"name": "Colour Cycle", "pattern": "colour_cycle", "colors": ["#FF0000", "#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null]}, "39": {"name": "flicker", "pattern": "flicker", "colors": ["#ae00ff"], "brightness": 255, "delay": 50, "auto": false, "n1": 100, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "40": {"name": "flame", "pattern": "flame", "colors": ["#ffc800"], "brightness": 128, "delay": 50, "auto": true, "n1": 35, "n2": 2600, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "background_palette_ref": null, "manual_beat_n": 1}, "41": {"name": "twinkle", "pattern": "twinkle", "colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 150, "n2": 20, "n3": 0, "n4": 10, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, null, null], "background": "#000000", "manual_beat_n": 1, "background_palette_ref": null}, "42": {"name": "radiate", "pattern": "radiate", "colors": ["#a600ff"], "brightness": 255, "delay": 2000, "auto": false, "n1": 60, "n2": 200, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "manual_beat_n": 1, "background": "#050500", "background_palette_ref": 8}, "43": {"name": "test meteor rain", "pattern": "meteor", "colors": ["#FF5000", "#0080FF"], "brightness": 200, "delay": 40, "auto": true, "n1": 50, "n2": 1, "n3": 200, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null]}, "44": {"name": "test scanner", "pattern": "meteor", "colors": ["#FF0000"], "brightness": 255, "delay": 30, "auto": true, "n1": 4, "n2": 2, "n3": 0, "n4": 0, "n5": 0, "n6": 2, "n7": 0, "n8": 0, "profile_id": "1"}, "45": {"name": "test gradient scroll", "pattern": "colour_cycle", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 220, "delay": 60, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "46": {"name": "test comet dual", "pattern": "meteor", "colors": ["#FFAA00", "#00AAFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 8, "n2": 1, "n3": 3, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#0b000f", "manual_beat_n": 1}, "47": {"name": "test sparkle trail", "pattern": "sparkle", "colors": ["#88CCFF", "#FFFFFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 24, "n2": 210, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#000000", "manual_beat_n": 1}, "49": {"name": "test plasma", "pattern": "plasma", "colors": ["#FF0066"], "brightness": 200, "delay": 60, "auto": true, "n1": 6, "n2": 2, "n3": 2, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "51": {"name": "test bar graph", "pattern": "bar_graph", "colors": ["#00FF00", "#102010"], "brightness": 200, "delay": 60, "auto": true, "n1": 60, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "53": {"name": "test strobe burst", "pattern": "strobe_burst", "colors": ["#FFFFFF"], "brightness": 200, "delay": 60, "auto": false, "n1": 2, "n2": 10, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "54": {"name": "test rain drops", "pattern": "rain_drops", "colors": ["#7cbdfe"], "brightness": 200, "delay": 60, "auto": true, "n1": 32, "n2": 3, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "55": {"name": "test fireflies", "pattern": "sparkle", "colors": ["#FFD060", "#90FF90"], "brightness": 200, "delay": 60, "auto": false, "n1": 6, "n2": 8, "n3": 0, "n4": 0, "n5": 0, "n6": 2, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#000000", "manual_beat_n": 1}, "56": {"name": "test clock sweep", "pattern": "clock_sweep", "colors": ["#FFFFFF", "#202020"], "brightness": 200, "delay": 60, "auto": true, "n1": 1, "n2": 5, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "57": {"name": "test marquee", "pattern": "chase", "colors": ["#FFFFFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 2, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "58": {"name": "test aurora", "pattern": "aurora", "colors": ["#2CC88C", "#5078FF", "#A050DC"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 40, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "59": {"name": "test snowfall", "pattern": "particles", "colors": ["#FFFFFF", "#B0DCFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 20, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "61": {"name": "test orbit", "pattern": "orbit", "colors": ["#FFFFFF", "#00B4FF", "#FF0077"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null]}, "62": {"name": "test palette morph", "pattern": "palette_morph", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 200, "delay": 60, "auto": true, "n1": 1200, "n2": 200, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "63": {"name": "off", "pattern": "off", "colors": [], "background": "#000000", "brightness": 0, "delay": 0, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "manual_beat_n": 1, "profile_id": "2", "palette_refs": [], "auto": true}, "64": {"name": "winter icicles", "pattern": "icicles", "colors": ["#F0F8FF", "#9ECFFF", "#FFFFFF"], "brightness": 220, "delay": 80, "auto": true, "n1": 14, "n2": 11, "n3": 1, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#0A1520", "manual_beat_n": 1}, "65": {"name": "winter blizzard", "pattern": "blizzard", "colors": ["#FFFFFF", "#CDE8FF", "#AACCF5"], "brightness": 220, "delay": 45, "auto": true, "n1": 180, "n2": 4, "n3": 140, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#050810", "manual_beat_n": 1, "background_palette_ref": null}, "66": {"name": "winter rime frost", "pattern": "rime", "colors": ["#E8F4FF", "#FFFFFF", "#B8DCF8"], "brightness": 200, "delay": 120, "auto": true, "n1": 40, "n2": 18, "n3": 4, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#071018", "manual_beat_n": 1}, "67": {"name": "winter northern wave", "pattern": "aurora", "colors": ["#183050", "#5090C8", "#C8E8FF"], "brightness": 200, "delay": 90, "auto": true, "n1": 22, "n2": 210, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#060C18", "manual_beat_n": 1}, "68": {"name": "winter candle glow", "pattern": "candle_glow", "colors": ["#FF8020", "#FFC080", "#FFA040"], "brightness": 180, "delay": 70, "auto": true, "n1": 4, "n2": 3, "n3": 120, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#0A0508", "manual_beat_n": 1}, "69": {"name": "winter starfall", "pattern": "particles", "colors": ["#FFFFFF", "#C8E8FF", "#FFF8E0"], "brightness": 220, "delay": 55, "auto": true, "n1": 16, "n2": 2, "n3": 12, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#040810", "manual_beat_n": 1}, "70": {"name": "winter ice sparkle", "pattern": "sparkle", "colors": ["#E8F4FF", "#B0DCFF", "#FFFFFF"], "brightness": 210, "delay": 50, "auto": true, "n1": 70, "n2": 165, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#081018", "manual_beat_n": 1}, "71": {"name": "test northern wave", "pattern": "aurora", "colors": ["#204060", "#4080C0", "#D0F0FF"], "brightness": 200, "delay": 75, "auto": true, "n1": 18, "n2": 190, "n3": 2, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#050A14", "manual_beat_n": 1}, "72": {"name": "test candle glow", "pattern": "candle_glow", "colors": ["#FF7020", "#FFD090", "#FFB060"], "brightness": 190, "delay": 65, "auto": true, "n1": 3, "n2": 4, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#080408", "manual_beat_n": 1}, "73": {"name": "test starfall", "pattern": "particles", "colors": ["#FFFFFF", "#B8D8FF", "#FFF0C0"], "brightness": 220, "delay": 50, "auto": true, "n1": 20, "n2": 3, "n3": 10, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#030610", "manual_beat_n": 1}, "74": {"name": "test ice sparkle", "pattern": "sparkle", "colors": ["#F0F8FF", "#A8D0FF", "#FFFFFF"], "brightness": 215, "delay": 45, "auto": true, "n1": 85, "n2": 150, "n3": 2, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#06121A", "manual_beat_n": 1}, "75": {"name": "test icicles", "pattern": "icicles", "colors": ["#E8F4FF", "#88C0FF", "#FFFFFF"], "brightness": 220, "delay": 70, "auto": true, "n1": 12, "n2": 9, "n3": 2, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#081420", "manual_beat_n": 1, "background_palette_ref": null}, "76": {"name": "test blizzard", "pattern": "blizzard", "colors": ["#FFFFFF", "#D0E8FF", "#B0C8F0"], "brightness": 220, "delay": 40, "auto": true, "n1": 95, "n2": 3, "n3": 128, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#040810", "manual_beat_n": 1}, "77": {"name": "test rime", "pattern": "rime", "colors": ["#E0F0FF", "#FFFFFF", "#A8D0F0"], "brightness": 205, "delay": 100, "auto": true, "n1": 35, "n2": 20, "n3": 5, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#061018", "manual_beat_n": 1}, "78": {"name": "winter off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1}, "79": {"name": "winter twinkle", "pattern": "twinkle", "colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"], "brightness": 220, "delay": 100, "auto": true, "n1": 150, "n2": 20, "n3": 0, "n4": 10, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null, null, null]}, "80": {"name": "winter icicles", "pattern": "icicles", "colors": ["#F0F8FF", "#9ECFFF", "#FFFFFF"], "brightness": 220, "delay": 80, "auto": true, "n1": 14, "n2": 11, "n3": 1, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "81": {"name": "winter blizzard", "pattern": "blizzard", "colors": ["#FFFFFF", "#CDE8FF", "#AACCF5"], "brightness": 220, "delay": 45, "auto": true, "n1": 110, "n2": 2, "n3": 140, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "82": {"name": "winter rime", "pattern": "rime", "colors": ["#E8F4FF", "#FFFFFF", "#B8DCF8"], "brightness": 220, "delay": 120, "auto": true, "n1": 40, "n2": 18, "n3": 4, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "83": {"name": "winter aurora", "pattern": "aurora", "colors": ["#183050", "#5090C8", "#C8E8FF"], "brightness": 220, "delay": 90, "auto": true, "n1": 22, "n2": 210, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "84": {"name": "winter starfall", "pattern": "particles", "colors": ["#FFFFFF", "#C8E8FF", "#FFF8E0"], "brightness": 220, "delay": 55, "auto": true, "n1": 16, "n2": 2, "n3": 12, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "85": {"name": "winter ice sparkle", "pattern": "sparkle", "colors": ["#E8F4FF", "#B0DCFF", "#FFFFFF"], "brightness": 220, "delay": 50, "auto": true, "n1": 70, "n2": 165, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "86": {"name": "winter cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1}, "87": {"name": "winter ice chase", "pattern": "chase", "colors": ["#E8F4FF", "#5080C8"], "brightness": 220, "delay": 120, "auto": false, "n1": 20, "n2": 20, "n3": 15, "n4": 15, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#071018", "manual_beat_n": 1, "palette_refs": [null, null]}} \ No newline at end of file +{"1": {"name": "on", "pattern": "on", "colors": ["#FFFFFF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "2": {"name": "off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "3": {"name": "rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 100, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "4": {"name": "transition", "pattern": "transition", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 255, "delay": 300, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#000000", "manual_beat_n": 1}, "5": {"name": "chase", "pattern": "chase", "colors": ["#FFFF00", "#FF00FF"], "brightness": 128, "delay": 200, "auto": false, "n1": 30, "n2": 30, "n3": 30, "n4": 30, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [3, 4], "manual_beat_n": 1, "background": "#000000", "background_palette_ref": null, "mode": 0}, "6": {"name": "pulse", "pattern": "pulse", "colors": ["#FF00FF"], "brightness": 255, "delay": 1000, "auto": false, "n1": 100, "n2": 0, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [4], "background_color": "#ec0909", "background_palette_ref": 8, "manual_beat_n": 1, "background": "#050500"}, "7": {"name": "circle", "pattern": "circle", "colors": ["#FFA500", "#800080"], "brightness": 255, "delay": 200, "auto": true, "n1": 2, "n2": 10, "n3": 2, "n4": 5, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "8": {"name": "blink", "pattern": "blink", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 1000, "auto": false, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, null]}, "9": {"name": "warm white", "pattern": "on", "colors": ["#FFF5E6"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "10": {"name": "cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "11": {"name": "red", "pattern": "on", "colors": ["#FF0000"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "12": {"name": "blue", "pattern": "on", "colors": ["#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "13": {"name": "rainbow slow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "14": {"name": "pulse slow", "pattern": "pulse", "colors": ["#FF6600"], "brightness": 255, "delay": 800, "auto": true, "n1": 2000, "n2": 1000, "n3": 2000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "15": {"name": "blink red green", "pattern": "blink", "colors": ["#FF0000", "#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "30": {"name": "rainbow slow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "31": {"name": "DJ Rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "32": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "33": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "34": {"name": "DJ Rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "35": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "36": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "37": {"name": "tranistion2", "pattern": "transition", "colors": ["#FF0000", "#FFFF00", "#FF00FF"], "brightness": 128, "delay": 1000, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [0, 3, 4]}, "38": {"name": "Colour Cycle", "pattern": "colour_cycle", "colors": ["#FF0000", "#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null]}, "39": {"name": "flicker", "pattern": "flicker", "colors": ["#ae00ff"], "brightness": 255, "delay": 50, "auto": false, "n1": 100, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "40": {"name": "flame", "pattern": "flame", "colors": ["#ffc800"], "brightness": 128, "delay": 50, "auto": true, "n1": 35, "n2": 2600, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "background_palette_ref": null, "manual_beat_n": 1}, "41": {"name": "twinkle", "pattern": "twinkle", "colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 150, "n2": 20, "n3": 0, "n4": 10, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, null, null], "background": "#000000", "manual_beat_n": 1, "background_palette_ref": null}, "42": {"name": "radiate", "pattern": "radiate", "colors": ["#a600ff"], "brightness": 255, "delay": 2000, "auto": false, "n1": 60, "n2": 200, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "manual_beat_n": 1, "background": "#050500", "background_palette_ref": 8}, "43": {"name": "test meteor rain", "pattern": "meteor", "colors": ["#FF5000", "#0080FF"], "brightness": 200, "delay": 40, "auto": true, "n1": 50, "n2": 1, "n3": 200, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null]}, "44": {"name": "test scanner", "pattern": "meteor", "colors": ["#FF0000"], "brightness": 255, "delay": 30, "auto": true, "n1": 4, "n2": 2, "n3": 0, "n4": 0, "n5": 0, "n6": 2, "n7": 0, "n8": 0, "profile_id": "1"}, "45": {"name": "test gradient scroll", "pattern": "colour_cycle", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 220, "delay": 60, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "46": {"name": "test comet dual", "pattern": "meteor", "colors": ["#FFAA00", "#00AAFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 8, "n2": 1, "n3": 3, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#0b000f", "manual_beat_n": 1}, "47": {"name": "test sparkle trail", "pattern": "sparkle", "colors": ["#88CCFF", "#FFFFFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 24, "n2": 210, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#000000", "manual_beat_n": 1}, "49": {"name": "test plasma", "pattern": "plasma", "colors": ["#FF0066"], "brightness": 200, "delay": 60, "auto": true, "n1": 6, "n2": 2, "n3": 2, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "51": {"name": "test bar graph", "pattern": "bar_graph", "colors": ["#00FF00", "#102010"], "brightness": 200, "delay": 60, "auto": true, "n1": 60, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "53": {"name": "test strobe burst", "pattern": "strobe_burst", "colors": ["#FFFFFF"], "brightness": 200, "delay": 60, "auto": false, "n1": 2, "n2": 10, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "54": {"name": "test rain drops", "pattern": "rain_drops", "colors": ["#7cbdfe"], "brightness": 200, "delay": 60, "auto": true, "n1": 32, "n2": 3, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "55": {"name": "test fireflies", "pattern": "sparkle", "colors": ["#FFD060", "#90FF90"], "brightness": 200, "delay": 60, "auto": false, "n1": 6, "n2": 8, "n3": 0, "n4": 0, "n5": 0, "n6": 2, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#000000", "manual_beat_n": 1}, "56": {"name": "test clock sweep", "pattern": "clock_sweep", "colors": ["#FFFFFF", "#202020"], "brightness": 200, "delay": 60, "auto": true, "n1": 1, "n2": 5, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "57": {"name": "test marquee", "pattern": "chase", "colors": ["#FFFFFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 2, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "58": {"name": "test aurora", "pattern": "aurora", "colors": ["#2CC88C", "#5078FF", "#A050DC"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 40, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "59": {"name": "test snowfall", "pattern": "particles", "colors": ["#FFFFFF", "#B0DCFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 20, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "61": {"name": "test orbit", "pattern": "orbit", "colors": ["#FFFFFF", "#00B4FF", "#FF0077"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null]}, "62": {"name": "test palette morph", "pattern": "palette_morph", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 200, "delay": 60, "auto": true, "n1": 1200, "n2": 200, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "63": {"name": "off", "pattern": "off", "colors": [], "background": "#000000", "brightness": 0, "delay": 0, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "manual_beat_n": 1, "profile_id": "2", "palette_refs": [], "auto": true}, "64": {"name": "winter icicles", "pattern": "icicles", "colors": ["#F0F8FF", "#9ECFFF", "#FFFFFF"], "brightness": 220, "delay": 80, "auto": true, "n1": 14, "n2": 11, "n3": 1, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#0A1520", "manual_beat_n": 1}, "65": {"name": "winter blizzard", "pattern": "blizzard", "colors": ["#FFFFFF", "#CDE8FF", "#AACCF5"], "brightness": 220, "delay": 45, "auto": true, "n1": 180, "n2": 4, "n3": 140, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#050810", "manual_beat_n": 1, "background_palette_ref": null}, "66": {"name": "winter rime frost", "pattern": "rime", "colors": ["#E8F4FF", "#FFFFFF", "#B8DCF8"], "brightness": 200, "delay": 120, "auto": true, "n1": 40, "n2": 18, "n3": 4, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#071018", "manual_beat_n": 1}, "67": {"name": "winter northern wave", "pattern": "aurora", "colors": ["#183050", "#5090C8", "#C8E8FF"], "brightness": 200, "delay": 90, "auto": true, "n1": 22, "n2": 210, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#060C18", "manual_beat_n": 1}, "68": {"name": "winter candle glow", "pattern": "candle_glow", "colors": ["#FF8020", "#FFC080", "#FFA040"], "brightness": 180, "delay": 70, "auto": true, "n1": 4, "n2": 3, "n3": 120, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#0A0508", "manual_beat_n": 1}, "69": {"name": "winter starfall", "pattern": "particles", "colors": ["#FFFFFF", "#C8E8FF", "#FFF8E0"], "brightness": 220, "delay": 55, "auto": true, "n1": 16, "n2": 2, "n3": 12, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#040810", "manual_beat_n": 1}, "70": {"name": "winter ice sparkle", "pattern": "sparkle", "colors": ["#E8F4FF", "#B0DCFF", "#FFFFFF"], "brightness": 210, "delay": 50, "auto": true, "n1": 70, "n2": 165, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#081018", "manual_beat_n": 1}, "71": {"name": "test northern wave", "pattern": "aurora", "colors": ["#204060", "#4080C0", "#D0F0FF"], "brightness": 200, "delay": 75, "auto": true, "n1": 18, "n2": 190, "n3": 2, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#050A14", "manual_beat_n": 1}, "72": {"name": "test candle glow", "pattern": "candle_glow", "colors": ["#FF7020", "#FFD090", "#FFB060"], "brightness": 190, "delay": 65, "auto": true, "n1": 3, "n2": 4, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#080408", "manual_beat_n": 1}, "73": {"name": "test starfall", "pattern": "particles", "colors": ["#FFFFFF", "#B8D8FF", "#FFF0C0"], "brightness": 220, "delay": 50, "auto": true, "n1": 20, "n2": 3, "n3": 10, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#030610", "manual_beat_n": 1}, "74": {"name": "test ice sparkle", "pattern": "sparkle", "colors": ["#F0F8FF", "#A8D0FF", "#FFFFFF"], "brightness": 215, "delay": 45, "auto": true, "n1": 85, "n2": 150, "n3": 2, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#06121A", "manual_beat_n": 1}, "75": {"name": "test icicles", "pattern": "icicles", "colors": ["#E8F4FF", "#88C0FF", "#FFFFFF"], "brightness": 220, "delay": 70, "auto": true, "n1": 12, "n2": 9, "n3": 2, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#081420", "manual_beat_n": 1, "background_palette_ref": null}, "76": {"name": "test blizzard", "pattern": "blizzard", "colors": ["#FFFFFF", "#D0E8FF", "#B0C8F0"], "brightness": 220, "delay": 40, "auto": true, "n1": 95, "n2": 3, "n3": 128, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#040810", "manual_beat_n": 1}, "77": {"name": "test rime", "pattern": "rime", "colors": ["#E0F0FF", "#FFFFFF", "#A8D0F0"], "brightness": 205, "delay": 100, "auto": true, "n1": 35, "n2": 20, "n3": 5, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#061018", "manual_beat_n": 1}, "78": {"name": "winter off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1}, "79": {"name": "winter twinkle", "pattern": "twinkle", "colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"], "brightness": 220, "delay": 100, "auto": true, "n1": 150, "n2": 20, "n3": 0, "n4": 10, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null, null, null]}, "80": {"name": "winter icicles", "pattern": "icicles", "colors": ["#F0F8FF", "#9ECFFF", "#FFFFFF"], "brightness": 220, "delay": 80, "auto": true, "n1": 14, "n2": 11, "n3": 1, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "81": {"name": "winter blizzard", "pattern": "blizzard", "colors": ["#FFFFFF", "#CDE8FF", "#AACCF5"], "brightness": 220, "delay": 45, "auto": true, "n1": 110, "n2": 2, "n3": 140, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "82": {"name": "winter rime", "pattern": "rime", "colors": ["#E8F4FF", "#FFFFFF", "#B8DCF8"], "brightness": 220, "delay": 120, "auto": true, "n1": 40, "n2": 18, "n3": 4, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "83": {"name": "winter aurora", "pattern": "aurora", "colors": ["#183050", "#5090C8", "#C8E8FF"], "brightness": 220, "delay": 90, "auto": true, "n1": 22, "n2": 210, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "84": {"name": "winter starfall", "pattern": "particles", "colors": ["#FFFFFF", "#C8E8FF", "#FFF8E0"], "brightness": 220, "delay": 55, "auto": true, "n1": 16, "n2": 2, "n3": 12, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "85": {"name": "winter ice sparkle", "pattern": "sparkle", "colors": ["#E8F4FF", "#B0DCFF", "#FFFFFF"], "brightness": 220, "delay": 50, "auto": true, "n1": 70, "n2": 165, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "86": {"name": "winter cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1}, "87": {"name": "winter ice chase", "pattern": "chase", "colors": ["#E8F4FF", "#5080C8"], "brightness": 220, "delay": 120, "auto": false, "n1": 20, "n2": 20, "n3": 15, "n4": 15, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#071018", "manual_beat_n": 1, "palette_refs": [null, null]}, "88": {"name": "on", "pattern": "on", "colors": ["#FFFFFF"], "background": "#000000", "brightness": 255, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "manual_beat_n": 1, "profile_id": "4", "auto": true}, "89": {"name": "off", "pattern": "off", "colors": [], "background": "#000000", "brightness": 0, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "manual_beat_n": 1, "profile_id": "4", "auto": true}, "90": {"name": "rainbow", "pattern": "colour_cycle", "colors": [], "background": "#000000", "brightness": 255, "delay": 100, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "manual_beat_n": 1, "profile_id": "4", "auto": true, "mode": 1}, "91": {"name": "Colour Cycle", "pattern": "colour_cycle", "colors": ["#FF0000", "#00FF00", "#0000FF"], "background": "#000000", "brightness": 255, "delay": 100, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "manual_beat_n": 1, "profile_id": "4", "auto": true}, "92": {"name": "transition", "pattern": "transition", "colors": ["#FF0000", "#00FF00", "#0000FF"], "background": "#000000", "brightness": 255, "delay": 500, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "manual_beat_n": 1, "profile_id": "4", "auto": true}, "93": {"name": "flicker", "pattern": "flicker", "colors": ["#FFB84D"], "background": "#000000", "brightness": 255, "delay": 80, "n1": 30, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "manual_beat_n": 1, "profile_id": "4", "auto": true}, "94": {"name": "flame", "pattern": "flame", "colors": [], "background": "#000000", "brightness": 255, "delay": 50, "n1": 35, "n2": 2600, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "manual_beat_n": 1, "profile_id": "4", "auto": true}, "95": {"name": "twinkle", "pattern": "twinkle", "colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"], "background": "#000000", "brightness": 255, "delay": 55, "n1": 72, "n2": 140, "n3": 2, "n4": 6, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "manual_beat_n": 1, "profile_id": "4", "auto": true}, "96": {"name": "p", "pattern": "", "colors": [], "background": "#000000", "brightness": 0, "delay": 0, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "manual_beat_n": 1, "profile_id": "1"}, "97": {"name": "Browser Test Preset", "pattern": "on", "colors": ["#ff0000"], "background": "#000000", "brightness": 200, "delay": 0, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "manual_beat_n": 1, "profile_id": "1", "palette_refs": [null], "background_palette_ref": null, "auto": true}, "98": {"name": "Browser Test Preset", "pattern": "on", "colors": ["#ff0000"], "background": "#000000", "brightness": 200, "delay": 0, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "manual_beat_n": 1, "profile_id": "1", "palette_refs": [null], "background_palette_ref": null, "auto": true}} \ No newline at end of file diff --git a/db/profile.json b/db/profile.json index 11b0fcc..fcf3009 100644 --- a/db/profile.json +++ b/db/profile.json @@ -1 +1 @@ -{"1":{"name":"default","type":"zones","zones":["1","9","8","10"],"scenes":[],"palette_id":"1"},"2":{"name":"test","type":"zones","zones":["6","7"],"scenes":[],"palette_id":"12"},"3":{"name":"Winter","type":"zones","zones":["11","12"],"scenes":[],"palette_id":"14"}} \ No newline at end of file +{"1": {"name": "default", "type": "zones", "zones": ["1", "9", "8", "10"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "zones", "zones": ["6", "7"], "scenes": [], "palette_id": "12"}, "3": {"name": "Winter", "type": "zones", "zones": ["11", "12"], "scenes": [], "palette_id": "14"}, "4": {"name": "t", "type": "zones", "zones": ["13"], "scenes": [], "palette_id": "15"}} \ No newline at end of file diff --git a/lib/microdot/__init__.py b/lib/microdot/__init__.py deleted file mode 100644 index 68cb381..0000000 --- a/lib/microdot/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from microdot.microdot import Microdot, Request, Response, abort, redirect, \ - send_file # noqa: F401 \ No newline at end of file diff --git a/lib/microdot/helpers.py b/lib/microdot/helpers.py deleted file mode 100644 index 664e58c..0000000 --- a/lib/microdot/helpers.py +++ /dev/null @@ -1,8 +0,0 @@ -try: - from functools import wraps -except ImportError: # pragma: no cover - # MicroPython does not currently implement functools.wraps - def wraps(wrapped): - def _(wrapper): - return wrapper - return _ diff --git a/lib/microdot/microdot.py b/lib/microdot/microdot.py deleted file mode 100644 index 0513f21..0000000 --- a/lib/microdot/microdot.py +++ /dev/null @@ -1,1450 +0,0 @@ -""" -microdot --------- - -The ``microdot`` module defines a few classes that help implement HTTP-based -servers for MicroPython and standard Python. -""" -import asyncio -import io -import json -import time - -try: - from inspect import iscoroutinefunction, iscoroutine - from functools import partial - - async def invoke_handler(handler, *args, **kwargs): - """Invoke a handler and return the result. - - This method runs sync handlers in a thread pool executor. - """ - if iscoroutinefunction(handler): - ret = await handler(*args, **kwargs) - else: - ret = await asyncio.get_running_loop().run_in_executor( - None, partial(handler, *args, **kwargs)) - return ret -except ImportError: # pragma: no cover - def iscoroutine(coro): - return hasattr(coro, 'send') and hasattr(coro, 'throw') - - async def invoke_handler(handler, *args, **kwargs): - """Invoke a handler and return the result. - - This method runs sync handlers in the asyncio thread, which can - potentially cause blocking and performance issues. - """ - ret = handler(*args, **kwargs) - if iscoroutine(ret): - ret = await ret - return ret - -try: - from sys import print_exception -except ImportError: # pragma: no cover - import traceback - - def print_exception(exc): - traceback.print_exc() - -MUTED_SOCKET_ERRORS = [ - 32, # Broken pipe - 54, # Connection reset by peer - 104, # Connection reset by peer - 128, # Operation on closed socket -] - - -def urldecode_str(s): - s = s.replace('+', ' ') - parts = s.split('%') - if len(parts) == 1: - return s - result = [parts[0]] - for item in parts[1:]: - if item == '': - result.append('%') - else: - code = item[:2] - result.append(chr(int(code, 16))) - result.append(item[2:]) - return ''.join(result) - - -def urldecode_bytes(s): - s = s.replace(b'+', b' ') - parts = s.split(b'%') - if len(parts) == 1: - return s.decode() - result = [parts[0]] - for item in parts[1:]: - if item == b'': - result.append(b'%') - else: - code = item[:2] - result.append(bytes([int(code, 16)])) - result.append(item[2:]) - return b''.join(result).decode() - - -def urlencode(s): - return s.replace('+', '%2B').replace(' ', '+').replace( - '%', '%25').replace('?', '%3F').replace('#', '%23').replace( - '&', '%26').replace('=', '%3D') - - -class NoCaseDict(dict): - """A subclass of dictionary that holds case-insensitive keys. - - :param initial_dict: an initial dictionary of key/value pairs to - initialize this object with. - - Example:: - - >>> d = NoCaseDict() - >>> d['Content-Type'] = 'text/html' - >>> print(d['Content-Type']) - text/html - >>> print(d['content-type']) - text/html - >>> print(d['CONTENT-TYPE']) - text/html - >>> del d['cOnTeNt-TyPe'] - >>> print(d) - {} - """ - def __init__(self, initial_dict=None): - super().__init__(initial_dict or {}) - self.keymap = {k.lower(): k for k in self.keys() if k.lower() != k} - - def __setitem__(self, key, value): - kl = key.lower() - key = self.keymap.get(kl, key) - if kl != key: - self.keymap[kl] = key - super().__setitem__(key, value) - - def __getitem__(self, key): - kl = key.lower() - return super().__getitem__(self.keymap.get(kl, kl)) - - def __delitem__(self, key): - kl = key.lower() - super().__delitem__(self.keymap.get(kl, kl)) - - def __contains__(self, key): - kl = key.lower() - return self.keymap.get(kl, kl) in self.keys() - - def get(self, key, default=None): - kl = key.lower() - return super().get(self.keymap.get(kl, kl), default) - - def update(self, other_dict): - for key, value in other_dict.items(): - self[key] = value - - -def mro(cls): # pragma: no cover - """Return the method resolution order of a class. - - This is a helper function that returns the method resolution order of a - class. It is used by Microdot to find the best error handler to invoke for - the raised exception. - - In CPython, this function returns the ``__mro__`` attribute of the class. - In MicroPython, this function implements a recursive depth-first scanning - of the class hierarchy. - """ - if hasattr(cls, 'mro'): - return cls.__mro__ - - def _mro(cls): - m = [cls] - for base in cls.__bases__: - m += _mro(base) - return m - - mro_list = _mro(cls) - - # If a class appears multiple times (due to multiple inheritance) remove - # all but the last occurence. This matches the method resolution order - # of MicroPython, but not CPython. - mro_pruned = [] - for i in range(len(mro_list)): - base = mro_list.pop(0) - if base not in mro_list: - mro_pruned.append(base) - return mro_pruned - - -class MultiDict(dict): - """A subclass of dictionary that can hold multiple values for the same - key. It is used to hold key/value pairs decoded from query strings and - form submissions. - - :param initial_dict: an initial dictionary of key/value pairs to - initialize this object with. - - Example:: - - >>> d = MultiDict() - >>> d['sort'] = 'name' - >>> d['sort'] = 'email' - >>> print(d['sort']) - 'name' - >>> print(d.getlist('sort')) - ['name', 'email'] - """ - def __init__(self, initial_dict=None): - super().__init__() - if initial_dict: - for key, value in initial_dict.items(): - self[key] = value - - def __setitem__(self, key, value): - if key not in self: - super().__setitem__(key, []) - super().__getitem__(key).append(value) - - def __getitem__(self, key): - return super().__getitem__(key)[0] - - def get(self, key, default=None, type=None): - """Return the value for a given key. - - :param key: The key to retrieve. - :param default: A default value to use if the key does not exist. - :param type: A type conversion callable to apply to the value. - - If the multidict contains more than one value for the requested key, - this method returns the first value only. - - Example:: - - >>> d = MultiDict() - >>> d['age'] = '42' - >>> d.get('age') - '42' - >>> d.get('age', type=int) - 42 - >>> d.get('name', default='noname') - 'noname' - """ - if key not in self: - return default - value = self[key] - if type is not None: - value = type(value) - return value - - def getlist(self, key, type=None): - """Return all the values for a given key. - - :param key: The key to retrieve. - :param type: A type conversion callable to apply to the values. - - If the requested key does not exist in the dictionary, this method - returns an empty list. - - Example:: - - >>> d = MultiDict() - >>> d.getlist('items') - [] - >>> d['items'] = '3' - >>> d.getlist('items') - ['3'] - >>> d['items'] = '56' - >>> d.getlist('items') - ['3', '56'] - >>> d.getlist('items', type=int) - [3, 56] - """ - if key not in self: - return [] - values = super().__getitem__(key) - if type is not None: - values = [type(value) for value in values] - return values - - -class AsyncBytesIO: - """An async wrapper for BytesIO.""" - def __init__(self, data): - self.stream = io.BytesIO(data) - - async def read(self, n=-1): - return self.stream.read(n) - - async def readline(self): # pragma: no cover - return self.stream.readline() - - async def readexactly(self, n): # pragma: no cover - return self.stream.read(n) - - async def readuntil(self, separator=b'\n'): # pragma: no cover - return self.stream.readuntil(separator=separator) - - async def awrite(self, data): # pragma: no cover - return self.stream.write(data) - - async def aclose(self): # pragma: no cover - pass - - -class Request: - """An HTTP request.""" - #: Specify the maximum payload size that is accepted. Requests with larger - #: payloads will be rejected with a 413 status code. Applications can - #: change this maximum as necessary. - #: - #: Example:: - #: - #: Request.max_content_length = 1 * 1024 * 1024 # 1MB requests allowed - max_content_length = 16 * 1024 - - #: Specify the maximum payload size that can be stored in ``body``. - #: Requests with payloads that are larger than this size and up to - #: ``max_content_length`` bytes will be accepted, but the application will - #: only be able to access the body of the request by reading from - #: ``stream``. Set to 0 if you always access the body as a stream. - #: - #: Example:: - #: - #: Request.max_body_length = 4 * 1024 # up to 4KB bodies read - max_body_length = 16 * 1024 - - #: Specify the maximum length allowed for a line in the request. Requests - #: with longer lines will not be correctly interpreted. Applications can - #: change this maximum as necessary. - #: - #: Example:: - #: - #: Request.max_readline = 16 * 1024 # 16KB lines allowed - max_readline = 2 * 1024 - - class G: - pass - - def __init__(self, app, client_addr, method, url, http_version, headers, - body=None, stream=None, sock=None): - #: The application instance to which this request belongs. - self.app = app - #: The address of the client, as a tuple (host, port). - self.client_addr = client_addr - #: The HTTP method of the request. - self.method = method - #: The request URL, including the path and query string. - self.url = url - #: The path portion of the URL. - self.path = url - #: The query string portion of the URL. - self.query_string = None - #: The parsed query string, as a - #: :class:`MultiDict ` object. - self.args = {} - #: A dictionary with the headers included in the request. - self.headers = headers - #: A dictionary with the cookies included in the request. - self.cookies = {} - #: The parsed ``Content-Length`` header. - self.content_length = 0 - #: The parsed ``Content-Type`` header. - self.content_type = None - #: A general purpose container for applications to store data during - #: the life of the request. - self.g = Request.G() - - self.http_version = http_version - if '?' in self.path: - self.path, self.query_string = self.path.split('?', 1) - self.args = self._parse_urlencoded(self.query_string) - - if 'Content-Length' in self.headers: - self.content_length = int(self.headers['Content-Length']) - if 'Content-Type' in self.headers: - self.content_type = self.headers['Content-Type'] - if 'Cookie' in self.headers: - for cookie in self.headers['Cookie'].split(';'): - name, value = cookie.strip().split('=', 1) - self.cookies[name] = value - - self._body = body - self.body_used = False - self._stream = stream - self.sock = sock - self._json = None - self._form = None - self.after_request_handlers = [] - - @staticmethod - async def create(app, client_reader, client_writer, client_addr): - """Create a request object. - - :param app: The Microdot application instance. - :param client_reader: An input stream from where the request data can - be read. - :param client_writer: An output stream where the response data can be - written. - :param client_addr: The address of the client, as a tuple. - - This method is a coroutine. It returns a newly created ``Request`` - object. - """ - # request line - line = (await Request._safe_readline(client_reader)).strip().decode() - if not line: # pragma: no cover - return None - method, url, http_version = line.split() - http_version = http_version.split('/', 1)[1] - - # headers - headers = NoCaseDict() - content_length = 0 - while True: - line = (await Request._safe_readline( - client_reader)).strip().decode() - if line == '': - break - header, value = line.split(':', 1) - value = value.strip() - headers[header] = value - if header.lower() == 'content-length': - content_length = int(value) - - # body - body = b'' - if content_length and content_length <= Request.max_body_length: - body = await client_reader.readexactly(content_length) - stream = None - else: - body = b'' - stream = client_reader - - return Request(app, client_addr, method, url, http_version, headers, - body=body, stream=stream, - sock=(client_reader, client_writer)) - - def _parse_urlencoded(self, urlencoded): - data = MultiDict() - if len(urlencoded) > 0: # pragma: no branch - if isinstance(urlencoded, str): - for kv in [pair.split('=', 1) - for pair in urlencoded.split('&') if pair]: - data[urldecode_str(kv[0])] = urldecode_str(kv[1]) \ - if len(kv) > 1 else '' - elif isinstance(urlencoded, bytes): # pragma: no branch - for kv in [pair.split(b'=', 1) - for pair in urlencoded.split(b'&') if pair]: - data[urldecode_bytes(kv[0])] = urldecode_bytes(kv[1]) \ - if len(kv) > 1 else b'' - return data - - @property - def body(self): - """The body of the request, as bytes.""" - return self._body - - @property - def stream(self): - """The body of the request, as a bytes stream.""" - if self._stream is None: - self._stream = AsyncBytesIO(self._body) - return self._stream - - @property - def json(self): - """The parsed JSON body, or ``None`` if the request does not have a - JSON body.""" - if self._json is None: - if self.content_type is None: - return None - mime_type = self.content_type.split(';')[0] - if mime_type != 'application/json': - return None - self._json = json.loads(self.body.decode()) - return self._json - - @property - def form(self): - """The parsed form submission body, as a - :class:`MultiDict ` object, or ``None`` if the - request does not have a form submission.""" - if self._form is None: - if self.content_type is None: - return None - mime_type = self.content_type.split(';')[0] - if mime_type != 'application/x-www-form-urlencoded': - return None - self._form = self._parse_urlencoded(self.body) - return self._form - - def after_request(self, f): - """Register a request-specific function to run after the request is - handled. Request-specific after request handlers run at the very end, - after the application's own after request handlers. The function must - take two arguments, the request and response objects. The return value - of the function must be the updated response object. - - Example:: - - @app.route('/') - def index(request): - # register a request-specific after request handler - @req.after_request - def func(request, response): - # ... - return response - - return 'Hello, World!' - - Note that the function is not called if the request handler raises an - exception and an error response is returned instead. - """ - self.after_request_handlers.append(f) - return f - - @staticmethod - async def _safe_readline(stream): - line = (await stream.readline()) - if len(line) > Request.max_readline: - raise ValueError('line too long') - return line - - -class Response: - """An HTTP response class. - - :param body: The body of the response. If a dictionary or list is given, - a JSON formatter is used to generate the body. If a file-like - object or an async generator is given, a streaming response is - used. If a string is given, it is encoded from UTF-8. Else, - the body should be a byte sequence. - :param status_code: The numeric HTTP status code of the response. The - default is 200. - :param headers: A dictionary of headers to include in the response. - :param reason: A custom reason phrase to add after the status code. The - default is "OK" for responses with a 200 status code and - "N/A" for any other status codes. - """ - types_map = { - 'css': 'text/css', - 'gif': 'image/gif', - 'html': 'text/html', - 'jpg': 'image/jpeg', - 'js': 'application/javascript', - 'json': 'application/json', - 'png': 'image/png', - 'txt': 'text/plain', - } - - send_file_buffer_size = 1024 - - #: The content type to use for responses that do not explicitly define a - #: ``Content-Type`` header. - default_content_type = 'text/plain' - - #: The default cache control max age used by :meth:`send_file`. A value - #: of ``None`` means that no ``Cache-Control`` header is added. - default_send_file_max_age = None - - #: Special response used to signal that a response does not need to be - #: written to the client. Used to exit WebSocket connections cleanly. - already_handled = None - - def __init__(self, body='', status_code=200, headers=None, reason=None): - if body is None and status_code == 200: - body = '' - status_code = 204 - self.status_code = status_code - self.headers = NoCaseDict(headers or {}) - self.reason = reason - if isinstance(body, (dict, list)): - self.body = json.dumps(body).encode() - self.headers['Content-Type'] = 'application/json; charset=UTF-8' - elif isinstance(body, str): - self.body = body.encode() - else: - # this applies to bytes, file-like objects or generators - self.body = body - self.is_head = False - - def set_cookie(self, cookie, value, path=None, domain=None, expires=None, - max_age=None, secure=False, http_only=False, - partitioned=False): - """Add a cookie to the response. - - :param cookie: The cookie's name. - :param value: The cookie's value. - :param path: The cookie's path. - :param domain: The cookie's domain. - :param expires: The cookie expiration time, as a ``datetime`` object - or a correctly formatted string. - :param max_age: The cookie's ``Max-Age`` value. - :param secure: The cookie's ``secure`` flag. - :param http_only: The cookie's ``HttpOnly`` flag. - :param partitioned: Whether the cookie is partitioned. - """ - http_cookie = '{cookie}={value}'.format(cookie=cookie, value=value) - if path: - http_cookie += '; Path=' + path - if domain: - http_cookie += '; Domain=' + domain - if expires: - if isinstance(expires, str): - http_cookie += '; Expires=' + expires - else: # pragma: no cover - http_cookie += '; Expires=' + time.strftime( - '%a, %d %b %Y %H:%M:%S GMT', expires.timetuple()) - if max_age is not None: - http_cookie += '; Max-Age=' + str(max_age) - if secure: - http_cookie += '; Secure' - if http_only: - http_cookie += '; HttpOnly' - if partitioned: - http_cookie += '; Partitioned' - if 'Set-Cookie' in self.headers: - self.headers['Set-Cookie'].append(http_cookie) - else: - self.headers['Set-Cookie'] = [http_cookie] - - def delete_cookie(self, cookie, **kwargs): - """Delete a cookie. - - :param cookie: The cookie's name. - :param kwargs: Any cookie opens and flags supported by - ``set_cookie()`` except ``expires`` and ``max_age``. - """ - self.set_cookie(cookie, '', expires='Thu, 01 Jan 1970 00:00:01 GMT', - max_age=0, **kwargs) - - def complete(self): - if isinstance(self.body, bytes) and \ - 'Content-Length' not in self.headers: - self.headers['Content-Length'] = str(len(self.body)) - if 'Content-Type' not in self.headers: - self.headers['Content-Type'] = self.default_content_type - if 'charset=' not in self.headers['Content-Type']: - self.headers['Content-Type'] += '; charset=UTF-8' - - async def write(self, stream): - self.complete() - - try: - # status code - reason = self.reason if self.reason is not None else \ - ('OK' if self.status_code == 200 else 'N/A') - await stream.awrite('HTTP/1.0 {status_code} {reason}\r\n'.format( - status_code=self.status_code, reason=reason).encode()) - - # headers - for header, value in self.headers.items(): - values = value if isinstance(value, list) else [value] - for value in values: - await stream.awrite('{header}: {value}\r\n'.format( - header=header, value=value).encode()) - await stream.awrite(b'\r\n') - - # body - if not self.is_head: - iter = self.body_iter() - async for body in iter: - if isinstance(body, str): # pragma: no cover - body = body.encode() - try: - await stream.awrite(body) - except OSError as exc: # pragma: no cover - if exc.errno in MUTED_SOCKET_ERRORS or \ - exc.args[0] == 'Connection lost': - if hasattr(iter, 'aclose'): - await iter.aclose() - raise - if hasattr(iter, 'aclose'): # pragma: no branch - await iter.aclose() - - except OSError as exc: # pragma: no cover - if exc.errno in MUTED_SOCKET_ERRORS or \ - exc.args[0] == 'Connection lost': - pass - else: - raise - - def body_iter(self): - if hasattr(self.body, '__anext__'): - # response body is an async generator - return self.body - - response = self - - class iter: - ITER_UNKNOWN = 0 - ITER_SYNC_GEN = 1 - ITER_FILE_OBJ = 2 - ITER_NO_BODY = -1 - - def __aiter__(self): - if response.body: - self.i = self.ITER_UNKNOWN # need to determine type - else: - self.i = self.ITER_NO_BODY - return self - - async def __anext__(self): - if self.i == self.ITER_NO_BODY: - await self.aclose() - raise StopAsyncIteration - if self.i == self.ITER_UNKNOWN: - if hasattr(response.body, 'read'): - self.i = self.ITER_FILE_OBJ - elif hasattr(response.body, '__next__'): - self.i = self.ITER_SYNC_GEN - return next(response.body) - else: - self.i = self.ITER_NO_BODY - return response.body - elif self.i == self.ITER_SYNC_GEN: - try: - return next(response.body) - except StopIteration: - await self.aclose() - raise StopAsyncIteration - buf = response.body.read(response.send_file_buffer_size) - if iscoroutine(buf): # pragma: no cover - buf = await buf - if len(buf) < response.send_file_buffer_size: - self.i = self.ITER_NO_BODY - return buf - - async def aclose(self): - if hasattr(response.body, 'close'): - result = response.body.close() - if iscoroutine(result): # pragma: no cover - await result - - return iter() - - @classmethod - def redirect(cls, location, status_code=302): - """Return a redirect response. - - :param location: The URL to redirect to. - :param status_code: The 3xx status code to use for the redirect. The - default is 302. - """ - if '\x0d' in location or '\x0a' in location: - raise ValueError('invalid redirect URL') - return cls(status_code=status_code, headers={'Location': location}) - - @classmethod - def send_file(cls, filename, status_code=200, content_type=None, - stream=None, max_age=None, compressed=False, - file_extension=''): - """Send file contents in a response. - - :param filename: The filename of the file. - :param status_code: The 3xx status code to use for the redirect. The - default is 302. - :param content_type: The ``Content-Type`` header to use in the - response. If omitted, it is generated - automatically from the file extension of the - ``filename`` parameter. - :param stream: A file-like object to read the file contents from. If - a stream is given, the ``filename`` parameter is only - used when generating the ``Content-Type`` header. - :param max_age: The ``Cache-Control`` header's ``max-age`` value in - seconds. If omitted, the value of the - :attr:`Response.default_send_file_max_age` attribute is - used. - :param compressed: Whether the file is compressed. If ``True``, the - ``Content-Encoding`` header is set to ``gzip``. A - string with the header value can also be passed. - Note that when using this option the file must have - been compressed beforehand. This option only sets - the header. - :param file_extension: A file extension to append to the ``filename`` - parameter when opening the file, including the - dot. The extension given here is not considered - when generating the ``Content-Type`` header. - - Security note: The filename is assumed to be trusted. Never pass - filenames provided by the user without validating and sanitizing them - first. - """ - if content_type is None: - if compressed and filename.endswith('.gz'): - ext = filename[:-3].split('.')[-1] - else: - ext = filename.split('.')[-1] - if ext in Response.types_map: - content_type = Response.types_map[ext] - else: - content_type = 'application/octet-stream' - headers = {'Content-Type': content_type} - - if max_age is None: - max_age = cls.default_send_file_max_age - if max_age is not None: - headers['Cache-Control'] = 'max-age={}'.format(max_age) - - if compressed: - headers['Content-Encoding'] = compressed \ - if isinstance(compressed, str) else 'gzip' - - f = stream or open(filename + file_extension, 'rb') - return cls(body=f, status_code=status_code, headers=headers) - - -class URLPattern(): - def __init__(self, url_pattern): - self.url_pattern = url_pattern - self.segments = [] - self.regex = None - pattern = '' - use_regex = False - for segment in url_pattern.lstrip('/').split('/'): - if segment and segment[0] == '<': - if segment[-1] != '>': - raise ValueError('invalid URL pattern') - segment = segment[1:-1] - if ':' in segment: - type_, name = segment.rsplit(':', 1) - else: - type_ = 'string' - name = segment - parser = None - if type_ == 'string': - parser = self._string_segment - pattern += '/([^/]+)' - elif type_ == 'int': - parser = self._int_segment - pattern += '/(-?\\d+)' - elif type_ == 'path': - use_regex = True - pattern += '/(.+)' - elif type_.startswith('re:'): - use_regex = True - pattern += '/({pattern})'.format(pattern=type_[3:]) - else: - raise ValueError('invalid URL segment type') - self.segments.append({'parser': parser, 'name': name, - 'type': type_}) - else: - pattern += '/' + segment - self.segments.append({'parser': self._static_segment(segment)}) - if use_regex: - import re - self.regex = re.compile('^' + pattern + '$') - - def match(self, path): - args = {} - if self.regex: - g = self.regex.match(path) - if not g: - return - i = 1 - for segment in self.segments: - if 'name' not in segment: - continue - value = g.group(i) - if segment['type'] == 'int': - value = int(value) - args[segment['name']] = value - i += 1 - else: - if len(path) == 0 or path[0] != '/': - return - path = path[1:] - args = {} - for segment in self.segments: - if path is None: - return - arg, path = segment['parser'](path) - if arg is None: - return - if 'name' in segment: - args[segment['name']] = arg - if path is not None: - return - return args - - def _static_segment(self, segment): - def _static(value): - s = value.split('/', 1) - if s[0] == segment: - return '', s[1] if len(s) > 1 else None - return None, None - return _static - - def _string_segment(self, value): - s = value.split('/', 1) - if len(s[0]) == 0: - return None, None - return s[0], s[1] if len(s) > 1 else None - - def _int_segment(self, value): - s = value.split('/', 1) - try: - return int(s[0]), s[1] if len(s) > 1 else None - except ValueError: - return None, None - - -class HTTPException(Exception): - def __init__(self, status_code, reason=None): - self.status_code = status_code - self.reason = reason or str(status_code) + ' error' - - def __repr__(self): # pragma: no cover - return 'HTTPException: {}'.format(self.status_code) - - -class Microdot: - """An HTTP application class. - - This class implements an HTTP application instance and is heavily - influenced by the ``Flask`` class of the Flask framework. It is typically - declared near the start of the main application script. - - Example:: - - from microdot import Microdot - - app = Microdot() - """ - - def __init__(self): - self.url_map = [] - self.before_request_handlers = [] - self.after_request_handlers = [] - self.after_error_request_handlers = [] - self.error_handlers = {} - self.shutdown_requested = False - self.options_handler = self.default_options_handler - self.debug = False - self.server = None - - def route(self, url_pattern, methods=None): - """Decorator that is used to register a function as a request handler - for a given URL. - - :param url_pattern: The URL pattern that will be compared against - incoming requests. - :param methods: The list of HTTP methods to be handled by the - decorated function. If omitted, only ``GET`` requests - are handled. - - The URL pattern can be a static path (for example, ``/users`` or - ``/api/invoices/search``) or a path with dynamic components enclosed - in ``<`` and ``>`` (for example, ``/users/`` or - ``/invoices//products``). Dynamic path components can also - include a type prefix, separated from the name with a colon (for - example, ``/users/``). The type can be ``string`` (the - default), ``int``, ``path`` or ``re:[regular-expression]``. - - The first argument of the decorated function must be - the request object. Any path arguments that are specified in the URL - pattern are passed as keyword arguments. The return value of the - function must be a :class:`Response` instance, or the arguments to - be passed to this class. - - Example:: - - @app.route('/') - def index(request): - return 'Hello, world!' - """ - def decorated(f): - self.url_map.append( - ([m.upper() for m in (methods or ['GET'])], - URLPattern(url_pattern), f)) - return f - return decorated - - def get(self, url_pattern): - """Decorator that is used to register a function as a ``GET`` request - handler for a given URL. - - :param url_pattern: The URL pattern that will be compared against - incoming requests. - - This decorator can be used as an alias to the ``route`` decorator with - ``methods=['GET']``. - - Example:: - - @app.get('/users/') - def get_user(request, id): - # ... - """ - return self.route(url_pattern, methods=['GET']) - - def post(self, url_pattern): - """Decorator that is used to register a function as a ``POST`` request - handler for a given URL. - - :param url_pattern: The URL pattern that will be compared against - incoming requests. - - This decorator can be used as an alias to the``route`` decorator with - ``methods=['POST']``. - - Example:: - - @app.post('/users') - def create_user(request): - # ... - """ - return self.route(url_pattern, methods=['POST']) - - def put(self, url_pattern): - """Decorator that is used to register a function as a ``PUT`` request - handler for a given URL. - - :param url_pattern: The URL pattern that will be compared against - incoming requests. - - This decorator can be used as an alias to the ``route`` decorator with - ``methods=['PUT']``. - - Example:: - - @app.put('/users/') - def edit_user(request, id): - # ... - """ - return self.route(url_pattern, methods=['PUT']) - - def patch(self, url_pattern): - """Decorator that is used to register a function as a ``PATCH`` request - handler for a given URL. - - :param url_pattern: The URL pattern that will be compared against - incoming requests. - - This decorator can be used as an alias to the ``route`` decorator with - ``methods=['PATCH']``. - - Example:: - - @app.patch('/users/') - def edit_user(request, id): - # ... - """ - return self.route(url_pattern, methods=['PATCH']) - - def delete(self, url_pattern): - """Decorator that is used to register a function as a ``DELETE`` - request handler for a given URL. - - :param url_pattern: The URL pattern that will be compared against - incoming requests. - - This decorator can be used as an alias to the ``route`` decorator with - ``methods=['DELETE']``. - - Example:: - - @app.delete('/users/') - def delete_user(request, id): - # ... - """ - return self.route(url_pattern, methods=['DELETE']) - - def before_request(self, f): - """Decorator to register a function to run before each request is - handled. The decorated function must take a single argument, the - request object. - - Example:: - - @app.before_request - def func(request): - # ... - """ - self.before_request_handlers.append(f) - return f - - def after_request(self, f): - """Decorator to register a function to run after each request is - handled. The decorated function must take two arguments, the request - and response objects. The return value of the function must be an - updated response object. - - Example:: - - @app.after_request - def func(request, response): - # ... - return response - """ - self.after_request_handlers.append(f) - return f - - def after_error_request(self, f): - """Decorator to register a function to run after an error response is - generated. The decorated function must take two arguments, the request - and response objects. The return value of the function must be an - updated response object. The handler is invoked for error responses - generated by Microdot, as well as those returned by application-defined - error handlers. - - Example:: - - @app.after_error_request - def func(request, response): - # ... - return response - """ - self.after_error_request_handlers.append(f) - return f - - def errorhandler(self, status_code_or_exception_class): - """Decorator to register a function as an error handler. Error handler - functions for numeric HTTP status codes must accept a single argument, - the request object. Error handler functions for Python exceptions - must accept two arguments, the request object and the exception - object. - - :param status_code_or_exception_class: The numeric HTTP status code or - Python exception class to - handle. - - Examples:: - - @app.errorhandler(404) - def not_found(request): - return 'Not found' - - @app.errorhandler(RuntimeError) - def runtime_error(request, exception): - return 'Runtime error' - """ - def decorated(f): - self.error_handlers[status_code_or_exception_class] = f - return f - return decorated - - def mount(self, subapp, url_prefix=''): - """Mount a sub-application, optionally under the given URL prefix. - - :param subapp: The sub-application to mount. - :param url_prefix: The URL prefix to mount the application under. - """ - for methods, pattern, handler in subapp.url_map: - self.url_map.append( - (methods, URLPattern(url_prefix + pattern.url_pattern), - handler)) - for handler in subapp.before_request_handlers: - self.before_request_handlers.append(handler) - for handler in subapp.after_request_handlers: - self.after_request_handlers.append(handler) - for handler in subapp.after_error_request_handlers: - self.after_error_request_handlers.append(handler) - for status_code, handler in subapp.error_handlers.items(): - self.error_handlers[status_code] = handler - - @staticmethod - def abort(status_code, reason=None): - """Abort the current request and return an error response with the - given status code. - - :param status_code: The numeric status code of the response. - :param reason: The reason for the response, which is included in the - response body. - - Example:: - - from microdot import abort - - @app.route('/users/') - def get_user(id): - user = get_user_by_id(id) - if user is None: - abort(404) - return user.to_dict() - """ - raise HTTPException(status_code, reason) - - async def start_server(self, host='0.0.0.0', port=5000, debug=False, - ssl=None): - """Start the Microdot web server as a coroutine. This coroutine does - not normally return, as the server enters an endless listening loop. - The :func:`shutdown` function provides a method for terminating the - server gracefully. - - :param host: The hostname or IP address of the network interface that - will be listening for requests. A value of ``'0.0.0.0'`` - (the default) indicates that the server should listen for - requests on all the available interfaces, and a value of - ``127.0.0.1`` indicates that the server should listen - for requests only on the internal networking interface of - the host. - :param port: The port number to listen for requests. The default is - port 5000. - :param debug: If ``True``, the server logs debugging information. The - default is ``False``. - :param ssl: An ``SSLContext`` instance or ``None`` if the server should - not use TLS. The default is ``None``. - - This method is a coroutine. - - Example:: - - import asyncio - from microdot import Microdot - - app = Microdot() - - @app.route('/') - async def index(request): - return 'Hello, world!' - - async def main(): - await app.start_server(debug=True) - - asyncio.run(main()) - """ - self.debug = debug - - async def serve(reader, writer): - if not hasattr(writer, 'awrite'): # pragma: no cover - # CPython provides the awrite and aclose methods in 3.8+ - async def awrite(self, data): - self.write(data) - await self.drain() - - async def aclose(self): - self.close() - await self.wait_closed() - - from types import MethodType - writer.awrite = MethodType(awrite, writer) - writer.aclose = MethodType(aclose, writer) - - await self.handle_request(reader, writer) - - if self.debug: # pragma: no cover - print('Starting async server on {host}:{port}...'.format( - host=host, port=port)) - - try: - self.server = await asyncio.start_server(serve, host, port, - ssl=ssl) - except TypeError: # pragma: no cover - self.server = await asyncio.start_server(serve, host, port) - - while True: - try: - if hasattr(self.server, 'serve_forever'): # pragma: no cover - try: - await self.server.serve_forever() - except asyncio.CancelledError: - pass - await self.server.wait_closed() - break - except AttributeError: # pragma: no cover - # the task hasn't been initialized in the server object yet - # wait a bit and try again - await asyncio.sleep(0.1) - - def run(self, host='0.0.0.0', port=5000, debug=False, ssl=None): - """Start the web server. This function does not normally return, as - the server enters an endless listening loop. The :func:`shutdown` - function provides a method for terminating the server gracefully. - - :param host: The hostname or IP address of the network interface that - will be listening for requests. A value of ``'0.0.0.0'`` - (the default) indicates that the server should listen for - requests on all the available interfaces, and a value of - ``127.0.0.1`` indicates that the server should listen - for requests only on the internal networking interface of - the host. - :param port: The port number to listen for requests. The default is - port 5000. - :param debug: If ``True``, the server logs debugging information. The - default is ``False``. - :param ssl: An ``SSLContext`` instance or ``None`` if the server should - not use TLS. The default is ``None``. - - Example:: - - from microdot import Microdot - - app = Microdot() - - @app.route('/') - async def index(request): - return 'Hello, world!' - - app.run(debug=True) - """ - asyncio.run(self.start_server(host=host, port=port, debug=debug, - ssl=ssl)) # pragma: no cover - - def shutdown(self): - """Request a server shutdown. The server will then exit its request - listening loop and the :func:`run` function will return. This function - can be safely called from a route handler, as it only schedules the - server to terminate as soon as the request completes. - - Example:: - - @app.route('/shutdown') - def shutdown(request): - request.app.shutdown() - return 'The server is shutting down...' - """ - self.server.close() - - def find_route(self, req): - method = req.method.upper() - if method == 'OPTIONS' and self.options_handler: - return self.options_handler(req) - if method == 'HEAD': - method = 'GET' - f = 404 - for route_methods, route_pattern, route_handler in self.url_map: - req.url_args = route_pattern.match(req.path) - if req.url_args is not None: - if method in route_methods: - f = route_handler - break - else: - f = 405 - return f - - def default_options_handler(self, req): - allow = [] - for route_methods, route_pattern, route_handler in self.url_map: - if route_pattern.match(req.path) is not None: - allow.extend(route_methods) - if 'GET' in allow: - allow.append('HEAD') - allow.append('OPTIONS') - return {'Allow': ', '.join(allow)} - - async def handle_request(self, reader, writer): - req = None - try: - req = await Request.create(self, reader, writer, - writer.get_extra_info('peername')) - except Exception as exc: # pragma: no cover - print_exception(exc) - - res = await self.dispatch_request(req) - if res != Response.already_handled: # pragma: no branch - await res.write(writer) - try: - await writer.aclose() - except OSError as exc: # pragma: no cover - if exc.errno in MUTED_SOCKET_ERRORS: - pass - else: - raise - if self.debug and req: # pragma: no cover - print('{method} {path} {status_code}'.format( - method=req.method, path=req.path, - status_code=res.status_code)) - - async def dispatch_request(self, req): - after_request_handled = False - if req: - if req.content_length > req.max_content_length: - if 413 in self.error_handlers: - res = await invoke_handler(self.error_handlers[413], req) - else: - res = 'Payload too large', 413 - else: - f = self.find_route(req) - try: - res = None - if callable(f): - for handler in self.before_request_handlers: - res = await invoke_handler(handler, req) - if res: - break - if res is None: - res = await invoke_handler( - f, req, **req.url_args) - if isinstance(res, int): - res = '', res - if isinstance(res, tuple): - if isinstance(res[0], int): - res = ('', res[0], - res[1] if len(res) > 1 else {}) - body = res[0] - if isinstance(res[1], int): - status_code = res[1] - headers = res[2] if len(res) > 2 else {} - else: - status_code = 200 - headers = res[1] - res = Response(body, status_code, headers) - elif not isinstance(res, Response): - res = Response(res) - for handler in self.after_request_handlers: - res = await invoke_handler( - handler, req, res) or res - for handler in req.after_request_handlers: - res = await invoke_handler( - handler, req, res) or res - after_request_handled = True - elif isinstance(f, dict): - res = Response(headers=f) - elif f in self.error_handlers: - res = await invoke_handler(self.error_handlers[f], req) - else: - res = 'Not found', f - except HTTPException as exc: - if exc.status_code in self.error_handlers: - res = self.error_handlers[exc.status_code](req) - else: - res = exc.reason, exc.status_code - except Exception as exc: - print_exception(exc) - exc_class = None - res = None - if exc.__class__ in self.error_handlers: - exc_class = exc.__class__ - else: - for c in mro(exc.__class__)[1:]: - if c in self.error_handlers: - exc_class = c - break - if exc_class: - try: - res = await invoke_handler( - self.error_handlers[exc_class], req, exc) - except Exception as exc2: # pragma: no cover - print_exception(exc2) - if res is None: - if 500 in self.error_handlers: - res = await invoke_handler( - self.error_handlers[500], req) - else: - res = 'Internal server error', 500 - else: - if 400 in self.error_handlers: - res = await invoke_handler(self.error_handlers[400], req) - else: - res = 'Bad request', 400 - if isinstance(res, tuple): - res = Response(*res) - elif not isinstance(res, Response): - res = Response(res) - if not after_request_handled: - for handler in self.after_error_request_handlers: - res = await invoke_handler( - handler, req, res) or res - res.is_head = (req and req.method == 'HEAD') - return res - - -Response.already_handled = Response() - -abort = Microdot.abort -redirect = Response.redirect -send_file = Response.send_file \ No newline at end of file diff --git a/lib/microdot/session.py b/lib/microdot/session.py deleted file mode 100644 index 78ce2e6..0000000 --- a/lib/microdot/session.py +++ /dev/null @@ -1,225 +0,0 @@ -try: - import jwt - HAS_JWT = True -except ImportError: - HAS_JWT = False - try: - import ubinascii - except ImportError: - import binascii as ubinascii - try: - import uhashlib as hashlib - except ImportError: - import hashlib - try: - import uhmac as hmac - except ImportError: - try: - import hmac - except ImportError: - hmac = None - import json - -from microdot.microdot import invoke_handler -from microdot.helpers import wraps - - -class SessionDict(dict): - """A session dictionary. - - The session dictionary is a standard Python dictionary that has been - extended with convenience ``save()`` and ``delete()`` methods. - """ - def __init__(self, request, session_dict): - super().__init__(session_dict) - self.request = request - - def save(self): - """Update the session cookie.""" - self.request.app._session.update(self.request, self) - - def delete(self): - """Delete the session cookie.""" - self.request.app._session.delete(self.request) - - -class Session: - """Session handling - - :param app: The application instance. - :param secret_key: The secret key, as a string or bytes object. - :param cookie_options: A dictionary with cookie options to pass as - arguments to :meth:`Response.set_cookie() - `. - """ - secret_key = None - - def __init__(self, app=None, secret_key=None, cookie_options=None): - self.secret_key = secret_key - self.cookie_options = cookie_options or {} - if app is not None: - self.initialize(app) - - def initialize(self, app, secret_key=None, cookie_options=None): - if secret_key is not None: - self.secret_key = secret_key - if cookie_options is not None: - self.cookie_options = cookie_options - if 'path' not in self.cookie_options: - self.cookie_options['path'] = '/' - if 'http_only' not in self.cookie_options: - self.cookie_options['http_only'] = True - app._session = self - - def get(self, request): - """Retrieve the user session. - - :param request: The client request. - - The return value is a session dictionary with the data stored in the - user's session, or ``{}`` if the session data is not available or - invalid. - """ - if not self.secret_key: - raise ValueError('The session secret key is not configured') - if hasattr(request.g, '_session'): - return request.g._session - session = request.cookies.get('session') - if session is None: - request.g._session = SessionDict(request, {}) - return request.g._session - request.g._session = SessionDict(request, self.decode(session)) - return request.g._session - - def update(self, request, session): - """Update the user session. - - :param request: The client request. - :param session: A dictionary with the update session data for the user. - - Applications would normally not call this method directly, instead they - would use the :meth:`SessionDict.save` method on the session - dictionary, which calls this method. For example:: - - @app.route('/') - @with_session - def index(request, session): - session['foo'] = 'bar' - session.save() - return 'Hello, World!' - - Calling this method adds a cookie with the updated session to the - request currently being processed. - """ - if not self.secret_key: - raise ValueError('The session secret key is not configured') - - encoded_session = self.encode(session) - - @request.after_request - def _update_session(request, response): - response.set_cookie('session', encoded_session, - **self.cookie_options) - return response - - def delete(self, request): - """Remove the user session. - - :param request: The client request. - - Applications would normally not call this method directly, instead they - would use the :meth:`SessionDict.delete` method on the session - dictionary, which calls this method. For example:: - - @app.route('/') - @with_session - def index(request, session): - session.delete() - return 'Hello, World!' - - Calling this method adds a cookie removal header to the request - currently being processed. - """ - @request.after_request - def _delete_session(request, response): - response.delete_cookie('session', **self.cookie_options) - return response - - def encode(self, payload, secret_key=None): - """Encode session data using JWT if available, otherwise use simple HMAC.""" - if HAS_JWT: - return jwt.encode(payload, secret_key or self.secret_key, - algorithm='HS256') - else: - # Simple encoding for MicroPython: base64(json) + HMAC signature - key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key) - payload_json = json.dumps(payload) - payload_b64 = ubinascii.b2a_base64(payload_json.encode()).decode().strip() - - # Create HMAC signature - if hmac: - # Use hmac module if available - h = hmac.new(key, payload_json.encode(), hashlib.sha256) - else: - # Fallback: simple SHA256(key + message) - h = hashlib.sha256(key + payload_json.encode()) - signature = ubinascii.b2a_base64(h.digest()).decode().strip() - - return f"{payload_b64}.{signature}" - - def decode(self, session, secret_key=None): - """Decode session data using JWT if available, otherwise use simple HMAC.""" - if HAS_JWT: - try: - payload = jwt.decode(session, secret_key or self.secret_key, - algorithms=['HS256']) - except jwt.exceptions.PyJWTError: # pragma: no cover - return {} - return payload - else: - try: - # Simple decoding for MicroPython - if '.' not in session: - return {} - - payload_b64, signature = session.rsplit('.', 1) - payload_json = ubinascii.a2b_base64(payload_b64).decode() - - # Verify HMAC signature - key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key) - if hmac: - # Use hmac module if available - h = hmac.new(key, payload_json.encode(), hashlib.sha256) - else: - # Fallback: simple SHA256(key + message) - h = hashlib.sha256(key + payload_json.encode()) - expected_signature = ubinascii.b2a_base64(h.digest()).decode().strip() - - if signature != expected_signature: - return {} - - return json.loads(payload_json) - except Exception: - return {} - - -def with_session(f): - """Decorator that passes the user session to the route handler. - - The session dictionary is passed to the decorated function as an argument - after the request object. Example:: - - @app.route('/') - @with_session - def index(request, session): - return 'Hello, World!' - - Note that the decorator does not save the session. To update the session, - call the :func:`session.save() ` method. - """ - @wraps(f) - async def wrapper(request, *args, **kwargs): - return await invoke_handler( - f, request, request.app._session.get(request), *args, **kwargs) - - return wrapper diff --git a/lib/microdot/utemplate.py b/lib/microdot/utemplate.py deleted file mode 100644 index 16d0398..0000000 --- a/lib/microdot/utemplate.py +++ /dev/null @@ -1,70 +0,0 @@ -from utemplate import recompile - -_loader = None - - -class Template: - """A template object. - - :param template: The filename of the template to render, relative to the - configured template directory. - """ - @classmethod - def initialize(cls, template_dir='templates', - loader_class=recompile.Loader): - """Initialize the templating subsystem. - - :param template_dir: the directory where templates are stored. This - argument is optional. The default is to load - templates from a *templates* subdirectory. - :param loader_class: the ``utemplate.Loader`` class to use when loading - templates. This argument is optional. The default - is the ``recompile.Loader`` class, which - automatically recompiles templates when they - change. - """ - global _loader - _loader = loader_class(None, template_dir) - - def __init__(self, template): - if _loader is None: # pragma: no cover - self.initialize() - #: The name of the template - self.name = template - self.template = _loader.load(template) - - def generate(self, *args, **kwargs): - """Return a generator that renders the template in chunks, with the - given arguments.""" - return self.template(*args, **kwargs) - - def render(self, *args, **kwargs): - """Render the template with the given arguments and return it as a - string.""" - return ''.join(self.generate(*args, **kwargs)) - - def generate_async(self, *args, **kwargs): - """Return an asynchronous generator that renders the template in - chunks, using the given arguments.""" - class sync_to_async_iter(): - def __init__(self, iter): - self.iter = iter - - def __aiter__(self): - return self - - async def __anext__(self): - try: - return next(self.iter) - except StopIteration: - raise StopAsyncIteration - - return sync_to_async_iter(self.generate(*args, **kwargs)) - - async def render_async(self, *args, **kwargs): - """Render the template with the given arguments asynchronously and - return it as a string.""" - response = '' - async for chunk in self.generate_async(*args, **kwargs): - response += chunk - return response diff --git a/lib/microdot/websocket.py b/lib/microdot/websocket.py deleted file mode 100644 index 0fb6f7c..0000000 --- a/lib/microdot/websocket.py +++ /dev/null @@ -1,231 +0,0 @@ -import binascii -import hashlib -from microdot import Request, Response -from microdot.microdot import MUTED_SOCKET_ERRORS, print_exception -from microdot.helpers import wraps - - -class WebSocketError(Exception): - """Exception raised when an error occurs in a WebSocket connection.""" - pass - - -class WebSocket: - """A WebSocket connection object. - - An instance of this class is sent to handler functions to manage the - WebSocket connection. - """ - CONT = 0 - TEXT = 1 - BINARY = 2 - CLOSE = 8 - PING = 9 - PONG = 10 - - #: Specify the maximum message size that can be received when calling the - #: ``receive()`` method. Messages with payloads that are larger than this - #: size will be rejected and the connection closed. Set to 0 to disable - #: the size check (be aware of potential security issues if you do this), - #: or to -1 to use the value set in - #: ``Request.max_body_length``. The default is -1. - #: - #: Example:: - #: - #: WebSocket.max_message_length = 4 * 1024 # up to 4KB messages - max_message_length = -1 - - def __init__(self, request): - self.request = request - self.closed = False - - async def handshake(self): - response = self._handshake_response() - await self.request.sock[1].awrite( - b'HTTP/1.1 101 Switching Protocols\r\n') - await self.request.sock[1].awrite(b'Upgrade: websocket\r\n') - await self.request.sock[1].awrite(b'Connection: Upgrade\r\n') - await self.request.sock[1].awrite( - b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n') - - async def receive(self): - """Receive a message from the client.""" - while True: - opcode, payload = await self._read_frame() - send_opcode, data = self._process_websocket_frame(opcode, payload) - if send_opcode: # pragma: no cover - await self.send(data, send_opcode) - elif data: # pragma: no branch - return data - - async def send(self, data, opcode=None): - """Send a message to the client. - - :param data: the data to send, given as a string or bytes. - :param opcode: a custom frame opcode to use. If not given, the opcode - is ``TEXT`` or ``BINARY`` depending on the type of the - data. - """ - frame = self._encode_websocket_frame( - opcode or (self.TEXT if isinstance(data, str) else self.BINARY), - data) - await self.request.sock[1].awrite(frame) - - async def close(self): - """Close the websocket connection.""" - if not self.closed: # pragma: no cover - self.closed = True - await self.send(b'', self.CLOSE) - - def _handshake_response(self): - connection = False - upgrade = False - websocket_key = None - for header, value in self.request.headers.items(): - h = header.lower() - if h == 'connection': - connection = True - if 'upgrade' not in value.lower(): - return self.request.app.abort(400) - elif h == 'upgrade': - upgrade = True - if not value.lower() == 'websocket': - return self.request.app.abort(400) - elif h == 'sec-websocket-key': - websocket_key = value - if not connection or not upgrade or not websocket_key: - return self.request.app.abort(400) - d = hashlib.sha1(websocket_key.encode()) - d.update(b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11') - return binascii.b2a_base64(d.digest())[:-1] - - @classmethod - def _parse_frame_header(cls, header): - fin = header[0] & 0x80 - opcode = header[0] & 0x0f - if fin == 0 or opcode == cls.CONT: # pragma: no cover - raise WebSocketError('Continuation frames not supported') - has_mask = header[1] & 0x80 - length = header[1] & 0x7f - if length == 126: - length = -2 - elif length == 127: - length = -8 - return fin, opcode, has_mask, length - - def _process_websocket_frame(self, opcode, payload): - if opcode == self.TEXT: - payload = payload.decode() - elif opcode == self.BINARY: - pass - elif opcode == self.CLOSE: - raise WebSocketError('Websocket connection closed') - elif opcode == self.PING: - return self.PONG, payload - elif opcode == self.PONG: # pragma: no branch - return None, None - return None, payload - - @classmethod - def _encode_websocket_frame(cls, opcode, payload): - frame = bytearray() - frame.append(0x80 | opcode) - if opcode == cls.TEXT: - payload = payload.encode() - if len(payload) < 126: - frame.append(len(payload)) - elif len(payload) < (1 << 16): - frame.append(126) - frame.extend(len(payload).to_bytes(2, 'big')) - else: - frame.append(127) - frame.extend(len(payload).to_bytes(8, 'big')) - frame.extend(payload) - return frame - - async def _read_frame(self): - header = await self.request.sock[0].read(2) - if len(header) != 2: # pragma: no cover - raise WebSocketError('Websocket connection closed') - fin, opcode, has_mask, length = self._parse_frame_header(header) - if length == -2: - length = await self.request.sock[0].read(2) - length = int.from_bytes(length, 'big') - elif length == -8: - length = await self.request.sock[0].read(8) - length = int.from_bytes(length, 'big') - max_allowed_length = Request.max_body_length \ - if self.max_message_length == -1 else self.max_message_length - if length > max_allowed_length: - raise WebSocketError('Message too large') - if has_mask: # pragma: no cover - mask = await self.request.sock[0].read(4) - payload = await self.request.sock[0].read(length) - if has_mask: # pragma: no cover - payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload)) - return opcode, payload - - -async def websocket_upgrade(request): - """Upgrade a request handler to a websocket connection. - - This function can be called directly inside a route function to process a - WebSocket upgrade handshake, for example after the user's credentials are - verified. The function returns the websocket object:: - - @app.route('/echo') - async def echo(request): - if not authenticate_user(request): - abort(401) - ws = await websocket_upgrade(request) - while True: - message = await ws.receive() - await ws.send(message) - """ - ws = WebSocket(request) - await ws.handshake() - - @request.after_request - async def after_request(request, response): - return Response.already_handled - - return ws - - -def websocket_wrapper(f, upgrade_function): - @wraps(f) - async def wrapper(request, *args, **kwargs): - ws = await upgrade_function(request) - try: - await f(request, ws, *args, **kwargs) - except OSError as exc: - if exc.errno not in MUTED_SOCKET_ERRORS: # pragma: no cover - raise - except WebSocketError: - pass - except Exception as exc: - print_exception(exc) - finally: # pragma: no cover - try: - await ws.close() - except Exception: - pass - return Response.already_handled - return wrapper - - -def with_websocket(f): - """Decorator to make a route a WebSocket endpoint. - - This decorator is used to define a route that accepts websocket - connections. The route then receives a websocket object as a second - argument that it can use to send and receive messages:: - - @app.route('/echo') - @with_websocket - async def echo(request, ws): - while True: - message = await ws.receive() - await ws.send(message) - """ - return websocket_wrapper(f, websocket_upgrade) diff --git a/lib/utemplate/__init__.py b/lib/utemplate/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/lib/utemplate/compiled.py b/lib/utemplate/compiled.py deleted file mode 100644 index 006e6f5..0000000 --- a/lib/utemplate/compiled.py +++ /dev/null @@ -1,14 +0,0 @@ -class Loader: - - def __init__(self, pkg, dir): - if dir == ".": - dir = "" - else: - dir = dir.replace("/", ".") + "." - if pkg and pkg != "__main__": - dir = pkg + "." + dir - self.p = dir - - def load(self, name): - name = name.replace(".", "_") - return __import__(self.p + name, None, None, (name,)).render \ No newline at end of file diff --git a/lib/utemplate/recompile.py b/lib/utemplate/recompile.py deleted file mode 100644 index b9bae4e..0000000 --- a/lib/utemplate/recompile.py +++ /dev/null @@ -1,21 +0,0 @@ -# (c) 2014-2020 Paul Sokolovsky. MIT license. -try: - from uos import stat, remove -except: - from os import stat, remove -from . import source - - -class Loader(source.Loader): - - def load(self, name): - o_path = self.pkg_path + self.compiled_path(name) - i_path = self.pkg_path + self.dir + "/" + name - try: - o_stat = stat(o_path) - i_stat = stat(i_path) - if i_stat[8] > o_stat[8]: - # input file is newer, remove output to force recompile - remove(o_path) - finally: - return super().load(name) \ No newline at end of file diff --git a/lib/utemplate/source.py b/lib/utemplate/source.py deleted file mode 100644 index 0ff4651..0000000 --- a/lib/utemplate/source.py +++ /dev/null @@ -1,188 +0,0 @@ -# (c) 2014-2019 Paul Sokolovsky. MIT license. -from . import compiled - - -class Compiler: - - START_CHAR = "{" - STMNT = "%" - STMNT_END = "%}" - EXPR = "{" - EXPR_END = "}}" - - def __init__(self, file_in, file_out, indent=0, seq=0, loader=None): - self.file_in = file_in - self.file_out = file_out - self.loader = loader - self.seq = seq - self._indent = indent - self.stack = [] - self.in_literal = False - self.flushed_header = False - self.args = "*a, **d" - - def indent(self, adjust=0): - if not self.flushed_header: - self.flushed_header = True - self.indent() - self.file_out.write("def render%s(%s):\n" % (str(self.seq) if self.seq else "", self.args)) - self.stack.append("def") - self.file_out.write(" " * (len(self.stack) + self._indent + adjust)) - - def literal(self, s): - if not s: - return - if not self.in_literal: - self.indent() - self.file_out.write('yield """') - self.in_literal = True - self.file_out.write(s.replace('"', '\\"')) - - def close_literal(self): - if self.in_literal: - self.file_out.write('"""\n') - self.in_literal = False - - def render_expr(self, e): - self.indent() - self.file_out.write('yield str(' + e + ')\n') - - def parse_statement(self, stmt): - tokens = stmt.split(None, 1) - if tokens[0] == "args": - if len(tokens) > 1: - self.args = tokens[1] - else: - self.args = "" - elif tokens[0] == "set": - self.indent() - self.file_out.write(stmt[3:].strip() + "\n") - elif tokens[0] == "include": - if not self.flushed_header: - # If there was no other output, we still need a header now - self.indent() - tokens = tokens[1].split(None, 1) - args = "" - if len(tokens) > 1: - args = tokens[1] - if tokens[0][0] == "{": - self.indent() - # "1" as fromlist param is uPy hack - self.file_out.write('_ = __import__(%s.replace(".", "_"), None, None, 1)\n' % tokens[0][2:-2]) - self.indent() - self.file_out.write("yield from _.render(%s)\n" % args) - return - - with self.loader.input_open(tokens[0][1:-1]) as inc: - self.seq += 1 - c = Compiler(inc, self.file_out, len(self.stack) + self._indent, self.seq) - inc_id = self.seq - self.seq = c.compile() - self.indent() - self.file_out.write("yield from render%d(%s)\n" % (inc_id, args)) - elif len(tokens) > 1: - if tokens[0] == "elif": - assert self.stack[-1] == "if" - self.indent(-1) - self.file_out.write(stmt + ":\n") - else: - self.indent() - self.file_out.write(stmt + ":\n") - self.stack.append(tokens[0]) - else: - if stmt.startswith("end"): - assert self.stack[-1] == stmt[3:] - self.stack.pop(-1) - elif stmt == "else": - assert self.stack[-1] == "if" - self.indent(-1) - self.file_out.write("else:\n") - else: - assert False - - def parse_line(self, l): - while l: - start = l.find(self.START_CHAR) - if start == -1: - self.literal(l) - return - self.literal(l[:start]) - self.close_literal() - sel = l[start + 1] - #print("*%s=%s=" % (sel, EXPR)) - if sel == self.STMNT: - end = l.find(self.STMNT_END) - assert end > 0 - stmt = l[start + len(self.START_CHAR + self.STMNT):end].strip() - self.parse_statement(stmt) - end += len(self.STMNT_END) - l = l[end:] - if not self.in_literal and l == "\n": - break - elif sel == self.EXPR: - # print("EXPR") - end = l.find(self.EXPR_END) - assert end > 0 - expr = l[start + len(self.START_CHAR + self.EXPR):end].strip() - self.render_expr(expr) - end += len(self.EXPR_END) - l = l[end:] - else: - self.literal(l[start]) - l = l[start + 1:] - - def header(self): - self.file_out.write("# Autogenerated file\n") - - def compile(self): - self.header() - for l in self.file_in: - self.parse_line(l) - self.close_literal() - return self.seq - - -class Loader(compiled.Loader): - - def __init__(self, pkg, dir): - super().__init__(pkg, dir) - self.dir = dir - if pkg == "__main__": - # if pkg isn't really a package, don't bother to use it - # it means we're running from "filesystem directory", not - # from a package. - pkg = None - - self.pkg_path = "" - if pkg: - p = __import__(pkg) - if isinstance(p.__path__, str): - # uPy - self.pkg_path = p.__path__ - else: - # CPy - self.pkg_path = p.__path__[0] - self.pkg_path += "/" - - def input_open(self, template): - path = self.pkg_path + self.dir + "/" + template - return open(path) - - def compiled_path(self, template): - return self.dir + "/" + template.replace(".", "_") + ".py" - - def load(self, name): - try: - return super().load(name) - except (OSError, ImportError): - pass - - compiled_path = self.pkg_path + self.compiled_path(name) - - f_in = self.input_open(name) - f_out = open(compiled_path, "w") - c = Compiler(f_in, f_out, loader=self) - c.compile() - f_in.close() - f_out.close() - return super().load(name) \ No newline at end of file diff --git a/scripts/migrate_controllers_native_fastapi.py b/scripts/migrate_controllers_native_fastapi.py new file mode 100644 index 0000000..b380aea --- /dev/null +++ b/scripts/migrate_controllers_native_fastapi.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Migrate Microdot controllers to native FastAPI (no compat layer).""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +CONTROLLERS = Path(__file__).resolve().parents[1] / "src" / "controllers" + +IMPORT_BLOCK = """from fastapi import APIRouter, Request +from http_responses import J, html_response, plain, read_json, send_file +from http_session import with_session + +""" + +_JSON_HEADERS = re.compile( + r"return json\.dumps\(([\s\S]*?)\),\s*(\d+)\s*,\s*\{\s*" + r"['\"]Content-Type['\"]\s*:\s*['\"]application/json['\"]\s*,?\s*\}", + re.MULTILINE, +) + +_JSON_PLAIN = re.compile( + r"^(\s*)return json\.dumps\((.+)\),\s*(\d+)\s*$", + re.MULTILINE, +) + +_HTML_LINE = re.compile( + r"^(\s*)return ([^,\n]+),\s*(\d+),\s*\{['\"]Content-Type['\"]: ['\"]text/html['\"]\}\s*$", + re.MULTILINE, +) + +_PLAIN_LINE = re.compile( + r"^(\s*)return ([^,\n]+),\s*(\d+),\s*\{['\"]Content-Type['\"]: ['\"]text/plain[^'\"]*['\"]\}\s*$", + re.MULTILINE, +) + +_PAREN_JSON = re.compile( + r"return \(\s*json\.dumps\(([\s\S]*?)\)\s*,\s*(\d+)\s*,\s*" + r"\{['\"]Content-Type['\"]: ['\"]application/json['\"]\}\s*,?\s*\)", + re.MULTILINE, +) + +_PAREN_JSON_NOHDR = re.compile( + r"return \(\s*json\.dumps\(([\s\S]*?)\)\s*,\s*(\d+)\s*,?\s*\)", + re.MULTILINE, +) + +_PAREN_HTML = re.compile( + r"return \(\s*([^,]+?)\s*,\s*(\d+)\s*,\s*" + r"\{['\"]Content-Type['\"]: ['\"]text/html['\"]\}\s*,?\s*\)", + re.DOTALL, +) + + +def _insert_imports(text: str) -> str: + if "from fastapi import APIRouter" in text: + return text + fut = re.search(r"^from __future__ import annotations\n", text, re.M) + if fut: + return text[: fut.end()] + "\n" + IMPORT_BLOCK + text[fut.end() :] + doc = re.match(r'("""[\s\S]*?"""\n+|\'\'\'[\s\S]*?\'\'\'\n+)', text) + if doc: + return text[: doc.end()] + "\n" + IMPORT_BLOCK + text[doc.end() :] + return IMPORT_BLOCK + text + + +def _strip_microdot(text: str) -> str: + text = re.sub(r"from microdot import Microdot(?:, send_file)?\n", "", text) + text = re.sub(r"from microdot\.session import with_session\n", "", text) + text = re.sub(r"from microdot import send_file\n", "", text) + text = text.replace("controller = Microdot()", "router = APIRouter()") + text = text.replace("@controller.", "@router.") + return text + + +def _convert_paths(text: str) -> str: + def fix(m: re.Match) -> str: + method, path = m.group(1), m.group(2) + if path == "": + path = "/" + path = re.sub(r"<(\w+)>", r"{\1}", path) + return f'@router.{method}("{path}")' + + return re.sub( + r"@router\.(get|post|put|delete|patch)\(['\"]([^'\"]*)['\"]\)", + fix, + text, + ) + + +def _convert_request_access(text: str) -> str: + text = text.replace("request.json or {}", "await read_json(request)") + text = re.sub(r"(? str: + text = re.sub(r"async def (\w+)\(request,", r"async def \1(request: Request,", text) + text = re.sub(r"async def (\w+)\(request\)", r"async def \1(request: Request)", text) + return text + + +def _convert_returns(text: str) -> str: + text = _PAREN_JSON.sub(r"return J(\1, \2)", text) + text = _PAREN_JSON_NOHDR.sub(r"return J(\1, \2)", text) + text = _PAREN_HTML.sub(r"return html_response(\1, \2)", text) + text = _JSON_HEADERS.sub(r"return J(\1, \2)", text) + text = _JSON_PLAIN.sub(r"\1return J(\2, \3)", text) + text = _HTML_LINE.sub(r"\1return html_response(\2, \3)", text) + text = _PLAIN_LINE.sub(r"\1return plain(\2, \3)", text) + text = re.sub( + r'^(\s*)return "([^"]+)",\s*(\d+)\s*$', + r'\1return plain("\2", \3)', + text, + flags=re.MULTILINE, + ) + return text + + +def convert_file(path: Path) -> str: + text = path.read_text(encoding="utf-8") + if "Microdot" not in text: + return text + text = _strip_microdot(text) + text = _insert_imports(text) + text = _convert_paths(text) + text = _convert_request_access(text) + text = _convert_request_annotations(text) + text = _convert_returns(text) + return text + + +def main() -> int: + for path in sorted(CONTROLLERS.glob("*.py")): + if path.name == "__init__.py": + continue + path.write_text(convert_file(path), encoding="utf-8") + print(path.name) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/app_factory.py b/src/app_factory.py index ac2e304..dc6162f 100644 --- a/src/app_factory.py +++ b/src/app_factory.py @@ -1,16 +1,16 @@ -"""Application factory: Microdot routes and shared runtime startup.""" +"""Application factory: FastAPI routes and shared runtime startup.""" from __future__ import annotations import asyncio import hashlib -import json import os import secrets from typing import Any, Optional -from microdot import Microdot, send_file -from microdot.session import Session +from fastapi import FastAPI +from fastapi.responses import PlainTextResponse +from fastapi.staticfiles import StaticFiles from settings import WIFI_CHANNEL_DEFAULT, get_settings import controllers.preset as preset @@ -25,6 +25,8 @@ import controllers.settings as settings_controller import controllers.device as device_controller import controllers.led_tool as led_tool_controller import controllers.wifi_bridge as wifi_bridge_controller +from http_responses import send_file, send_html_file +from http_session import SessionMiddleware from models.transport import ( BridgeSerialTransport, BridgeWsTransport, @@ -37,6 +39,9 @@ from models.bridge_ws_client import init_bridge_client from util.espnow_registry import handle_bridge_uplink from util.bridge_runtime import set_bridge_uplink_handler from util.audio_detector import AudioBeatDetector +from util.wifi_driver_runtime import start_wifi_driver_runtime, stop_wifi_driver_runtime + +_SRC_DIR = os.path.dirname(os.path.abspath(__file__)) def live_reload_enabled() -> bool: @@ -60,7 +65,7 @@ def dev_client_revision() -> Optional[str]: """Revision of static/template assets (changes when UI files are saved).""" if not live_reload_enabled(): return None - base = os.path.dirname(os.path.abspath(__file__)) + base = _SRC_DIR parts: list[str] = [] for sub in ("static", "templates"): root = os.path.join(base, sub) @@ -82,71 +87,42 @@ def dev_client_revision() -> Optional[str]: return digest[:16] -def create_microdot_app(*, inject_live_reload: bool = False) -> Microdot: - """Build the Microdot app with mounted controllers and static routes.""" - settings = get_settings() - app = Microdot() - - secret_key = settings.get( - "session_secret_key", "led-controller-secret-key-change-in-production" +def mount_controller_routers(app: FastAPI) -> None: + """Register all controller API routers.""" + app.include_router(preset.router, prefix="/presets", tags=["presets"]) + app.include_router(profile.router, prefix="/profiles", tags=["profiles"]) + app.include_router(group.router, prefix="/groups", tags=["groups"]) + app.include_router(sequence.router, prefix="/sequences", tags=["sequences"]) + app.include_router(zone.router, prefix="/zones", tags=["zones"]) + app.include_router(palette.router, prefix="/palettes", tags=["palettes"]) + app.include_router(scene.router, prefix="/scenes", tags=["scenes"]) + app.include_router(pattern.router, prefix="/patterns", tags=["patterns"]) + app.include_router(settings_controller.router, prefix="/settings", tags=["settings"]) + app.include_router( + wifi_bridge_controller.router, prefix="/settings/wifi", tags=["wifi"] ) - Session(app, secret_key=secret_key) + app.include_router(device_controller.router, prefix="/devices", tags=["devices"]) + app.include_router(led_tool_controller.router, prefix="/led-tool", tags=["led-tool"]) - app.mount(preset.controller, "/presets") - app.mount(profile.controller, "/profiles") - app.mount(group.controller, "/groups") - app.mount(sequence.controller, "/sequences") - app.mount(zone.controller, "/zones") - app.mount(palette.controller, "/palettes") - app.mount(scene.controller, "/scenes") - app.mount(pattern.controller, "/patterns") - app.mount(settings_controller.controller, "/settings") - app.mount(wifi_bridge_controller.controller, "/settings/wifi") - app.mount(device_controller.controller, "/devices") - app.mount(led_tool_controller.controller, "/led-tool") +def mount_static_routes(app: FastAPI, *, inject_live_reload: bool = False) -> None: + """Index page, favicon, and static assets.""" build_id = dev_build_id() if inject_live_reload else None - if build_id: + live_tag = '' - @app.route("/__dev/build-id") - def dev_build_id_route(request): - _ = request - return ( - build_id, - 200, - { - "Content-Type": "text/plain; charset=utf-8", - "Cache-Control": "no-store", - }, - ) - - @app.route("/") - def index(request): - _ = request + @app.get("/") + async def index(): if build_id: - try: - with open("templates/index.html", encoding="utf-8") as f: - html = f.read() - tag = '' - if "" in html: - html = html.replace("", tag + "\n", 1) - return html, 200, {"Content-Type": "text/html; charset=utf-8"} - except OSError: - pass + return send_html_file("templates/index.html", inject=live_tag) return send_file("templates/index.html") - @app.route("/favicon.ico") - def favicon(request): - _ = request - return "", 204 + @app.get("/favicon.ico") + async def favicon(): + return PlainTextResponse("", status_code=204) - @app.route("/static/") - def static_handler(request, path): - if ".." in path: - return "Not found", 404 - return send_file("static/" + path) - - return app + static_dir = os.path.join(_SRC_DIR, "static") + if os.path.isdir(static_dir): + app.mount("/static", StaticFiles(directory=static_dir), name="static") class AppRuntime: @@ -226,24 +202,43 @@ class AppRuntime: from util import beat_driver_route beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop()) + from util import beat_status_broadcaster as beat_sse from util import sequence_playback as seq_pb + loop = asyncio.get_running_loop() + beat_sse.configure( + loop=loop, + status_builder=lambda: audio_status_payload( + self.audio_detector, self.settings + ), + ) seq_pb.ensure_beat_consumer_started() Device() + if not test_mode: + await start_wifi_driver_runtime(self.settings) async def shutdown(self) -> None: + try: + await stop_wifi_driver_runtime() + except Exception: + pass try: self.audio_detector.stop() except Exception: pass + try: + from util import beat_status_broadcaster as beat_sse + + await beat_sse.shutdown() + except Exception: + pass try: from util import sequence_playback as seq_pb seq_pb.stop() - for attr in ("_pending_beat_task", "_sim_beat_task"): - t = getattr(seq_pb, attr, None) - if t is not None and not t.done(): - t.cancel() + t = getattr(seq_pb, "_background_beat_task", None) + if t is not None and not t.done(): + t.cancel() except Exception: pass @@ -259,10 +254,15 @@ def audio_status_payload( st["sequence"] = sequence_playback.playback_status() st["manual_beat_stride"] = beat_driver_route.manual_beat_stride_status() seq = st.get("sequence") + running = bool(st.get("running")) beat_readout = "" if isinstance(seq, dict) and str(seq.get("beat_readout") or "").strip(): beat_readout = str(seq.get("beat_readout") or "").strip() - elif st.get("running"): + if not beat_readout: + tail = sequence_playback.last_completed_beat_readout() + if tail: + beat_readout = tail + if not beat_readout and st.get("running"): mb = st.get("manual_beat_stride") if isinstance(mb, dict) and mb.get("active"): try: @@ -293,6 +293,28 @@ def audio_status_payload( seq_wait = str(settings.get("sequence_switch_wait") or "beat").strip().lower() if seq_wait not in ("beat", "downbeat"): seq_wait = "beat" - st["sequence_switch_wait"] = seq_wait + st["sequence_switch_wait_saved"] = seq_wait + from util.sequence_playback import effective_sequence_switch_wait + + st["sequence_switch_wait"] = effective_sequence_switch_wait() st["audio_run"] = read_audio_run_state() + from util.bpm_limits import clamp_bpm + + sim_bpm = int(clamp_bpm(settings.get("audio_simulated_bpm"))) + st["audio_simulated_bpm"] = sim_bpm + st["sequence_pending"] = sequence_playback.pending_play_status() + from util import audio_detector as audio_detector_module + + st["bpm_simulated"] = not audio_detector_module.shared_beat_detector_timing_sequences() + if running and st.get("bpm") is not None: + st["bpm"] = float(clamp_bpm(st["bpm"])) + if not running: + st["bpm"] = float(sim_bpm) + st["simulated_beat_tick"] = sequence_playback.simulated_beat_tick() + if not running: + phase = sequence_playback.simulated_beat_phase_snapshot() + st["bar_beat"] = phase.get("bar_beat") + st["is_downbeat"] = bool(phase.get("is_downbeat")) + st["bar_phase_readout"] = str(phase.get("bar_phase_readout") or "") + st["phase_confidence"] = 0.0 return st diff --git a/src/controllers/device.py b/src/controllers/device.py index a1f0437..7c80b68 100644 --- a/src/controllers/device.py +++ b/src/controllers/device.py @@ -1,4 +1,7 @@ -from microdot import Microdot +from fastapi import APIRouter, Request +from http_responses import J, html_response, plain, read_json, send_file +from http_session import with_session + from models.device import ( Device, derive_device_mac, @@ -46,7 +49,7 @@ def _compact_v1_json(*, presets=None, select=None, save=False): body["save"] = True if select is not None: body["select"] = select - return json.dumps(body, separators=(",", ":")) + return J(body, separators=(",", ":")) # Seconds after identify blink before selecting built-in ``off`` (tests may monkeypatch). IDENTIFY_OFF_DELAY_S = 2.0 @@ -69,7 +72,7 @@ def _brightness_save_message_json(b_val: int) -> str: return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":")) -controller = Microdot() +router = APIRouter() devices = Device() _group_registry = Group() _pi_settings = get_settings() @@ -246,38 +249,34 @@ async def send_identify_to_group_devices( return len(seen), errors -@controller.get("") -async def list_devices(request): +@router.get("/") +async def list_devices(request: Request): """List all devices (includes ``connected`` for live Wi-Fi WebSocket presence).""" devices_data = {} for dev_id in devices.list(): d = devices.read(dev_id) if d: devices_data[dev_id] = _device_json_with_live_status(d) - return json.dumps(devices_data), 200, {"Content-Type": "application/json"} - - -@controller.post("/resolve-brightness") -async def resolve_brightness_batch(request): + return J(devices_data, 200) +@router.post("/resolve-brightness") +async def resolve_brightness_batch(request: Request): """ POST JSON ``{ \"macs\": [\"..\"], \"zone_brightness\": optional 0–255 }``. Returns ``{ \"values\": { mac: combined_int } }`` — global × group(s) × device × zone (optional). """ try: - data = request.json or {} + data = await read_json(request) except Exception: data = {} macs = data.get("macs") if not isinstance(macs, list): - return json.dumps({"error": "macs must be an array"}), 400, { - "Content-Type": "application/json", - } + return J({"error": "macs must be an array"}, 400) zb = None if isinstance(data, dict) and data.get("zone_brightness") is not None: try: zb = _validate_output_brightness(data.get("zone_brightness")) except ValueError as e: - return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"} + return J({"error": str(e)}, 400) values = {} for raw in macs: m = normalize_mac(str(raw)) @@ -290,47 +289,37 @@ async def resolve_brightness_batch(request): m, zone_brightness=zb, ) - return json.dumps({"values": values}), 200, {"Content-Type": "application/json"} + return J({"values": values}, 200) -@controller.get("/") -async def get_device(request, id): +@router.get("/{id}") +async def get_device(request: Request, id): """Get a device by ID (includes ``connected`` for live Wi-Fi WebSocket presence).""" dev = devices.read(id) if dev: - return json.dumps(_device_json_with_live_status(dev)), 200, { - "Content-Type": "application/json", - } - return json.dumps({"error": "Device not found"}), 404, { - "Content-Type": "application/json", - } + return J(_device_json_with_live_status(dev), 200) + return J({"error": "Device not found"}, 404) -@controller.post("") -async def create_device(request): +@router.post("/") +async def create_device(request: Request): """Create a new device.""" try: - data = request.json or {} + data = await read_json(request) name = data.get("name", "").strip() if not name: - return json.dumps({"error": "name is required"}), 400, { - "Content-Type": "application/json", - } + return J({"error": "name is required"}, 400) try: device_type = validate_device_type(data.get("type", "led")) transport = validate_device_transport(data.get("transport", "espnow")) except ValueError as e: - return json.dumps({"error": str(e)}), 400, { - "Content-Type": "application/json", - } + return J({"error": str(e)}, 400) address = data.get("address") mac = data.get("mac") if derive_device_mac(mac=mac, address=address, transport=transport) is None: - return json.dumps( - { + return J({ "error": "mac is required (12 hex digits); for Wi-Fi include mac plus IP in address" - } - ), 400, {"Content-Type": "application/json"} + }, 400) default_pattern = data.get("default_pattern") zl = data.get("zones") if isinstance(zl, list): @@ -347,20 +336,20 @@ async def create_device(request): transport=transport, ) dev = devices.read(dev_id) - return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"} + return J({dev_id: dev}, 201) except ValueError as e: msg = str(e) code = 409 if "already exists" in msg.lower() else 400 - return json.dumps({"error": msg}), code, {"Content-Type": "application/json"} + return J({"error": msg}, code) except Exception as e: - return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"} + return J({"error": str(e)}, 400) -@controller.put("/") -async def update_device(request, id): +@router.put("/{id}") +async def update_device(request: Request, id): """Update a device.""" try: - raw = request.json or {} + raw = await read_json(request) data = dict(raw) data.pop("id", None) data.pop("addresses", None) @@ -368,9 +357,7 @@ async def update_device(request, id): if "name" in data: n = (data.get("name") or "").strip() if not n: - return json.dumps({"error": "name cannot be empty"}), 400, { - "Content-Type": "application/json", - } + return J({"error": "name cannot be empty"}, 400) data["name"] = n if "type" in data: data["type"] = validate_device_type(data.get("type")) @@ -389,32 +376,24 @@ async def update_device(request, id): from util.beat_driver_route import remap_beat_route_device_name remap_beat_route_device_name(on, nn) - return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"} - return json.dumps({"error": "Device not found"}), 404, { - "Content-Type": "application/json", - } + return J(devices.read(id), 200) + return J({"error": "Device not found"}, 404) except ValueError as e: - return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"} + return J({"error": str(e)}, 400) except Exception as e: - return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"} + return J({"error": str(e)}, 400) -@controller.delete("/") -async def delete_device(request, id): +@router.delete("/{id}") +async def delete_device(request: Request, id): """Delete a device.""" if devices.delete(id): - return ( - json.dumps({"message": "Device deleted successfully"}), - 200, - {"Content-Type": "application/json"}, - ) - return json.dumps({"error": "Device not found"}), 404, { - "Content-Type": "application/json", - } + return J({"message": "Device deleted successfully"}, 200) + return J({"error": "Device not found"}, 404) -@controller.post("/groups") -async def update_device_groups(request): +@router.post("/groups") +async def update_device_groups(request: Request): """Push current group membership to all ESP-NOW drivers in the registry.""" _ = request from util.espnow_registry import push_groups_all_espnow_devices @@ -422,16 +401,12 @@ async def update_device_groups(request): result = await push_groups_all_espnow_devices() status = 200 if result.get("ok") else 503 if not result.get("total"): - return ( - json.dumps({"ok": False, "error": "No ESP-NOW devices in registry"}), - 400, - {"Content-Type": "application/json"}, - ) - return json.dumps(result), status, {"Content-Type": "application/json"} + return J({"ok": False, "error": "No ESP-NOW devices in registry"}, 400) + return J(result, status) -@controller.post("/ping") -async def ping_devices(request): +@router.post("/ping") +async def ping_devices(request: Request): """ Broadcast ESP-NOW PING_REQ; collect PING_RSP until timeout (default 3 s). JSON body: ``{"timeout_s": 3.0}`` (optional). @@ -440,21 +415,19 @@ async def ping_devices(request): timeout_s = 3.0 try: - body = request.json or {} + body = await read_json(request) if isinstance(body, dict) and body.get("timeout_s") is not None: timeout_s = float(body["timeout_s"]) except (TypeError, ValueError): - return json.dumps({"error": "Invalid timeout_s"}), 400, { - "Content-Type": "application/json", - } + return J({"error": "Invalid timeout_s"}, 400) timeout_s = max(0.5, min(30.0, timeout_s)) result = await run_ping(timeout_s=timeout_s) status = 200 if result.get("ok") else 503 - return json.dumps(result), status, {"Content-Type": "application/json"} + return J(result, status) -@controller.post("//identify") -async def identify_device(request, id): +@router.post("/{id}/identify") +async def identify_device(request: Request, id): """ One v1 JSON object: ``presets.__identify`` (``d``=50 ms → 10 Hz blink) plus ``select`` for this device name — same combined shape as profile sends the driver already accepts over TCP @@ -462,30 +435,26 @@ async def identify_device(request, id): """ status, err = await send_identify_to_device(id) if status == 200: - return json.dumps({"message": "Identify sent"}), 200, { - "Content-Type": "application/json", - } - return json.dumps({"error": err}), status, {"Content-Type": "application/json"} + return J({"message": "Identify sent"}, 200) + return J({"error": err}, status) -@controller.post("//brightness") -async def push_device_output_brightness(request, id): +@router.post("/{id}/brightness") +async def push_device_output_brightness(request: Request, id): """ Push combined brightness to the driver: global × group(s) × device × optional ``zone_brightness`` in JSON body — single ``b`` (``v``/``b``/``save``). Wi‑Fi or ESP‑NOW. """ dev = devices.read(id) if not dev: - return json.dumps({"error": "Device not found"}), 404, { - "Content-Type": "application/json", - } - body = request.json or {} + return J({"error": "Device not found"}, 404) + body = await read_json(request) zb = None if isinstance(body, dict) and body.get("zone_brightness") is not None: try: zb = _validate_output_brightness(body.get("zone_brightness")) except ValueError as e: - return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"} + return J({"error": str(e)}, 400) b_val = effective_brightness_for_mac( _pi_settings, _group_registry, @@ -496,40 +465,30 @@ async def push_device_output_brightness(request, id): bridge = get_current_bridge() if not bridge: - return json.dumps({"error": "Transport not configured"}), 503, { - "Content-Type": "application/json", - } + return J({"error": "Transport not configured"}, 503) try: ok = await bridge.send({"v": "1", "b": b_val, "save": True}, addr=id) if not ok: - return json.dumps({"error": "Send failed"}), 503, { - "Content-Type": "application/json", - } + return J({"error": "Send failed"}, 503) except Exception as e: - return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"} + return J({"error": str(e)}, 503) - return json.dumps({"message": "brightness sent", "brightness": b_val}), 200, { - "Content-Type": "application/json", - } + return J({"message": "brightness sent", "brightness": b_val}, 200) -@controller.post("//driver-config") -async def push_driver_config(request, id): +@router.post("/{id}/driver-config") +async def push_driver_config(request: Request, id): """ Push ``device_config`` to an ESP-NOW LED driver. Body JSON: optional ``name``, ``num_leds``, ``color_order``, ``startup_mode`` (default|last|off). """ dev = devices.read(id) if not dev: - return json.dumps({"error": "Device not found"}), 404, { - "Content-Type": "application/json", - } + return J({"error": "Device not found"}, 404) bridge = get_current_bridge() if not bridge: - return json.dumps({"error": "Transport not configured"}), 503, { - "Content-Type": "application/json", - } - body = request.json or {} + return J({"error": "Transport not configured"}, 503) + body = await read_json(request) dc = {} if isinstance(body.get("name"), str) and body["name"].strip(): dc["name"] = body["name"].strip() @@ -549,31 +508,21 @@ async def push_driver_config(request, id): if sm in ("default", "last", "off"): dc["startup_mode"] = sm if not dc: - return json.dumps( - { + return J({ "error": "Provide at least one of name, num_leds, color_order, startup_mode" - } - ), 400, {"Content-Type": "application/json"} + }, 400) ok = await bridge.send({"v": "1", "device_config": dc, "save": True}, addr=id) if not ok: - return json.dumps({"error": "Send failed"}), 503, { - "Content-Type": "application/json", - } - return json.dumps({"message": "driver-config sent"}), 200, { - "Content-Type": "application/json", - } + return J({"error": "Send failed"}, 503) + return J({"message": "driver-config sent"}, 200) -@controller.post("//patterns/push") -async def push_patterns_ota(request, id): +@router.post("/{id}/patterns/push") +async def push_patterns_ota(request: Request, id): """ Pattern OTA over HTTP is not available for ESP-NOW drivers. """ dev = devices.read(id) if not dev: - return json.dumps({"error": "Device not found"}), 404, { - "Content-Type": "application/json", - } - return json.dumps( - {"error": "Pattern OTA push is not supported for ESP-NOW devices"} - ), 400, {"Content-Type": "application/json"} + return J({"error": "Device not found"}, 404) + return J({"error": "Pattern OTA push is not supported for ESP-NOW devices"}, 400) diff --git a/src/controllers/group.py b/src/controllers/group.py index 59c096e..65a5812 100644 --- a/src/controllers/group.py +++ b/src/controllers/group.py @@ -1,5 +1,7 @@ -from microdot import Microdot -from microdot.session import with_session +from fastapi import APIRouter, Request +from http_responses import J, html_response, plain, read_json, send_file +from http_session import with_session + import asyncio from models.group import Group from models.device import Device @@ -9,7 +11,7 @@ from settings import get_settings from util.brightness_combine import effective_brightness_for_mac import json -controller = Microdot() +router = APIRouter() groups = Group() devices = Device() _pi_settings = get_settings() @@ -41,27 +43,25 @@ def _filtered_groups_dict(session): return out -@controller.get("") +@router.get("/") @with_session -async def list_groups(request, session): +async def list_groups(request: Request, session): """List groups visible for the current profile (shared + profile-scoped).""" - return json.dumps(_filtered_groups_dict(session)), 200, {"Content-Type": "application/json"} + return J(_filtered_groups_dict(session), 200) -@controller.get("/") +@router.get("/{id}") @with_session -async def get_group(request, session, id): +async def get_group(request: Request, session, id): """Get a specific group by ID (404 if scoped to another profile).""" group = groups.read(id) if not group or not isinstance(group, dict): - return json.dumps({"error": "Group not found"}), 404 + return J({"error": "Group not found"}, 404) from controllers.zone import get_current_profile_id if not _group_doc_visible_for_profile(group, get_current_profile_id(session)): - return json.dumps({"error": "Group not found"}), 404 - return json.dumps(group), 200, {"Content-Type": "application/json"} - - + return J({"error": "Group not found"}, 404) + return J(group, 200) def _sanitize_group_bridge_id_write(data): """Per-group bridge assignment is disabled; ignore writes.""" if isinstance(data, dict) and "bridge_id" in data: @@ -89,12 +89,12 @@ def _sanitize_group_profile_id_write(data, session): data.pop("profile_id", None) -@controller.post("") +@router.post("/") @with_session -async def create_group(request, session): +async def create_group(request: Request, session): """Create a new group (omit ``profile_id`` for shared; or ``profile_scoped``: true for this profile only).""" try: - data = dict(request.json or {}) + data = dict(await read_json(request)) name = data.get("name", "") profile_scoped = bool(data.pop("profile_scoped", False)) _sanitize_group_profile_id_write(data, session) @@ -111,19 +111,17 @@ async def create_group(request, session): g = groups.read(group_id) if g: await push_groups_for_group_devices(g) - return json.dumps(groups.read(group_id)), 201, {"Content-Type": "application/json"} + return J(groups.read(group_id), 201) except Exception as e: - return json.dumps({"error": str(e)}), 400 - - -@controller.put("/") + return J({"error": str(e)}, 400) +@router.put("/{id}") @with_session -async def update_group(request, session, id): +async def update_group(request: Request, session, id): """Update an existing group.""" try: - data = request.json + data = await read_json(request) if not isinstance(data, dict): - return json.dumps({"error": "Invalid JSON"}), 400, {"Content-Type": "application/json"} + return J({"error": "Invalid JSON"}, 400) data = dict(data) _sanitize_group_profile_id_write(data, session) _sanitize_group_bridge_id_write(data) @@ -131,29 +129,26 @@ async def update_group(request, session, id): g = groups.read(id) if g: await push_groups_for_group_devices(g) - return json.dumps(g), 200, {"Content-Type": "application/json"} - return json.dumps({"error": "Group not found"}), 404 + return J(g, 200) + return J({"error": "Group not found"}, 404) except Exception as e: - return json.dumps({"error": str(e)}), 400 - -@controller.delete("/") + return J({"error": str(e)}, 400) +@router.delete("/{id}") @with_session -async def delete_group(request, session, id): +async def delete_group(request: Request, session, id): """Delete a group (not allowed for another profile's scoped group).""" g = groups.read(id) if not g or not isinstance(g, dict): - return json.dumps({"error": "Group not found"}), 404 + return J({"error": "Group not found"}, 404) from controllers.zone import get_current_profile_id if not _group_doc_visible_for_profile(g, get_current_profile_id(session)): - return json.dumps({"error": "Group not found"}), 404 + return J({"error": "Group not found"}, 404) macs = list(g.get("devices") or []) if isinstance(g, dict) else [] if groups.delete(id): await push_groups_for_group_devices({"devices": macs}) - return json.dumps({"message": "Group deleted successfully"}), 200 - return json.dumps({"error": "Group not found"}), 404 - - + return J({"message": "Group deleted successfully"}, 200) + return J({"error": "Group not found"}, 404) def _group_driver_config_payload(doc): """Build ``device_config`` dict from stored group Wi‑Fi defaults (non-empty only).""" dc = {} @@ -194,18 +189,17 @@ def _read_group_for_session(session, id): return g -@controller.post("//driver-config") +@router.post("/{id}/driver-config") @with_session -async def push_group_driver_config(request, session, id): +async def push_group_driver_config(request: Request, session, id): """ Push group driver defaults to every ESP-NOW device listed in the group. Uses stored ``wifi_*`` fields on the group; optional JSON body may override for this send only. """ gdoc = _read_group_for_session(session, id) if not gdoc: - return json.dumps({"error": "Group not found"}), 404 - - body = request.json or {} + return J({"error": "Group not found"}, 404) + body = await read_json(request) merged = dict(gdoc) if isinstance(body, dict): for k in ( @@ -218,16 +212,19 @@ async def push_group_driver_config(request, session, id): merged[k] = body[k] dc = _group_driver_config_payload(merged) if not dc: - return json.dumps( - {"error": "No driver defaults on this group (set display name, LEDs, colour order, or power-on pattern)"} - ), 400, {"Content-Type": "application/json"} + return J( + { + "error": "No driver defaults on this group (set display name, LEDs, colour order, or power-on pattern)" + }, + 400, + ) mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else [] sent = 0 errors = [] bridge = get_current_bridge() if not bridge: - return json.dumps({"error": "Transport not configured"}), 503 + return J({"error": "Transport not configured"}, 503) payload = {"v": "1", "device_config": dc, "save": True} for mac in mac_list: m = str(mac).strip().lower().replace(":", "").replace("-", "") @@ -245,9 +242,7 @@ async def push_group_driver_config(request, session, id): except Exception as e: errors.append({"mac": m, "error": str(e)}) - return json.dumps( - {"message": "driver-config sent", "sent": sent, "errors": errors} - ), 200, {"Content-Type": "application/json"} + return J({"message": "driver-config sent", "sent": sent, "errors": errors}, 200) def _brightness_save_message_json(b_val: int) -> str: @@ -255,16 +250,15 @@ def _brightness_save_message_json(b_val: int) -> str: return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":")) -@controller.post("//brightness") +@router.post("/{id}/brightness") @with_session -async def push_group_output_brightness(request, session, id): +async def push_group_output_brightness(request: Request, session, id): """ Push combined brightness (global × group(s) × device) to each member — one ``b`` per device. """ gdoc = _read_group_for_session(session, id) if not gdoc: - return json.dumps({"error": "Group not found"}), 404 - + return J({"error": "Group not found"}, 404) mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else [] sent = 0 errors = [] @@ -309,14 +303,12 @@ async def push_group_output_brightness(request, session, id): elif err: errors.append({"mac": m, "error": err}) - return json.dumps( - {"message": "brightness sent", "sent": sent, "errors": errors} - ), 200, {"Content-Type": "application/json"} + return J({"message": "brightness sent", "sent": sent, "errors": errors}, 200) -@controller.post("//identify") +@router.post("/{id}/identify") @with_session -async def identify_group_devices(request, session, id): +async def identify_group_devices(request: Request, session, id): """ Run the same identify blink as ``POST /devices//identify`` for every registry member in parallel so all drivers in the group blink together. @@ -324,11 +316,11 @@ async def identify_group_devices(request, session, id): _ = request gdoc = _read_group_for_session(session, id) if not gdoc: - return json.dumps({"error": "Group not found"}), 404, {"Content-Type": "application/json"} + return J({"error": "Group not found"}, 404) mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else [] if not mac_list: - return json.dumps({"error": "Group has no devices"}), 400, {"Content-Type": "application/json"} + return J({"error": "Group has no devices"}, 400) from controllers.device import send_identify_to_group_devices @@ -342,15 +334,11 @@ async def identify_group_devices(request, session, id): normalized.append(m) if not normalized: - return json.dumps( - {"message": "identify group done", "sent": 0, "errors": errors} - ), 200, {"Content-Type": "application/json"} + return J({"message": "identify group done", "sent": 0, "errors": errors}, 200) sent, batch_errors = await send_identify_to_group_devices( normalized, group_ids=[str(id)] ) errors.extend(batch_errors) - return json.dumps( - {"message": "identify group done", "sent": sent, "errors": errors} - ), 200, {"Content-Type": "application/json"} + return J({"message": "identify group done", "sent": sent, "errors": errors}, 200) diff --git a/src/controllers/led_tool.py b/src/controllers/led_tool.py index a709a2f..4599598 100644 --- a/src/controllers/led_tool.py +++ b/src/controllers/led_tool.py @@ -1,12 +1,15 @@ +from fastapi import APIRouter, Request +from http_responses import J, html_response, plain, read_json, send_file +from http_session import with_session + import json import os import subprocess import sys -from microdot import Microdot, send_file from serial.tools import list_ports -controller = Microdot() +router = APIRouter() _STATIC_ALLOWED = frozenset( {"settings_editor.html", "settings_editor.js", "web_serial.js"} @@ -74,31 +77,17 @@ def _run_led_cli_command(cmd, cli_path: str, timeout_s=180): cwd=os.path.dirname(cli_path), ) except subprocess.TimeoutExpired: - return ( - json.dumps({"error": "led-tool command timed out after 180 seconds"}), - 504, - {"Content-Type": "application/json"}, - ) + return J({"error": "led-tool command timed out after 180 seconds"}, 504) except Exception as exc: - return ( - json.dumps({"error": str(exc)}), - 500, - {"Content-Type": "application/json"}, - ) + return J({"error": str(exc)}, 500) - return ( - json.dumps( - { + return J({ "ok": result.returncode == 0, "returncode": result.returncode, "stdout": result.stdout, "stderr": result.stderr, "command": cmd, - } - ), - 200, - {"Content-Type": "application/json"}, - ) + }, 200) def _extract_settings_from_stdout(stdout: str): @@ -112,31 +101,27 @@ def _extract_settings_from_stdout(stdout: str): return None -@controller.get("/editor") -async def settings_editor_page(request): +@router.get("/editor") +async def settings_editor_page(request: Request): """led-tool settings UI (Web Serial + host serial via led-cli).""" path = os.path.join(_led_tool_static_dir(), "settings_editor.html") if not os.path.isfile(path): - return ( - json.dumps({"error": "led-tool/static/settings_editor.html not found"}), - 404, - {"Content-Type": "application/json"}, - ) + return J({"error": "led-tool/static/settings_editor.html not found"}, 404) return send_file(path) -@controller.get("/static/") -async def led_tool_static(request, filename): +@router.get("/static/") +async def led_tool_static(request: Request, filename): if filename not in _STATIC_ALLOWED: - return "Not found", 404 + return plain("Not found", 404) path = os.path.join(_led_tool_static_dir(), filename) if not os.path.isfile(path): - return "Not found", 404 + return plain("Not found", 404) return send_file(path) -@controller.get("/ports") -async def list_serial_ports(request): +@router.get("/ports") +async def list_serial_ports(request: Request): ports = _filter_host_serial_ports( [ { @@ -147,87 +132,57 @@ async def list_serial_ports(request): for info in list_ports.comports() ] ) - return ( - json.dumps( - { + return J({ "ports": ports, "led_cli_exists": os.path.exists(_led_cli_path()), - } - ), - 200, - {"Content-Type": "application/json"}, - ) + }, 200) -@controller.post("/settings") -async def apply_settings(request): - data = request.json or {} +@router.post("/settings") +async def apply_settings(request: Request): + data = await read_json(request) port = str(data.get("port") or "").strip() if not port: - return ( - json.dumps({"error": "port is required"}), - 400, - {"Content-Type": "application/json"}, - ) + return J({"error": "port is required"}, 400) cli_path = _led_cli_path() if not os.path.exists(cli_path): - return ( - json.dumps({"error": "led-tool/cli.py not found"}), - 500, - {"Content-Type": "application/json"}, - ) + return J({"error": "led-tool/cli.py not found"}, 500) cmd = _build_led_cli_command(port, data) + ["--follow"] return _run_led_cli_command(cmd, cli_path, timeout_s=None) -@controller.post("/reset") -@controller.post("/reset/") -async def reset_device(request): - data = request.json or {} +@router.post("/reset") +@router.post("/reset/") +async def reset_device(request: Request): + data = await read_json(request) port = str(data.get("port") or "").strip() if not port: - return ( - json.dumps({"error": "port is required"}), - 400, - {"Content-Type": "application/json"}, - ) + return J({"error": "port is required"}, 400) cli_path = _led_cli_path() if not os.path.exists(cli_path): - return ( - json.dumps({"error": "led-tool/cli.py not found"}), - 500, - {"Content-Type": "application/json"}, - ) + return J({"error": "led-tool/cli.py not found"}, 500) cmd = [sys.executable, cli_path, "--port", port, "--reset", "--follow"] return _run_led_cli_command(cmd, cli_path, timeout_s=None) -@controller.get("/settings") -async def read_settings(request): - port = str(request.args.get("port") or "").strip() +@router.get("/settings") +async def read_settings(request: Request): + port = str(request.query_params.get("port") or "").strip() if not port: - return ( - json.dumps({"error": "port is required"}), - 400, - {"Content-Type": "application/json"}, - ) + return J({"error": "port is required"}, 400) cli_path = _led_cli_path() if not os.path.exists(cli_path): - return ( - json.dumps({"error": "led-tool/cli.py not found"}), - 500, - {"Content-Type": "application/json"}, - ) + return J({"error": "led-tool/cli.py not found"}, 500) cmd = [sys.executable, cli_path, "--port", port, "--show"] - body, status, headers = _run_led_cli_command(cmd, cli_path) - if status != 200: - return body, status, headers - data = json.loads(body) + result = _run_led_cli_command(cmd, cli_path) + if result.status_code != 200: + return result + data = json.loads(result.body.decode()) data["settings"] = _extract_settings_from_stdout(data.get("stdout") or "") - return json.dumps(data), status, headers + return J(data, 200) diff --git a/src/controllers/palette.py b/src/controllers/palette.py index 8a42218..12870b2 100644 --- a/src/controllers/palette.py +++ b/src/controllers/palette.py @@ -1,58 +1,58 @@ -from microdot import Microdot +from fastapi import APIRouter, Request +from http_responses import J, html_response, plain, read_json, send_file +from http_session import with_session + from models.pallet import Palette import json -controller = Microdot() +router = APIRouter() palettes = Palette() -@controller.get('') -async def list_palettes(request): +@router.get("/") +async def list_palettes(request: Request): """List all palettes.""" data = {} for pid in palettes.list(): colors = palettes.read(pid) data[pid] = colors - return json.dumps(data), 200, {'Content-Type': 'application/json'} + return J(data, 200) -@controller.get('/') -async def get_palette(request, id): +@router.get("/{id}") +async def get_palette(request: Request, id): """Get a specific palette by ID.""" if str(id) in palettes: palette = palettes.read(id) - return json.dumps({"colors": palette or [], "id": str(id)}), 200, {'Content-Type': 'application/json'} - return json.dumps({"error": "Palette not found"}), 404 - -@controller.post('') -async def create_palette(request): + return J({"colors": palette or [], "id": str(id)}, 200) + return J({"error": "Palette not found"}, 404) +@router.post("/") +async def create_palette(request: Request): """Create a new palette.""" try: - data = request.json or {} + data = await read_json(request) colors = data.get("colors", None) # Palette no longer needs a name; only colors are stored. palette_id = palettes.create("", colors) created_colors = palettes.read(palette_id) or [] - return json.dumps({"id": str(palette_id), "colors": created_colors}), 201, {'Content-Type': 'application/json'} + return J({"id": str(palette_id), "colors": created_colors}, 201) except Exception as e: - return json.dumps({"error": str(e)}), 400 - -@controller.put('/') -async def update_palette(request, id): + return J({"error": str(e)}, 400) +@router.put("/{id}") +async def update_palette(request: Request, id): """Update an existing palette.""" try: - data = request.json or {} + data = await read_json(request) # Ignore any name field; only colors are relevant. if "name" in data: data.pop("name", None) if palettes.update(id, data): colors = palettes.read(id) or [] - return json.dumps({"id": str(id), "colors": colors}), 200, {'Content-Type': 'application/json'} - return json.dumps({"error": "Palette not found"}), 404 + return J({"id": str(id), "colors": colors}, 200) + return J({"error": "Palette not found"}, 404) except Exception as e: - return json.dumps({"error": str(e)}), 400 - -@controller.delete('/') -async def delete_palette(request, id): + return J({"error": str(e)}, 400) +@router.delete("/{id}") +async def delete_palette(request: Request, id): """Delete a palette.""" if palettes.delete(id): - return json.dumps({"message": "Palette deleted successfully"}), 200 - return json.dumps({"error": "Palette not found"}), 404 + return J({"message": "Palette deleted successfully"}, 200) + return J({"error": "Palette not found"}, 404) \ No newline at end of file diff --git a/src/controllers/pattern.py b/src/controllers/pattern.py index d978103..7723343 100644 --- a/src/controllers/pattern.py +++ b/src/controllers/pattern.py @@ -1,4 +1,7 @@ -from microdot import Microdot +from fastapi import APIRouter, Request +from http_responses import J, html_response, plain, read_json, send_file +from http_session import with_session + from models.pattern import Pattern from models.device import Device from util.driver_patterns import ( @@ -12,7 +15,7 @@ import os import socket from urllib.parse import quote -controller = Microdot() +router = APIRouter() patterns = Pattern() @@ -147,26 +150,24 @@ def build_runtime_pattern_map(): result[name] = {} return result -@controller.get('/definitions') -async def get_pattern_definitions(request): +@router.get("/definitions") +async def get_pattern_definitions(request: Request): """Get definitions for patterns currently available on the driver.""" definitions = build_runtime_pattern_map() - return json.dumps(definitions), 200, {'Content-Type': 'application/json'} + return J(definitions, 200) -@controller.get('/ota/manifest') -async def ota_manifest(request): +@router.get("/ota/manifest") +async def ota_manifest(request: Request): """Manifest of driver pattern source files for OTA pulls.""" base_dir = driver_patterns_dir() host = request.headers.get("Host", "") if not host: - return json.dumps({"error": "Missing Host header"}), 400, { - "Content-Type": "application/json" - } + return J({"error": "Missing Host header"}, 400) try: names = sorted(os.listdir(base_dir)) except OSError as e: - return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"} + return J({"error": str(e)}, 500) files = [] for name in names: @@ -177,97 +178,69 @@ async def ota_manifest(request): "url": "http://%s/patterns/ota/file/%s" % (host, name), }) - return json.dumps({"files": files}), 200, {"Content-Type": "application/json"} + return J({"files": files}, 200) -@controller.get('/ota/file/') -async def ota_pattern_file(request, name): +@router.get("/ota/file/{name}") +async def ota_pattern_file(request: Request, name): """Serve one driver pattern source file for OTA pulls.""" fname = normalize_pattern_py_filename(name) if not fname or not _safe_pattern_filename(fname) or fname == "__init__.py": - return json.dumps({"error": "Invalid filename"}), 400, { - "Content-Type": "application/json" - } + return J({"error": "Invalid filename"}, 400) if is_firmware_builtin_pattern_module(fname): - return json.dumps( - { + return J({ "error": "on and off are built into the driver firmware; there is no module file to serve.", - } - ), 400, { - "Content-Type": "application/json" - } + }, 400) base = driver_patterns_dir() path = os.path.join(base, fname) try: with open(path, "r") as f: content = f.read() except OSError: - return json.dumps( - { + return J({ "error": "Pattern file not found", "path": path, "hint": "Ensure led-driver is present or set LED_CONTROLLER_PATTERNS_DIR.", - } - ), 404, { - "Content-Type": "application/json" - } - return content, 200, {"Content-Type": "text/plain; charset=utf-8"} - - -@controller.post('//send') -async def send_pattern_to_device(request, name): + }, 404) + return plain(content, 200) +@router.post("/{name}/send") +async def send_pattern_to_device(request: Request, name): """Push one pattern source file directly to Wi-Fi driver(s) over HTTP.""" if not isinstance(name, str): - return json.dumps({"error": "Invalid pattern name"}), 400, { - "Content-Type": "application/json" - } + return J({"error": "Invalid pattern name"}, 400) filename = normalize_pattern_py_filename(name) if not filename or not _safe_pattern_filename(filename) or filename == "__init__.py": - return json.dumps({"error": "Invalid pattern filename"}), 400, { - "Content-Type": "application/json" - } + return J({"error": "Invalid pattern filename"}, 400) if is_firmware_builtin_pattern_module(filename): - return json.dumps( - { + return J({ "error": "on and off are built into the driver firmware; send does not apply.", - } - ), 400, { - "Content-Type": "application/json" - } + }, 400) devices = Device() - body = request.json or {} + body = await read_json(request) requested_device_id = str(body.get("device_id") or "").strip() base = driver_patterns_dir() path = os.path.join(base, filename) if not os.path.exists(path): - return json.dumps( - { + return J({ "error": "Pattern file not found", "path": path, "hint": "Ensure led-driver is present or set LED_CONTROLLER_PATTERNS_DIR.", - } - ), 404, { - "Content-Type": "application/json" - } + }, 404) try: with open(path, "r") as f: source = f.read() except OSError as e: - return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"} + return J({"error": str(e)}, 500) target_ids = [] if requested_device_id: dev = devices.read(requested_device_id) if not dev: - return json.dumps({"error": "Device not found"}), 404, { - "Content-Type": "application/json" - } + return J({"error": "Device not found"}, 404) if (dev.get("transport") or "").lower() != "wifi": - return json.dumps({"error": "Pattern send is only supported for Wi-Fi devices"}), 400, { - "Content-Type": "application/json" - } + return J({"error": "Pattern send is only supported for Wi-Fi devices"}, 400) target_ids = [requested_device_id] else: for did in devices.list(): @@ -275,9 +248,7 @@ async def send_pattern_to_device(request, name): if (dev.get("transport") or "").lower() == "wifi": target_ids.append(str(did)) if not target_ids: - return json.dumps({"error": "No Wi-Fi devices found"}), 404, { - "Content-Type": "application/json" - } + return J({"error": "No Wi-Fi devices found"}, 404) sent_ids = [] for did in target_ids: @@ -290,16 +261,12 @@ async def send_pattern_to_device(request, name): sent_ids.append(did) if not sent_ids: - return json.dumps({"error": "No Wi-Fi drivers accepted pattern upload"}), 503, { - "Content-Type": "application/json" - } - return json.dumps({"message": "Pattern sent", "pattern": filename, "device_ids": sent_ids, "sent_count": len(sent_ids)}), 200, { - "Content-Type": "application/json" - } + return J({"error": "No Wi-Fi drivers accepted pattern upload"}, 503) + return J({"message": "Pattern sent", "pattern": filename, "device_ids": sent_ids, "sent_count": len(sent_ids)}, 200) -@controller.post('/upload') -async def upload_pattern_file(request): +@router.post("/upload") +async def upload_pattern_file(request: Request): """ Upload a pattern source file to led-controller local storage. @@ -310,56 +277,44 @@ async def upload_pattern_file(request): "overwrite": true | false # optional, default true } """ - data = request.json or {} + data = await read_json(request) raw_name = data.get("name") or data.get("filename") code = data.get("code") overwrite = data.get("overwrite", True) overwrite = bool(overwrite) if not isinstance(raw_name, str) or not raw_name.strip(): - return json.dumps({"error": "name is required"}), 400, { - "Content-Type": "application/json" - } + return J({"error": "name is required"}, 400) filename = raw_name.strip() if not filename.endswith(".py"): filename += ".py" if not _safe_pattern_filename(filename) or filename == "__init__.py": - return json.dumps({"error": "invalid pattern filename"}), 400, { - "Content-Type": "application/json" - } + return J({"error": "invalid pattern filename"}, 400) if is_firmware_builtin_pattern_module(filename): - return json.dumps( - {"error": "on and off are built into the driver firmware; use a different pattern name."} - ), 400, { - "Content-Type": "application/json" - } + return J({"error": "on and off are built into the driver firmware; use a different pattern name."}, 400) if not isinstance(code, str) or not code.strip(): - return json.dumps({"error": "code is required"}), 400, { - "Content-Type": "application/json" - } + return J({"error": "code is required"}, 400) path = os.path.join(driver_patterns_dir(), filename) exists = os.path.exists(path) if exists and not overwrite: - return json.dumps({"error": "pattern file already exists", "name": filename}), 409, { - "Content-Type": "application/json" - } + return J({"error": "pattern file already exists", "name": filename}, 409) try: with open(path, "w") as f: f.write(code) except OSError as e: - return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"} + return J({"error": str(e)}, 500) - return json.dumps({ + return J({ "message": "Pattern uploaded", "name": filename, "overwrote": bool(exists), - }), 201, {"Content-Type": "application/json"} + }, 201) -@controller.post('/driver') -async def create_driver_pattern(request): +@router.post("/driver") +async def create_driver_pattern(request: Request): """ Create a driver pattern: save ``.py`` under led-driver/src/patterns and metadata in db/pattern.json (Pattern model). @@ -372,33 +327,25 @@ async def create_driver_pattern(request): n1..n8 (optional string labels), overwrite (optional, default true). """ - data = request.json or {} + data = await read_json(request) key = _normalize_pattern_key(data.get("name") or "") if not _valid_pattern_key(key): - return json.dumps({ + return J({ "error": "name must be a valid Python identifier (e.g. sparkle, my_pattern)", - }), 400, {"Content-Type": "application/json"} + }, 400) if is_firmware_builtin_pattern_module(key): - return json.dumps( - {"error": "on and off are built into the driver firmware; use a different pattern name."} - ), 400, { - "Content-Type": "application/json" - } + return J({"error": "on and off are built into the driver firmware; use a different pattern name."}, 400) code = data.get("code") if not isinstance(code, str) or not code.strip(): - return json.dumps({"error": "code is required (upload a .py file or paste source)"}), 400, { - "Content-Type": "application/json" - } + return J({"error": "code is required (upload a .py file or paste source)"}, 400) overwrite = bool(data.get("overwrite", True)) filename = key + ".py" py_path = os.path.join(driver_patterns_dir(), filename) if os.path.exists(py_path) and not overwrite: - return json.dumps({"error": "pattern file already exists", "name": filename}), 409, { - "Content-Type": "application/json" - } + return J({"error": "pattern file already exists", "name": filename}, 409) meta = {} for fld in ("min_delay", "max_delay", "max_colors"): @@ -407,9 +354,7 @@ async def create_driver_pattern(request): try: meta[fld] = int(data[fld]) except (TypeError, ValueError): - return json.dumps({"error": "%s must be an integer" % fld}), 400, { - "Content-Type": "application/json" - } + return J({"error": "%s must be an integer" % fld}, 400) if "has_background" in data: meta["has_background"] = bool(data.get("has_background")) @@ -432,41 +377,39 @@ async def create_driver_pattern(request): with open(py_path, "w") as f: f.write(code) except OSError as e: - return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"} + return J({"error": str(e)}, 500) if patterns.read(key): patterns.update(key, meta) else: patterns.create(key, meta) - return json.dumps({ + return J({ "message": "Pattern created", "name": key, "file": filename, "metadata": patterns.read(key), - }), 201, {"Content-Type": "application/json"} + }, 201) -@controller.get('') -async def list_patterns(request): +@router.get("/") +async def list_patterns(request: Request): """List patterns for UI (DB metadata + local driver additions).""" - return json.dumps(build_runtime_pattern_map()), 200, {'Content-Type': 'application/json'} + return J(build_runtime_pattern_map(), 200) -@controller.get('/') -async def get_pattern(request, id): +@router.get("/{id}") +async def get_pattern(request: Request, id): """Get a specific pattern by ID.""" pattern = patterns.read(id) if pattern is not None: - return json.dumps(pattern), 200, {'Content-Type': 'application/json'} - return json.dumps({"error": "Pattern not found"}), 404 - - -@controller.post('') -async def create_pattern(request): + return J(pattern, 200) + return J({"error": "Pattern not found"}, 404) +@router.post("/") +async def create_pattern(request: Request): """Create a new pattern.""" try: - payload = request.json or {} + payload = await read_json(request) name = payload.get("name", "") pattern_data = payload.get("data", {}) @@ -483,26 +426,22 @@ async def create_pattern(request): extra.pop("data", None) if extra: patterns.update(pattern_id, extra) - return json.dumps(patterns.read(pattern_id)), 201, {'Content-Type': 'application/json'} + return J(patterns.read(pattern_id), 201) except Exception as e: - return json.dumps({"error": str(e)}), 400 - - -@controller.put('/') -async def update_pattern(request, id): + return J({"error": str(e)}, 400) +@router.put("/{id}") +async def update_pattern(request: Request, id): """Update an existing pattern.""" try: - data = request.json + data = await read_json(request) if patterns.update(id, data): - return json.dumps(patterns.read(id)), 200, {'Content-Type': 'application/json'} - return json.dumps({"error": "Pattern not found"}), 404 + return J(patterns.read(id), 200) + return J({"error": "Pattern not found"}, 404) except Exception as e: - return json.dumps({"error": str(e)}), 400 - - -@controller.delete('/') -async def delete_pattern(request, id): + return J({"error": str(e)}, 400) +@router.delete("/{id}") +async def delete_pattern(request: Request, id): """Delete a pattern.""" if patterns.delete(id): - return json.dumps({"message": "Pattern deleted successfully"}), 200 - return json.dumps({"error": "Pattern not found"}), 404 + return J({"message": "Pattern deleted successfully"}, 200) + return J({"error": "Pattern not found"}, 404) \ No newline at end of file diff --git a/src/controllers/preset.py b/src/controllers/preset.py index 383d682..92ce8aa 100644 --- a/src/controllers/preset.py +++ b/src/controllers/preset.py @@ -1,5 +1,7 @@ -from microdot import Microdot -from microdot.session import with_session +from fastapi import APIRouter, Request +from http_responses import J, html_response, plain, read_json, send_file +from http_session import with_session + from models.preset import Preset from models.profile import Profile from models.pallet import Palette @@ -13,7 +15,7 @@ from util.espnow_message import build_message, build_preset_dict from util.profile_bundle import export_preset_bundle, import_preset_bundle import json -controller = Microdot() +router = APIRouter() presets = Preset() profiles = Profile() @@ -41,76 +43,75 @@ def get_current_profile_id(session=None): return profile_list[0] return None -@controller.get('') +@router.get("/") @with_session -async def list_presets(request, session): +async def list_presets(request: Request, session): """List presets for the current profile.""" current_profile_id = get_current_profile_id(session) if not current_profile_id: - return json.dumps({}), 200, {'Content-Type': 'application/json'} + return J({}, 200) scoped = { pid: pdata for pid, pdata in presets.items() if isinstance(pdata, dict) and str(pdata.get("profile_id")) == str(current_profile_id) } - return json.dumps(scoped), 200, {'Content-Type': 'application/json'} + return J(scoped, 200) -@controller.get('//export') +@router.get("/{preset_id}/export") @with_session -async def export_preset(request, session, preset_id): +async def export_preset(request: Request, session, preset_id): """Export one preset as a JSON bundle.""" current_profile_id = get_current_profile_id(session) preset = presets.read(preset_id) if not preset or str(preset.get("profile_id")) != str(current_profile_id): - return json.dumps({"error": "Preset not found"}), 404, {'Content-Type': 'application/json'} + return J({"error": "Preset not found"}, 404) try: bundle = export_preset_bundle(preset_id, presets) - return json.dumps(bundle), 200, {'Content-Type': 'application/json'} + return J(bundle, 200) except ValueError as e: - return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'} + return J({"error": str(e)}, 404) -@controller.post('/import') +@router.post("/import") @with_session -async def import_preset(request, session): +async def import_preset(request: Request, session): """Import a preset bundle into the current profile.""" try: current_profile_id = get_current_profile_id(session) if not current_profile_id: - return json.dumps({"error": "No profile available"}), 404, {'Content-Type': 'application/json'} - body = request.json or {} + return J({"error": "No profile available"}, 404) + body = await read_json(request) bundle = body.get("bundle") if isinstance(body, dict) else body if not isinstance(bundle, dict): - return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'} + return J({"error": "Expected JSON bundle"}, 400) new_id, preset_data = import_preset_bundle(bundle, presets, current_profile_id) - return json.dumps({new_id: preset_data}), 201, {'Content-Type': 'application/json'} + return J({new_id: preset_data}, 201) except ValueError as e: - return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'} + return J({"error": str(e)}, 400) except Exception as e: - return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'} + return J({"error": str(e)}, 400) -@controller.get('/') +@router.get("/{preset_id}") @with_session -async def get_preset(request, session, preset_id): +async def get_preset(request: Request, session, preset_id): """Get a specific preset by ID (current profile only).""" preset = presets.read(preset_id) current_profile_id = get_current_profile_id(session) if preset and str(preset.get("profile_id")) == str(current_profile_id): - return json.dumps(preset), 200, {'Content-Type': 'application/json'} - return json.dumps({"error": "Preset not found"}), 404 - -@controller.post('') + return J(preset, 200) + return J({"error": "Preset not found"}, 404) +@router.post("/") @with_session -async def create_preset(request, session): +async def create_preset(request: Request, session): """Create a new preset for the current profile.""" try: try: - data = request.json or {} + data = await read_json(request) except Exception: - return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'} + return J({"error": "Invalid JSON"}, 400) current_profile_id = get_current_profile_id(session) if not current_profile_id: - return json.dumps({"error": "No profile available"}), 404 + return J({"error": "No profile available"}, 404) preset_id = presets.create(current_profile_id) if not isinstance(data, dict): data = {} @@ -118,65 +119,46 @@ async def create_preset(request, session): data["profile_id"] = str(current_profile_id) if presets.update(preset_id, data): preset_data = presets.read(preset_id) - return json.dumps({preset_id: preset_data}), 201, {'Content-Type': 'application/json'} - return json.dumps({"error": "Failed to create preset"}), 400 + return J({preset_id: preset_data}, 201) + return J({"error": "Failed to create preset"}, 400) except Exception as e: - return json.dumps({"error": str(e)}), 400 - -@controller.put('/') + return J({"error": str(e)}, 400) +@router.put("/{preset_id}") @with_session -async def update_preset(request, session, preset_id): +async def update_preset(request: Request, session, preset_id): """Update an existing preset (current profile only).""" try: preset = presets.read(preset_id) current_profile_id = get_current_profile_id(session) if not preset or str(preset.get("profile_id")) != str(current_profile_id): - return json.dumps({"error": "Preset not found"}), 404 + return J({"error": "Preset not found"}, 404) try: - data = request.json or {} + data = await read_json(request) except Exception: - return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'} + return J({"error": "Invalid JSON"}, 400) if not isinstance(data, dict): data = {} data = dict(data) data["profile_id"] = str(current_profile_id) if presets.update(preset_id, data): - return json.dumps(presets.read(preset_id)), 200, {'Content-Type': 'application/json'} - return json.dumps({"error": "Preset not found"}), 404 + return J(presets.read(preset_id), 200) + return J({"error": "Preset not found"}, 404) except Exception as e: - return json.dumps({"error": str(e)}), 400 - -@controller.delete('/') + return J({"error": str(e)}, 400) +@router.delete("/{preset_id}") @with_session -async def delete_preset(request, *args, **kwargs): +async def delete_preset(request: Request, session, preset_id): """Delete a preset (current profile only).""" - # Be tolerant of wrapper/arg-order variations. - session = None - preset_id = None - if len(args) > 0: - session = args[0] - if len(args) > 1: - preset_id = args[1] - if 'session' in kwargs and kwargs.get('session') is not None: - session = kwargs.get('session') - if 'preset_id' in kwargs and kwargs.get('preset_id') is not None: - preset_id = kwargs.get('preset_id') - if 'id' in kwargs and kwargs.get('id') is not None and preset_id is None: - preset_id = kwargs.get('id') - if preset_id is None: - return json.dumps({"error": "Preset ID is required"}), 400 preset = presets.read(preset_id) current_profile_id = get_current_profile_id(session) if not preset or str(preset.get("profile_id")) != str(current_profile_id): - return json.dumps({"error": "Preset not found"}), 404 + return J({"error": "Preset not found"}, 404) if presets.delete(preset_id): - return json.dumps({"message": "Preset deleted successfully"}), 200 - return json.dumps({"error": "Preset not found"}), 404 - - -@controller.post('/send') + return J({"message": "Preset deleted successfully"}, 200) + return J({"error": "Preset not found"}, 404) +@router.post("/send") @with_session -async def send_presets(request, session): +async def send_presets(request: Request, session): """ Send one or more presets to LED drivers (serial/ESP-NOW and/or TCP Wi-Fi clients). @@ -191,13 +173,12 @@ async def send_presets(request, session): Optional "destination_mac" / "to": single MAC when targets is omitted. """ try: - data = request.json or {} + data = await read_json(request) except Exception: - return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'} - + return J({"error": "Invalid JSON"}, 400) preset_ids = data.get('preset_ids') or data.get('ids') if not isinstance(preset_ids, list) or not preset_ids: - return json.dumps({"error": "preset_ids must be a non-empty list"}), 400, {'Content-Type': 'application/json'} + return J({"error": "preset_ids must be a non-empty list"}, 400) save_flag = data.get('save', True) save_flag = bool(save_flag) default_id = data.get('default') @@ -219,14 +200,14 @@ async def send_presets(request, session): presets_by_name[preset_key] = preset_payload if not presets_by_name: - return json.dumps({"error": "No matching presets found"}), 404, {'Content-Type': 'application/json'} + return J({"error": "No matching presets found"}, 404) if default_id is not None and str(default_id) not in presets_by_name: default_id = None bridge = get_current_bridge() if not bridge: - return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'} + return J({"error": "Transport not configured"}, 503) send_delay_s = 0.1 total_presets = len(presets_by_name) @@ -300,18 +281,18 @@ async def send_presets(request, session): delay_s=send_delay_s, ) except Exception: - return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'} + return J({"error": "Send failed"}, 503) - return json.dumps({ + return J({ "message": "Presets sent", "presets_sent": total_presets, "messages_sent": deliveries, - }), 200, {'Content-Type': 'application/json'} + }, 200) -@controller.post('/push') +@router.post("/push") @with_session -async def push_driver_messages(request, session): +async def push_driver_messages(request: Request, session): """ Deliver one or more raw v1 JSON objects to devices (ESP-NOW and/or TCP). @@ -320,15 +301,15 @@ async def push_driver_messages(request, session): or a single {"payload": {...}, "targets": [...]}. """ try: - data = request.json or {} + data = await read_json(request) except Exception: - return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'} + return J({"error": "Invalid JSON"}, 400) seq = data.get("sequence") if not seq and data.get("payload") is not None: seq = [data["payload"]] if not isinstance(seq, list) or not seq: - return json.dumps({"error": "sequence or payload required"}), 400, {'Content-Type': 'application/json'} + return J({"error": "sequence or payload required"}, 400) raw_targets = data.get("targets") target_list = None @@ -344,7 +325,7 @@ async def push_driver_messages(request, session): bridge = get_current_bridge() if not bridge: - return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'} + return J({"error": "Transport not configured"}, 503) messages = [] i = 0 @@ -355,7 +336,7 @@ async def push_driver_messages(request, session): messages.append(item) i += 1 continue - return json.dumps({"error": "sequence items must be objects or strings"}), 400, {'Content-Type': 'application/json'} + return J({"error": "sequence items must be objects or strings"}, 400) nxt = seq[i + 1] if i + 1 < len(seq) else None if ( isinstance(nxt, dict) @@ -392,7 +373,7 @@ async def push_driver_messages(request, session): unicast=unicast, ) except Exception: - return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'} + return J({"error": "Send failed"}, 503) try: from util import sequence_playback as seq_pb @@ -405,8 +386,8 @@ async def push_driver_messages(request, session): except Exception: pass - return json.dumps({ + return J({ "message": "Delivered", "deliveries": deliveries, - }), 200, {'Content-Type': 'application/json'} + }, 200) diff --git a/src/controllers/profile.py b/src/controllers/profile.py index 6d76d7b..4b37b9c 100644 --- a/src/controllers/profile.py +++ b/src/controllers/profile.py @@ -1,5 +1,7 @@ -from microdot import Microdot -from microdot.session import with_session +from fastapi import APIRouter, Request +from http_responses import J, html_response, plain, read_json, send_file +from http_session import with_session + from models.profile import Profile from models.zone import Zone from models.preset import Preset @@ -7,15 +9,15 @@ from models.sequence import Sequence from util.profile_bundle import export_profile_bundle, import_profile_bundle import json -controller = Microdot() +router = APIRouter() profiles = Profile() zones = Zone() presets = Preset() sequences = Sequence() -@controller.get('') +@router.get("/") @with_session -async def list_profiles(request, session): +async def list_profiles(request: Request, session): """List all profiles with current profile info.""" profile_list = profiles.list() current_id = session.get('current_profile') @@ -35,14 +37,14 @@ async def list_profiles(request, session): if profile_data: profiles_data[profile_id] = profile_data - return json.dumps({ + return J({ "profiles": profiles_data, "current_profile_id": current_id - }), 200, {'Content-Type': 'application/json'} + }, 200) -@controller.get('/current') +@router.get("/current") @with_session -async def get_current_profile(request, session): +async def get_current_profile(request: Request, session): """Get the current profile ID from session (or fallback).""" profile_list = profiles.list() current_id = session.get('current_profile') @@ -54,19 +56,17 @@ async def get_current_profile(request, session): session.save() if current_id: profile = profiles.read(current_id) - return json.dumps({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'} - return json.dumps({"error": "No profile available"}), 404 - - -@controller.post('/import') + return J({"id": current_id, "profile": profile}, 200) + return J({"error": "No profile available"}, 404) +@router.post("/import") @with_session -async def import_profile(request, session): +async def import_profile(request: Request, session): """Import a profile bundle (optionally apply as current profile).""" try: - body = request.json or {} + body = await read_json(request) bundle = body.get("bundle") if isinstance(body, dict) else body if not isinstance(bundle, dict): - return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'} + return J({"error": "Expected JSON bundle"}, 400) name = body.get("name") if isinstance(body, dict) else None apply_raw = body.get("apply", True) if isinstance(body, dict) else True if isinstance(apply_raw, str): @@ -86,19 +86,15 @@ async def import_profile(request, session): if apply: session['current_profile'] = str(new_profile_id) session.save() - return ( - json.dumps({new_profile_id: profile_data, "id": new_profile_id}), - 201, - {'Content-Type': 'application/json'}, - ) + return J({new_profile_id: profile_data, "id": new_profile_id}, 201) except ValueError as e: - return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'} + return J({"error": str(e)}, 400) except Exception as e: - return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'} + return J({"error": str(e)}, 400) -@controller.get('//export') -async def export_profile(request, id): +@router.get("/{id}/export") +async def export_profile(request: Request, id): """Export profile, zones, presets, sequences, and palette as a JSON bundle.""" try: bundle = export_profile_bundle( @@ -109,33 +105,32 @@ async def export_profile(request, id): sequences, profiles._palette_model, ) - return json.dumps(bundle), 200, {'Content-Type': 'application/json'} + return J(bundle, 200) except ValueError as e: - return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'} + return J({"error": str(e)}, 404) except Exception as e: - return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'} + return J({"error": str(e)}, 400) -@controller.post('//apply') +@router.post("/{id}/apply") @with_session -async def apply_profile(request, session, id): +async def apply_profile(request: Request, session, id): """Apply a profile by saving it to session.""" if not profiles.read(id): - return json.dumps({"error": "Profile not found"}), 404 + return J({"error": "Profile not found"}, 404) session['current_profile'] = str(id) session.save() - return json.dumps({"message": "Profile applied", "id": str(id)}), 200, {'Content-Type': 'application/json'} + return J({"message": "Profile applied", "id": str(id)}, 200) -@controller.post('//clone') -async def clone_profile(request, id): +@router.post("/{id}/clone") +async def clone_profile(request: Request, id): """Clone an existing profile along with its tabs and palette.""" try: source = profiles.read(id) if not source: - return json.dumps({"error": "Profile not found"}), 404 - - data = request.json or {} + return J({"error": "Profile not found"}, 404) + data = await read_json(request) source_name = source.get("name") or f"Profile {id}" new_name = data.get("name") or source_name profile_type = source.get("type", "zones") @@ -235,14 +230,12 @@ async def clone_profile(request, id): zones.save() profiles.save() - return json.dumps({new_profile_id: new_profile_data}), 201, {'Content-Type': 'application/json'} + return J({new_profile_id: new_profile_data}, 201) except Exception as e: - return json.dumps({"error": str(e)}), 400 - - -@controller.get('/') + return J({"error": str(e)}, 400) +@router.get("/{id}") @with_session -async def get_profile(request, id, session): +async def get_profile(request: Request, id, session): """Get a specific profile by ID.""" # Handle 'current' as a special case if id == 'current': @@ -250,14 +243,13 @@ async def get_profile(request, id, session): profile = profiles.read(id) if profile: - return json.dumps(profile), 200, {'Content-Type': 'application/json'} - return json.dumps({"error": "Profile not found"}), 404 - -@controller.post('') -async def create_profile(request): + return J(profile, 200) + return J({"error": "Profile not found"}, 404) +@router.post("/") +async def create_profile(request: Request): """Create a new profile.""" try: - data = dict(request.json or {}) + data = dict(await read_json(request)) name = data.get("name", "") seed_raw = data.get("seed_dj_zone", False) if isinstance(seed_raw, str): @@ -413,16 +405,15 @@ async def create_profile(request): profiles.update(profile_id, {"zones": profile_tabs}) profile_data = profiles.read(profile_id) - return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'} + return J({profile_id: profile_data}, 201) except Exception as e: - return json.dumps({"error": str(e)}), 400 - -@controller.put('/current') + return J({"error": str(e)}, 400) +@router.put("/current") @with_session -async def update_current_profile(request, session): +async def update_current_profile(request: Request, session): """Update the current profile using session (or fallback).""" try: - data = request.json or {} + data = await read_json(request) profile_list = profiles.list() current_id = session.get('current_profile') if not current_id and profile_list: @@ -430,27 +421,25 @@ async def update_current_profile(request, session): session['current_profile'] = str(current_id) session.save() if not current_id: - return json.dumps({"error": "No profile available"}), 404 + return J({"error": "No profile available"}, 404) if profiles.update(current_id, data): - return json.dumps(profiles.read(current_id)), 200, {'Content-Type': 'application/json'} - return json.dumps({"error": "Profile not found"}), 404 + return J(profiles.read(current_id), 200) + return J({"error": "Profile not found"}, 404) except Exception as e: - return json.dumps({"error": str(e)}), 400 - -@controller.put('/') -async def update_profile(request, id): + return J({"error": str(e)}, 400) +@router.put("/{id}") +async def update_profile(request: Request, id): """Update an existing profile.""" try: - data = request.json + data = await read_json(request) if profiles.update(id, data): - return json.dumps(profiles.read(id)), 200, {'Content-Type': 'application/json'} - return json.dumps({"error": "Profile not found"}), 404 + return J(profiles.read(id), 200) + return J({"error": "Profile not found"}, 404) except Exception as e: - return json.dumps({"error": str(e)}), 400 - -@controller.delete('/') -async def delete_profile(request, id): + return J({"error": str(e)}, 400) +@router.delete("/{id}") +async def delete_profile(request: Request, id): """Delete a profile.""" if profiles.delete(id): - return json.dumps({"message": "Profile deleted successfully"}), 200 - return json.dumps({"error": "Profile not found"}), 404 + return J({"message": "Profile deleted successfully"}, 200) + return J({"error": "Profile not found"}, 404) \ No newline at end of file diff --git a/src/controllers/scene.py b/src/controllers/scene.py index de35074..e7e8b0b 100644 --- a/src/controllers/scene.py +++ b/src/controllers/scene.py @@ -1,49 +1,49 @@ -from microdot import Microdot +from fastapi import APIRouter, Request +from http_responses import J, html_response, plain, read_json, send_file +from http_session import with_session + from models.scene import Scene import json -controller = Microdot() +router = APIRouter() scenes = Scene() -@controller.get('') -async def list_scenes(request): +@router.get("/") +async def list_scenes(request: Request): """List all scenes.""" - return json.dumps(scenes), 200, {'Content-Type': 'application/json'} + return J(scenes, 200) -@controller.get('/') -async def get_scene(request, id): +@router.get("/{id}") +async def get_scene(request: Request, id): """Get a specific scene by ID.""" scene = scenes.read(id) if scene: - return json.dumps(scene), 200, {'Content-Type': 'application/json'} - return json.dumps({"error": "Scene not found"}), 404 - -@controller.post('') -async def create_scene(request): + return J(scene, 200) + return J({"error": "Scene not found"}, 404) +@router.post("/") +async def create_scene(request: Request): """Create a new scene.""" try: - data = request.json + data = await read_json(request) scene_id = scenes.create() if scenes.update(scene_id, data): - return json.dumps(scenes.read(scene_id)), 201, {'Content-Type': 'application/json'} - return json.dumps({"error": "Failed to create scene"}), 400 + return J(scenes.read(scene_id), 201) + return J({"error": "Failed to create scene"}, 400) except Exception as e: - return json.dumps({"error": str(e)}), 400 - -@controller.put('/') -async def update_scene(request, id): + return J({"error": str(e)}, 400) +@router.put("/{id}") +async def update_scene(request: Request, id): """Update an existing scene.""" try: - data = request.json + data = await read_json(request) if scenes.update(id, data): - return json.dumps(scenes.read(id)), 200, {'Content-Type': 'application/json'} - return json.dumps({"error": "Scene not found"}), 404 + return J(scenes.read(id), 200) + return J({"error": "Scene not found"}, 404) except Exception as e: - return json.dumps({"error": str(e)}), 400 - -@controller.delete('/') -async def delete_scene(request, id): + return J({"error": str(e)}, 400) +@router.delete("/{id}") +async def delete_scene(request: Request, id): """Delete a scene.""" if scenes.delete(id): - return json.dumps({"message": "Scene deleted successfully"}), 200 - return json.dumps({"error": "Scene not found"}), 404 + return J({"message": "Scene deleted successfully"}, 200) + return J({"error": "Scene not found"}, 404) \ No newline at end of file diff --git a/src/controllers/sequence.py b/src/controllers/sequence.py index c32818d..00fc3b3 100644 --- a/src/controllers/sequence.py +++ b/src/controllers/sequence.py @@ -1,5 +1,7 @@ -from microdot import Microdot -from microdot.session import with_session +from fastapi import APIRouter, Request +from http_responses import J, html_response, plain, read_json, send_file +from http_session import with_session + from models.sequence import Sequence from models.profile import Profile from models.transport import get_current_bridge @@ -7,7 +9,7 @@ from models.preset import Preset from util.profile_bundle import export_sequence_bundle, import_sequence_bundle import json -controller = Microdot() +router = APIRouter() sequences = Sequence() profiles = Profile() presets = Preset() @@ -26,30 +28,30 @@ def get_current_profile_id(session=None): return None -@controller.get("") +@router.get("/") @with_session -async def list_sequences(request, session): +async def list_sequences(request: Request, session): """List sequences for the current profile.""" sequences.load() current_profile_id = get_current_profile_id(session) if not current_profile_id: - return json.dumps({}), 200, {"Content-Type": "application/json"} + return J({}, 200) scoped = { sid: sdata for sid, sdata in sequences.items() if isinstance(sdata, dict) and str(sdata.get("profile_id")) == str(current_profile_id) } - return json.dumps(scoped), 200, {"Content-Type": "application/json"} + return J(scoped, 200) -@controller.get("//export") +@router.get("/{id}/export") @with_session -async def export_sequence(request, session, id): +async def export_sequence(request: Request, session, id): """Export a sequence and referenced presets as a JSON bundle.""" current_profile_id = get_current_profile_id(session) if not current_profile_id: - return json.dumps({"error": "No profile available"}), 404, {"Content-Type": "application/json"} + return J({"error": "No profile available"}, 404) try: bundle = export_sequence_bundle( id, @@ -57,46 +59,34 @@ async def export_sequence(request, session, id): presets, profile_id=current_profile_id, ) - return json.dumps(bundle), 200, {"Content-Type": "application/json"} + return J(bundle, 200) except ValueError as e: - return json.dumps({"error": str(e)}), 404, {"Content-Type": "application/json"} + return J({"error": str(e)}, 404) -@controller.post("/import") +@router.post("/import") @with_session -async def import_sequence(request, session): +async def import_sequence(request: Request, session): """Import a sequence bundle into the current profile.""" try: current_profile_id = get_current_profile_id(session) if not current_profile_id: - return ( - json.dumps({"error": "No profile available"}), - 404, - {"Content-Type": "application/json"}, - ) - body = request.json or {} + return J({"error": "No profile available"}, 404) + body = await read_json(request) bundle = body.get("bundle") if isinstance(body, dict) else body if not isinstance(bundle, dict): - return ( - json.dumps({"error": "Expected JSON bundle"}), - 400, - {"Content-Type": "application/json"}, - ) + return J({"error": "Expected JSON bundle"}, 400) new_id, seq_data = import_sequence_bundle(bundle, sequences, presets, current_profile_id) - return ( - json.dumps({new_id: seq_data}), - 201, - {"Content-Type": "application/json"}, - ) + return J({new_id: seq_data}, 201) except ValueError as e: - return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"} + return J({"error": str(e)}, 400) except Exception as e: - return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"} + return J({"error": str(e)}, 400) -@controller.get("/") +@router.get("/{id}") @with_session -async def get_sequence(request, session, id): +async def get_sequence(request: Request, session, id): """Get a specific sequence by ID (current profile only).""" sequences.load() current_profile_id = get_current_profile_id(session) @@ -106,30 +96,20 @@ async def get_sequence(request, session, id): and current_profile_id and str(seq.get("profile_id")) == str(current_profile_id) ): - return json.dumps(seq), 200, {"Content-Type": "application/json"} - return json.dumps({"error": "Sequence not found"}), 404 - - -@controller.post("") + return J(seq, 200) + return J({"error": "Sequence not found"}, 404) +@router.post("/") @with_session -async def create_sequence(request, session): +async def create_sequence(request: Request, session): """Create a new sequence for the current profile.""" try: try: - data = request.json or {} + data = await read_json(request) except Exception: - return ( - json.dumps({"error": "Invalid JSON"}), - 400, - {"Content-Type": "application/json"}, - ) + return J({"error": "Invalid JSON"}, 400) current_profile_id = get_current_profile_id(session) if not current_profile_id: - return ( - json.dumps({"error": "No profile available"}), - 404, - {"Content-Type": "application/json"}, - ) + return J({"error": "No profile available"}, 404) sequence_id = sequences.create(current_profile_id) if not isinstance(data, dict): data = {} @@ -137,36 +117,24 @@ async def create_sequence(request, session): data["profile_id"] = str(current_profile_id) if sequences.update(sequence_id, data): seq_data = sequences.read(sequence_id) - return ( - json.dumps({sequence_id: seq_data}), - 201, - {"Content-Type": "application/json"}, - ) - return ( - json.dumps({"error": "Failed to create sequence"}), - 400, - {"Content-Type": "application/json"}, - ) + return J({sequence_id: seq_data}, 201) + return J({"error": "Failed to create sequence"}, 400) except Exception as e: - return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"} + return J({"error": str(e)}, 400) -@controller.put("/") +@router.put("/{id}") @with_session -async def update_sequence(request, session, id): +async def update_sequence(request: Request, session, id): """Update an existing sequence (current profile only).""" try: current_profile_id = get_current_profile_id(session) seq = sequences.read(id) if not seq or str(seq.get("profile_id")) != str(current_profile_id): - return json.dumps({"error": "Sequence not found"}), 404 - data = request.json + return J({"error": "Sequence not found"}, 404) + data = await read_json(request) if not isinstance(data, dict): - return ( - json.dumps({"error": "Invalid JSON"}), - 400, - {"Content-Type": "application/json"}, - ) + return J({"error": "Invalid JSON"}, 400) data = dict(data) data["profile_id"] = str(current_profile_id) if sequences.update(id, data): @@ -176,20 +144,20 @@ async def update_sequence(request, session, id): stop_if_playing_sequence(str(id)) except Exception: pass - return json.dumps(sequences.read(id)), 200, {"Content-Type": "application/json"} - return json.dumps({"error": "Sequence not found"}), 404 + return J(sequences.read(id), 200) + return J({"error": "Sequence not found"}, 404) except Exception as e: - return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"} + return J({"error": str(e)}, 400) -@controller.delete("/") +@router.delete("/{id}") @with_session -async def delete_sequence(request, session, id): +async def delete_sequence(request: Request, session, id): """Delete a sequence (current profile only).""" current_profile_id = get_current_profile_id(session) seq = sequences.read(id) if not seq or str(seq.get("profile_id")) != str(current_profile_id): - return json.dumps({"error": "Sequence not found"}), 404 + return J({"error": "Sequence not found"}, 404) try: from util.sequence_playback import stop_if_playing_sequence @@ -197,21 +165,15 @@ async def delete_sequence(request, session, id): except Exception: pass if sequences.delete(id): - return ( - json.dumps({"message": "Sequence deleted successfully"}), - 200, - {"Content-Type": "application/json"}, - ) - return json.dumps({"error": "Sequence not found"}), 404 - - -@controller.post("/sync-phase") + return J({"message": "Sequence deleted successfully"}, 200) + return J({"error": "Sequence not found"}, 404) +@router.post("/sync-phase") @with_session -async def sync_sequence_beat_phase(request, session): +async def sync_sequence_beat_phase(request: Request, session): """Align beat counters while a sequence is playing (body: {\"mode\": \"step\"|\"pass\"}).""" _ = session try: - data = request.json or {} + data = await read_json(request) except Exception: data = {} if not isinstance(data, dict): @@ -221,65 +183,47 @@ async def sync_sequence_beat_phase(request, session): from util.sequence_playback import sync_beat_phase if not await sync_beat_phase(str(mode)): - return ( - json.dumps({"error": "No sequence is playing"}), - 409, - {"Content-Type": "application/json"}, - ) + return J({"error": "No sequence is playing"}, 409) from util.audio_detector import anchor_shared_bar_phase anchor_shared_bar_phase() - return json.dumps({"ok": True, "mode": str(mode).strip().lower()}), 200, { - "Content-Type": "application/json" - } + return J({"ok": True, "mode": str(mode).strip().lower()}, 200) except Exception as e: - return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"} + return J({"error": str(e)}, 500) -@controller.post("/stop") +@router.post("/stop") @with_session -async def stop_sequence_playback(request, session): +async def stop_sequence_playback(request: Request, session): """Stop server-driven zone sequence playback.""" _ = request try: from util.sequence_playback import stop_playback await stop_playback(clear_devices=True) - return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"} + return J({"ok": True}, 200) except Exception as e: - return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"} + return J({"error": str(e)}, 500) -@controller.post("//play") +@router.post("/{id}/play") @with_session -async def play_sequence(request, session, id): +async def play_sequence(request: Request, session, id): """Start server-driven playback for a sequence in a zone (body: {\"zone_id\": \"...\"}).""" if not get_current_bridge(): - return ( - json.dumps({"error": "Transport not configured"}), - 503, - {"Content-Type": "application/json"}, - ) + return J({"error": "Transport not configured"}, 503) current_profile_id = get_current_profile_id(session) if not current_profile_id: - return ( - json.dumps({"error": "No profile available"}), - 404, - {"Content-Type": "application/json"}, - ) + return J({"error": "No profile available"}, 404) try: - data = request.json or {} + data = await read_json(request) except Exception: data = {} if not isinstance(data, dict): data = {} zone_id = data.get("zone_id") or data.get("zoneId") if zone_id is None or str(zone_id).strip() == "": - return ( - json.dumps({"error": "zone_id required"}), - 400, - {"Content-Type": "application/json"}, - ) + return J({"error": "zone_id required"}, 400) zone_id = str(zone_id).strip() try: from util.sequence_playback import start @@ -289,10 +233,10 @@ async def play_sequence(request, session, id): from util.sequence_playback import pending_play_status body = {"ok": True, **pending_play_status()} - return json.dumps(body), 200, {"Content-Type": "application/json"} + return J(body, 200) except ValueError as e: - return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"} + return J({"error": str(e)}, 400) except RuntimeError as e: - return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"} + return J({"error": str(e)}, 503) except Exception as e: - return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"} + return J({"error": str(e)}, 500) diff --git a/src/controllers/settings.py b/src/controllers/settings.py index 4821da7..9c97716 100644 --- a/src/controllers/settings.py +++ b/src/controllers/settings.py @@ -1,22 +1,25 @@ +from fastapi import APIRouter, Request +from http_responses import J, html_response, plain, read_json, send_file +from http_session import with_session + import asyncio import json -from microdot import Microdot, send_file from settings import get_settings -controller = Microdot() +router = APIRouter() settings = get_settings() -@controller.get('') -async def get_settings(request): +@router.get("/") +async def get_settings(request: Request): """Get all settings.""" # Settings is already a dict subclass; avoid dict() wrapper which can # trigger MicroPython's "dict update sequence has wrong length" quirk. - return json.dumps(settings), 200, {'Content-Type': 'application/json'} + return J(settings, 200) -@controller.get('/wifi/ap') -async def get_ap_config(request): +@router.get("/wifi/ap") +async def get_ap_config(request: Request): """Get saved AP configuration (Pi: no in-device AP).""" config = { 'saved_ssid': settings.get('wifi_ap_ssid'), @@ -24,40 +27,37 @@ async def get_ap_config(request): 'saved_channel': settings.get('wifi_ap_channel'), 'active': False, } - return json.dumps(config), 200, {'Content-Type': 'application/json'} + return J(config, 200) -@controller.post('/wifi/ap') -async def configure_ap(request): +@router.post("/wifi/ap") +async def configure_ap(request: Request): """Save AP configuration to settings (Pi: no in-device AP).""" try: - data = request.json + data = await read_json(request) ssid = data.get('ssid') password = data.get('password', '') channel = data.get('channel') if not ssid: - return json.dumps({"error": "SSID is required"}), 400 - + return J({"error": "SSID is required"}, 400) # Validate channel (1-11 for 2.4GHz) if channel is not None: channel = int(channel) if channel < 1 or channel > 11: - return json.dumps({"error": "Channel must be between 1 and 11"}), 400 - + return J({"error": "Channel must be between 1 and 11"}, 400) settings['wifi_ap_ssid'] = ssid settings['wifi_ap_password'] = password if channel is not None: settings['wifi_ap_channel'] = channel settings.save() - return json.dumps({ + return J({ "message": "AP settings saved", "ssid": ssid, - "channel": channel - }), 200, {'Content-Type': 'application/json'} + "channel": channel, + }, 200) except Exception as e: - return json.dumps({"error": str(e)}), 500 - + return J({"error": str(e)}, 500) def _validate_wifi_channel(value): """Return int 1–11 or raise ValueError.""" ch = int(value) @@ -95,11 +95,17 @@ def _validate_audio_input_volume(value): return v -@controller.put('') -async def update_settings(request): +def _validate_audio_simulated_bpm(value): + from util.bpm_limits import clamp_bpm + + return int(clamp_bpm(value)) + + +@router.put("/") +async def update_settings(request: Request): """Update general settings.""" try: - data = request.json + data = await read_json(request) global_brightness_changed = False for key, value in data.items(): if key == 'wifi_channel' and value is not None: @@ -113,17 +119,18 @@ async def update_settings(request): settings[key] = _validate_audio_beat_phase_ms(value) elif key == 'audio_input_volume' and value is not None: settings[key] = _validate_audio_input_volume(value) + elif key == 'audio_simulated_bpm' and value is not None: + settings[key] = _validate_audio_simulated_bpm(value) else: settings[key] = value settings.save() - return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'} + return J({"message": "Settings updated successfully"}, 200) except ValueError as e: - return json.dumps({"error": str(e)}), 400 + return J({"error": str(e)}, 400) except Exception as e: - return json.dumps({"error": str(e)}), 500 - -@controller.get('/page') -async def settings_page(request): + return J({"error": str(e)}, 500) +@router.get("/page") +async def settings_page(request: Request): """Serve the settings page.""" return send_file('templates/settings.html') diff --git a/src/controllers/wifi_bridge.py b/src/controllers/wifi_bridge.py index f3a59d1..dc29c75 100644 --- a/src/controllers/wifi_bridge.py +++ b/src/controllers/wifi_bridge.py @@ -2,10 +2,14 @@ from __future__ import annotations +from fastapi import APIRouter, Request +from http_responses import J, html_response, plain, read_json, send_file +from http_session import with_session + + import json import secrets -from microdot import Microdot from settings import get_settings from util.bridge_profiles import find_bridge_profile, normalise_bridges @@ -20,7 +24,7 @@ from util.bridge_runtime import ( ) from util.pi_wifi import list_wifi_interfaces, nmcli_available, scan_wifi -controller = Microdot() +router = APIRouter() def _bridge_transport(settings) -> str: @@ -44,55 +48,39 @@ def _bridges_payload(settings) -> dict: } -@controller.get("/interfaces") -async def wifi_interfaces(request): +@router.get("/interfaces") +async def wifi_interfaces(request: Request): _ = request if not nmcli_available(): - return ( - json.dumps({"ok": False, "error": "nmcli not found (install NetworkManager)"}), - 503, - {"Content-Type": "application/json"}, - ) - return ( - json.dumps({"ok": True, "interfaces": list_wifi_interfaces()}), - 200, - {"Content-Type": "application/json"}, - ) + return J({"ok": False, "error": "nmcli not found (install NetworkManager)"}, 503) + return J({"ok": True, "interfaces": list_wifi_interfaces()}, 200) -@controller.get("/scan") -async def wifi_scan(request): - device = (request.args.get("device") or "").strip() +@router.get("/scan") +async def wifi_scan(request: Request): + device = (request.query_params.get("device") or "").strip() if not device: - return json.dumps({"error": "device query param required"}), 400, { - "Content-Type": "application/json", - } + return J({"error": "device query param required"}, 400) if not nmcli_available(): - return json.dumps({"ok": False, "error": "nmcli not found"}), 503, { - "Content-Type": "application/json", - } + return J({"ok": False, "error": "nmcli not found"}, 503) try: networks = await scan_wifi(device) - return json.dumps({"ok": True, "device": device, "networks": networks}), 200, { - "Content-Type": "application/json", - } + return J({"ok": True, "device": device, "networks": networks}, 200) except Exception as e: - return json.dumps({"ok": False, "error": str(e)}), 500, { - "Content-Type": "application/json", - } + return J({"ok": False, "error": str(e)}, 500) -@controller.get("/bridges") -async def get_bridges(request): +@router.get("/bridges") +async def get_bridges(request: Request): _ = request settings = get_settings() - return json.dumps(_bridges_payload(settings)), 200, {"Content-Type": "application/json"} + return J(_bridges_payload(settings), 200) -@controller.put("/bridges") -async def put_bridges(request): +@router.put("/bridges") +async def put_bridges(request: Request): try: - data = request.json or {} + data = await read_json(request) settings = get_settings() if "wifi_interface" in data: settings["wifi_interface"] = str(data.get("wifi_interface") or "").strip() @@ -109,62 +97,50 @@ async def put_bridges(request): if "bridges" in data: settings["bridges"] = normalise_bridges(data.get("bridges")) settings.save() - return json.dumps({"ok": True, "message": "Bridge profiles saved"}), 200, { - "Content-Type": "application/json", - } + return J({"ok": True, "message": "Bridge profiles saved"}, 200) except Exception as e: - return json.dumps({"ok": False, "error": str(e)}), 400, { - "Content-Type": "application/json", - } + return J({"ok": False, "error": str(e)}, 400) -@controller.delete("/bridges/") -async def delete_bridge_profile(request, bridge_id): +@router.delete("/bridges/{bridge_id}") +async def delete_bridge_profile(request: Request, bridge_id): _ = request settings = get_settings() bid = str(bridge_id or "").strip() bridges = normalise_bridges(settings.get("bridges")) kept = [b for b in bridges if str(b.get("id") or "") != bid] if len(kept) == len(bridges): - return json.dumps({"ok": False, "error": "Bridge profile not found"}), 404, { - "Content-Type": "application/json", - } + return J({"ok": False, "error": "Bridge profile not found"}, 404) settings["bridges"] = kept settings.save() payload = _bridges_payload(settings) payload["message"] = "Bridge profile deleted" - return json.dumps(payload), 200, {"Content-Type": "application/json"} + return J(payload, 200) -@controller.post("/bridges//connect") -async def connect_saved_bridge(request, bridge_id): +@router.post("/bridges/{bridge_id}/connect") +async def connect_saved_bridge(request: Request, bridge_id): _ = request settings = get_settings() profile = find_bridge_profile(settings, bridge_id) if not profile: - return json.dumps({"error": "Bridge profile not found"}), 404, { - "Content-Type": "application/json", - } + return J({"error": "Bridge profile not found"}, 404) try: ok, err = await connect_bridge_profile(profile, settings) if not ok: - return json.dumps({"ok": False, "error": err or "Connect failed"}), 400, { - "Content-Type": "application/json", - } + return J({"ok": False, "error": err or "Connect failed"}, 400) payload = _bridges_payload(settings) payload["message"] = f"Connected to {profile.get('label')}" - return json.dumps(payload), 200, {"Content-Type": "application/json"} + return J(payload, 200) except Exception as e: - return json.dumps({"ok": False, "error": str(e)}), 500, { - "Content-Type": "application/json", - } + return J({"ok": False, "error": str(e)}, 500) -@controller.post("/connect") -async def wifi_connect_bridge(request): +@router.post("/connect") +async def wifi_connect_bridge(request: Request): """Join a bridge AP and open its WebSocket.""" try: - data = request.json or {} + data = await read_json(request) settings = get_settings() device = str(data.get("device") or settings.get("wifi_interface") or "").strip() ssid = str(data.get("ssid") or "").strip() @@ -177,13 +153,9 @@ async def wifi_connect_bridge(request): label = str(data.get("label") or ssid).strip() or ssid save_profile = bool(data.get("save_profile", True)) if not device: - return json.dumps({"error": "Wi‑Fi interface (device) is required"}), 400, { - "Content-Type": "application/json", - } + return J({"error": "Wi‑Fi interface (device) is required"}, 400) if not ssid: - return json.dumps({"error": "ssid is required"}), 400, { - "Content-Type": "application/json", - } + return J({"error": "ssid is required"}, 400) settings["wifi_interface"] = device bridges = normalise_bridges(settings.get("bridges")) profile_id = None @@ -217,23 +189,19 @@ async def wifi_connect_bridge(request): } ok, err = await connect_bridge_wifi(profile, settings) if not ok: - return json.dumps({"ok": False, "error": err or "Connect failed"}), 400, { - "Content-Type": "application/json", - } + return J({"ok": False, "error": err or "Connect failed"}, 400) payload = _bridges_payload(settings) payload["profile_id"] = profile_id payload["message"] = f"Connected to {ssid}" - return json.dumps(payload), 200, {"Content-Type": "application/json"} + return J(payload, 200) except Exception as e: - return json.dumps({"ok": False, "error": str(e)}), 500, { - "Content-Type": "application/json", - } + return J({"ok": False, "error": str(e)}, 500) -@controller.post("/serial/connect") -async def serial_connect_bridge(request): +@router.post("/serial/connect") +async def serial_connect_bridge(request: Request): try: - data = request.json or {} + data = await read_json(request) port = str(data.get("port") or data.get("serial_port") or "").strip() save_profile = bool(data.get("save_profile", True)) label = str(data.get("label") or port).strip() or port @@ -242,9 +210,7 @@ async def serial_connect_bridge(request): except (TypeError, ValueError): baud = 921600 if not port: - return json.dumps({"error": "port is required"}), 400, { - "Content-Type": "application/json", - } + return J({"error": "port is required"}, 400) settings = get_settings() bridges = normalise_bridges(settings.get("bridges")) profile_id = None @@ -269,14 +235,10 @@ async def serial_connect_bridge(request): profile = {"transport": "serial", "serial_port": port, "serial_baudrate": baud} ok, err = await connect_bridge_serial(profile, settings) if not ok: - return json.dumps({"ok": False, "error": err}), 500, { - "Content-Type": "application/json", - } + return J({"ok": False, "error": err}, 500) payload = _bridges_payload(settings) payload["profile_id"] = profile_id payload["message"] = f"Connected on {port}" - return json.dumps(payload), 200, {"Content-Type": "application/json"} + return J(payload, 200) except Exception as e: - return json.dumps({"ok": False, "error": str(e)}), 500, { - "Content-Type": "application/json", - } + return J({"ok": False, "error": str(e)}, 500) diff --git a/src/controllers/zone.py b/src/controllers/zone.py index 23998eb..551cb12 100644 --- a/src/controllers/zone.py +++ b/src/controllers/zone.py @@ -1,10 +1,12 @@ -from microdot import Microdot, send_file -from microdot.session import with_session +from fastapi import APIRouter, Request +from http_responses import J, J_cookie, html_response, plain, read_json, send_file +from http_session import with_session + from models.zone import Zone from models.profile import Profile import json -controller = Microdot() +router = APIRouter() zones = Zone() profiles = Profile() @@ -69,11 +71,7 @@ def _render_zones_list_fragment(request, session): """Render zone strip HTML for HTMX / JS.""" profile_id = get_current_profile_id(session) if not profile_id: - return ( - '
No profile selected
', - 200, - {"Content-Type": "text/html"}, - ) + return html_response('
No profile selected
', 200) zone_order = get_profile_zone_order(profile_id) current_zone_id = get_current_zone_id(request, session) @@ -96,9 +94,7 @@ def _render_zones_list_fragment(request, session): + "" ) html += "" - return html, 200, {"Content-Type": "text/html"} - - + return html_response(html, 200) def _render_zone_content_fragment(request, session, id): if id == "current": current_zone_id = get_current_zone_id(request, session) @@ -106,18 +102,13 @@ def _render_zone_content_fragment(request, session, id): accept_header = request.headers.get("Accept", "") wants_html = "text/html" in accept_header if wants_html: - return ( - '
No current zone set
', - 404, - {"Content-Type": "text/html"}, - ) - return json.dumps({"error": "No current zone set"}), 404 + return html_response('
No current zone set
', 404) + return J({"error": "No current zone set"}, 404) id = current_zone_id z = zones.read(id) if not z: - return '
Zone not found
', 404, {"Content-Type": "text/html"} - + return html_response('
Zone not found
', 404) session["current_zone"] = str(id) session.save() @@ -133,18 +124,16 @@ def _render_zone_content_fragment(request, session, id): "" "" ) - return html, 200, {"Content-Type": "text/html"} - - -@controller.get("//content-fragment") + return html_response(html, 200) +@router.get("/{id}/content-fragment") @with_session -async def zone_content_fragment(request, session, id): +async def zone_content_fragment(request: Request, session, id): return _render_zone_content_fragment(request, session, id) -@controller.get("") +@router.get("/") @with_session -async def list_zones(request, session): +async def list_zones(request: Request, session): zones.load() profile_id = get_current_profile_id(session) current_zone_id = get_current_zone_id(request, session) @@ -156,93 +145,66 @@ async def list_zones(request, session): if zdata: zones_data[zid] = zdata - return ( - json.dumps( - { + return J({ "zones": zones_data, "zone_order": zone_order, "current_zone_id": current_zone_id, "profile_id": profile_id, - } - ), - 200, - {"Content-Type": "application/json"}, - ) + }, 200) -@controller.get("/current") +@router.get("/current") @with_session -async def get_current_zone(request, session): +async def get_current_zone(request: Request, session): current_zone_id = get_current_zone_id(request, session) if not current_zone_id: - return ( - json.dumps({"error": "No current zone set", "zone": None, "zone_id": None}), - 404, - ) + return J({"error": "No current zone set", "zone": None, "zone_id": None}, 404) z = zones.read(current_zone_id) if z: - return ( - json.dumps({"zone": z, "zone_id": current_zone_id}), - 200, - {"Content-Type": "application/json"}, - ) - return ( - json.dumps({"error": "Zone not found", "zone": None, "zone_id": None}), - 404, - ) + return J({"zone": z, "zone_id": current_zone_id}, 200) + return J({"error": "Zone not found", "zone": None, "zone_id": None}, 404) -@controller.post("//set-current") -async def set_current_zone(request, id): +@router.post("/{id}/set-current") +async def set_current_zone(request: Request, id): z = zones.read(id) if not z: - return json.dumps({"error": "Zone not found"}), 404 - - response_data = json.dumps({"message": "Current zone set", "zone_id": id}) - return ( - response_data, - 200, - { - "Content-Type": "application/json", - "Set-Cookie": ( - f"current_zone={id}; Path=/; Max-Age=31536000; SameSite=Lax" - ), - }, + return J({"error": "Zone not found"}, 404) + return J_cookie( + {"message": "Current zone set", "zone_id": id}, + name="current_zone", + value=str(id), + max_age=31536000, ) -@controller.get("/") -async def get_zone(request, id): +@router.get("/{id}") +async def get_zone(request: Request, id): zones.load() z = zones.read(id) if z: - return json.dumps(z), 200, {"Content-Type": "application/json"} - return json.dumps({"error": "Zone not found"}), 404 - - -@controller.put("/") -async def update_zone(request, id): + return J(z, 200) + return J({"error": "Zone not found"}, 404) +@router.put("/{id}") +async def update_zone(request: Request, id): try: - data = request.json + data = await read_json(request) if zones.update(id, data): - return json.dumps(zones.read(id)), 200, {"Content-Type": "application/json"} - return json.dumps({"error": "Zone not found"}), 404 + return J(zones.read(id), 200) + return J({"error": "Zone not found"}, 404) except Exception as e: - return json.dumps({"error": str(e)}), 400 - - -@controller.delete("/") + return J({"error": str(e)}, 400) +@router.delete("/{id}") @with_session -async def delete_zone(request, session, id): +async def delete_zone(request: Request, session, id): try: if id == "current": current_zone_id = get_current_zone_id(request, session) if current_zone_id: id = current_zone_id else: - return json.dumps({"error": "No current zone to delete"}), 404 - + return J({"error": "No current zone to delete"}, 404) if zones.delete(id): profile_id = get_current_profile_id(session) if profile_id: @@ -256,23 +218,15 @@ async def delete_zone(request, session, id): current_zone_id = get_current_zone_id(request, session) if current_zone_id == id: - response_data = json.dumps({"message": "Zone deleted successfully"}) - return ( - response_data, - 200, - { - "Content-Type": "application/json", - "Set-Cookie": ( - "current_zone=; Path=/; Max-Age=0; SameSite=Lax" - ), - }, + return J_cookie( + {"message": "Zone deleted successfully"}, + name="current_zone", + value="", + max_age=0, ) - return json.dumps({"message": "Zone deleted successfully"}), 200, { - "Content-Type": "application/json" - } - - return json.dumps({"error": "Zone not found"}), 404 + return J({"message": "Zone deleted successfully"}, 200) + return J({"error": "Zone not found"}, 404) except Exception as e: import sys @@ -280,22 +234,24 @@ async def delete_zone(request, session, id): sys.print_exception(e) except Exception: pass - return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"} + return J({"error": str(e)}, 500) -@controller.post("") +@router.post("/") @with_session -async def create_zone(request, session): +async def create_zone(request: Request, session): try: - if request.form: - name = request.form.get("name", "").strip() - ids_str = request.form.get("ids", "1").strip() + ct = (request.headers.get("content-type") or "").split(";")[0].strip().lower() + if ct in ("application/x-www-form-urlencoded", "multipart/form-data"): + form = await request.form() + name = (form.get("name") or "").strip() + ids_str = (form.get("ids") or "1").strip() names = [i.strip() for i in ids_str.split(",") if i.strip()] preset_ids = None group_ids = [] content_kind = None else: - data = request.json or {} + data = await read_json(request) name = data.get("name", "") names = data.get("names") if names is None: @@ -312,8 +268,7 @@ async def create_zone(request, session): content_kind = raw_kind if raw_kind in ("presets", "sequences") else None if not name: - return json.dumps({"error": "Zone name cannot be empty"}), 400 - + return J({"error": "Zone name cannot be empty"}, 400) zid = zones.create(name, names, preset_ids, group_ids, content_kind) profile_id = get_current_profile_id(session) @@ -327,23 +282,20 @@ async def create_zone(request, session): profiles.update(profile_id, profile) zdata = zones.read(zid) - return json.dumps({zid: zdata}), 201, {"Content-Type": "application/json"} + return J({zid: zdata}, 201) except Exception as e: import sys sys.print_exception(e) - return json.dumps({"error": str(e)}), 400 - - -@controller.post("//clone") + return J({"error": str(e)}, 400) +@router.post("/{id}/clone") @with_session -async def clone_zone(request, session, id): +async def clone_zone(request: Request, session, id): try: source = zones.read(id) if not source: - return json.dumps({"error": "Zone not found"}), 404 - - data = request.json or {} + return J({"error": "Zone not found"}, 404) + data = await read_json(request) source_name = source.get("name") or f"Zone {id}" new_name = data.get("name") or f"{source_name} Copy" clone_id = zones.create( @@ -368,7 +320,7 @@ async def clone_zone(request, session, id): profiles.update(profile_id, profile) zdata = zones.read(clone_id) - return json.dumps({clone_id: zdata}), 201, {"Content-Type": "application/json"} + return J({clone_id: zdata}, 201) except Exception as e: import sys @@ -376,5 +328,4 @@ async def clone_zone(request, session, id): sys.print_exception(e) except Exception: pass - return json.dumps({"error": str(e)}), 400 - + return J({"error": str(e)}, 400) \ No newline at end of file diff --git a/src/fastapi_app.py b/src/fastapi_app.py index 620fab6..0ed290b 100644 --- a/src/fastapi_app.py +++ b/src/fastapi_app.py @@ -1,4 +1,4 @@ -"""FastAPI entrypoint; Microdot controllers run behind an ASGI bridge.""" +"""FastAPI application entrypoint.""" from __future__ import annotations @@ -6,23 +6,26 @@ import json import logging import os from contextlib import asynccontextmanager -from typing import Any, Optional +from typing import Optional -from fastapi import FastAPI, WebSocket, WebSocketDisconnect -from fastapi.responses import JSONResponse, PlainTextResponse +import asyncio + +from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect +from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse from app_factory import ( AppRuntime, audio_status_payload, - create_microdot_app, + dev_build_id, + dev_client_revision, live_reload_enabled, + mount_controller_routers, + mount_static_routes, ) -from microdot_asgi import MicrodotASGI +from http_session import SessionMiddleware from models.transport import get_current_bridge - _runtime: Optional[AppRuntime] = None -_microdot_app = None _test_mode = False @@ -38,6 +41,15 @@ def _bridge(): return get_current_bridge() +def _notify_audio_status_sse() -> None: + try: + from util.beat_status_broadcaster import request_status_broadcast + + request_status_broadcast() + except Exception: + pass + + @asynccontextmanager async def _lifespan(app: FastAPI): global _runtime @@ -51,14 +63,19 @@ async def _lifespan(app: FastAPI): await _runtime.shutdown() -def _create_fastapi() -> FastAPI: +def create_application(*, test_mode: bool = False) -> FastAPI: + global _test_mode + _test_mode = test_mode + api = FastAPI(title="LED Controller", lifespan=_lifespan) + api.add_middleware(SessionMiddleware) + + mount_controller_routers(api) + mount_static_routes(api, inject_live_reload=live_reload_enabled()) @api.get("/__dev/build-id", response_class=PlainTextResponse) async def dev_build_id_route(): - from app_factory import dev_build_id as current_build_id - - bid = current_build_id() + bid = dev_build_id() if not bid: return PlainTextResponse("", status_code=404) return PlainTextResponse( @@ -68,8 +85,6 @@ def _create_fastapi() -> FastAPI: @api.get("/__dev/client-rev", response_class=PlainTextResponse) async def dev_client_rev_route(): - from app_factory import dev_client_revision - rev = dev_client_revision() if not rev: return PlainTextResponse("", status_code=404) @@ -114,6 +129,7 @@ def _create_fastapi() -> FastAPI: device_override=str(body.get("device_override") or ""), device_select=device_select, ) + _notify_audio_status_sse() return {"ok": True, "status": _runtime.audio_detector.status()} except Exception as e: return JSONResponse({"ok": False, "error": str(e)}, status_code=500) @@ -146,6 +162,7 @@ def _create_fastapi() -> FastAPI: from util.audio_run_persist import write_audio_run_state write_audio_run_state(enabled=False) + _notify_audio_status_sse() return {"ok": True, "status": _runtime.audio_detector.status()} @api.post("/api/audio/reset") @@ -158,6 +175,7 @@ def _create_fastapi() -> FastAPI: {"ok": False, "error": "Audio detector is not running"}, status_code=409, ) + _notify_audio_status_sse() return {"ok": True, "status": _runtime.audio_detector.status()} @api.post("/api/audio/anchor-bar") @@ -170,6 +188,7 @@ def _create_fastapi() -> FastAPI: {"ok": False, "error": "Audio detector is not running"}, status_code=409, ) + _notify_audio_status_sse() return {"ok": True, "status": _runtime.audio_detector.status()} @api.get("/api/audio/status") @@ -178,9 +197,54 @@ def _create_fastapi() -> FastAPI: return JSONResponse({"error": "not ready"}, status_code=503) return {"status": audio_status_payload(_runtime.audio_detector, _runtime.settings)} + @api.get("/api/audio/events") + async def audio_events(request: Request): + if _runtime is None: + return JSONResponse({"error": "not ready"}, status_code=503) + from util.beat_status_broadcaster import ( + initial_sse_line, + register_sse_client, + unregister_sse_client, + ) + + async def stream(): + queue: asyncio.Queue[str] = asyncio.Queue(maxsize=8) + await register_sse_client(queue) + try: + yield await initial_sse_line() + while True: + if await request.is_disconnected(): + break + try: + line = await asyncio.wait_for(queue.get(), timeout=30.0) + except asyncio.TimeoutError: + yield ": keepalive\n\n" + continue + yield line + finally: + await unregister_sse_client(queue) + + return StreamingResponse( + stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + @api.websocket("/ws") async def ws_endpoint(websocket: WebSocket): + from util.device_status_broadcaster import ( + broadcast_device_tcp_snapshot_to, + register_device_status_ws, + unregister_device_status_ws, + ) + await websocket.accept() + await register_device_status_ws(websocket) + await broadcast_device_tcp_snapshot_to(websocket) bridge = _bridge() try: while True: @@ -208,44 +272,10 @@ def _create_fastapi() -> FastAPI: pass except Exception: pass + finally: + await unregister_device_status_ws(websocket) return api -class CombinedASGI: - """Route FastAPI-only paths first; delegate the rest to Microdot.""" - - _FASTAPI_PREFIXES = ("/api/", "/__dev/") - - def __init__(self, fastapi_app: FastAPI, microdot_asgi: MicrodotASGI): - self.fastapi_app = fastapi_app - self.microdot_asgi = microdot_asgi - - async def __call__(self, scope: dict, receive: Any, send: Any) -> None: - stype = scope.get("type") - if stype == "lifespan": - await self.fastapi_app(scope, receive, send) - return - if stype == "websocket": - if scope.get("path") == "/ws": - await self.fastapi_app(scope, receive, send) - return - await send({"type": "websocket.close", "code": 1000}) - return - if stype == "http": - path = scope.get("path") or "" - if path.startswith(self._FASTAPI_PREFIXES): - await self.fastapi_app(scope, receive, send) - return - await self.microdot_asgi(scope, receive, send) - - -def create_application(*, test_mode: bool = False) -> CombinedASGI: - global _microdot_app, _test_mode - _test_mode = test_mode - _microdot_app = create_microdot_app(inject_live_reload=live_reload_enabled()) - fastapi_app = _create_fastapi() - return CombinedASGI(fastapi_app, MicrodotASGI(_microdot_app)) - - app = create_application() diff --git a/src/http_responses.py b/src/http_responses.py new file mode 100644 index 0000000..94f28df --- /dev/null +++ b/src/http_responses.py @@ -0,0 +1,84 @@ +"""Response helpers for FastAPI controllers.""" + +from __future__ import annotations + +import os +from typing import Any + +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response + + +_SRC_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def J( + data: Any, + status_code: int = 200, + *, + headers: dict[str, str] | None = None, +) -> Response: + """JSON response (accepts dict or JSON string).""" + if isinstance(data, str): + return Response( + content=data, + status_code=status_code, + media_type="application/json", + headers=headers, + ) + return JSONResponse(content=data, status_code=status_code, headers=headers) + + +async def read_json(request) -> dict: + try: + body = await request.json() + except Exception: + return {} + return body if isinstance(body, dict) else {} + + +def send_file(relative_path: str) -> FileResponse: + path = os.path.join(_SRC_DIR, relative_path) + return FileResponse(path) + + +def send_html_file(relative_path: str, *, inject: str | None = None) -> HTMLResponse: + path = os.path.join(_SRC_DIR, relative_path) + with open(path, encoding="utf-8") as f: + html = f.read() + if inject and "" in html: + html = html.replace("", inject + "\n", 1) + return HTMLResponse(content=html) + + +def html_response(content: str, status_code: int = 200) -> HTMLResponse: + return HTMLResponse(content=content, status_code=status_code) + + +def plain(content: str, status_code: int = 200) -> Response: + return Response( + content=content, + status_code=status_code, + media_type="text/plain; charset=utf-8", + ) + + +def empty(status_code: int = 204) -> Response: + return Response(status_code=status_code) + + +def J_cookie( + data: Any, + status_code: int = 200, + *, + name: str, + value: str, + max_age: int | None = None, + path: str = "/", + samesite: str = "lax", +) -> Response: + resp = J(data, status_code) + kwargs: dict[str, Any] = {"path": path, "samesite": samesite} + if max_age is not None: + kwargs["max_age"] = max_age + resp.set_cookie(name, value, **kwargs) + return resp diff --git a/src/http_session.py b/src/http_session.py new file mode 100644 index 0000000..e498002 --- /dev/null +++ b/src/http_session.py @@ -0,0 +1,117 @@ +"""Signed-cookie sessions for the web UI.""" + +from __future__ import annotations + +import inspect +from typing import Any, Callable + +import jwt +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response + +from settings import get_settings + +_COOKIE = "session" +_ALGORITHM = "HS256" + + +class SessionDict(dict): + """Session mapping with ``save()`` / ``delete()`` for cookie persistence.""" + + def __init__(self, request: Request, data: dict | None = None): + super().__init__(data or {}) + self._request = request + self._save = False + self._delete = False + + def save(self) -> None: + self._save = True + self._delete = False + + def delete(self) -> None: + self._delete = True + self._save = False + + +def _secret_key() -> str: + return str( + get_settings().get( + "session_secret_key", + "led-controller-secret-key-change-in-production", + ) + ) + + +def encode_session(payload: dict) -> str: + return jwt.encode(payload, _secret_key(), algorithm=_ALGORITHM) + + +def decode_session(token: str) -> dict: + try: + data = jwt.decode(token, _secret_key(), algorithms=[_ALGORITHM]) + return data if isinstance(data, dict) else {} + except jwt.PyJWTError: + return {} + + +def get_session(request: Request) -> SessionDict: + session = getattr(request.state, "session", None) + if session is None: + cookie = request.cookies.get(_COOKIE) + data = decode_session(cookie) if cookie else {} + session = SessionDict(request, data) + request.state.session = session + return session + + +def with_session(handler: Callable) -> Callable: + sig = inspect.signature(handler) + public_params = [ + p + for name, p in sig.parameters.items() + if name not in ("request", "session") + ] + if "request" in sig.parameters: + req_param = sig.parameters["request"].replace(annotation=Request) + else: + req_param = inspect.Parameter( + "request", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Request + ) + wrapper_sig = inspect.Signature([req_param, *public_params]) + + async def wrapper(request: Request, *args: Any, **kwargs: Any): + session = get_session(request) + return await handler(request, session, *args, **kwargs) + + wrapper.__name__ = handler.__name__ + wrapper.__doc__ = handler.__doc__ + wrapper.__module__ = handler.__module__ + wrapper.__qualname__ = handler.__qualname__ + wrapper.__signature__ = wrapper_sig # type: ignore[attr-defined] + return wrapper + + +def _apply_session_cookie(request: Request, response: Response) -> Response: + session: SessionDict | None = getattr(request.state, "session", None) + if session is None: + return response + if session._delete: + response.delete_cookie(_COOKIE, path="/", httponly=True) + elif session._save: + response.set_cookie( + _COOKIE, + encode_session(dict(session)), + path="/", + httponly=True, + ) + return response + + +class SessionMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + cookie = request.cookies.get(_COOKIE) + data = decode_session(cookie) if cookie else {} + request.state.session = SessionDict(request, data) + response = await call_next(request) + return _apply_session_cookie(request, response) diff --git a/src/microdot_asgi.py b/src/microdot_asgi.py deleted file mode 100644 index 3512939..0000000 --- a/src/microdot_asgi.py +++ /dev/null @@ -1,84 +0,0 @@ -"""ASGI bridge for existing Microdot route handlers.""" - -from __future__ import annotations - -from typing import Any - -from microdot.microdot import Microdot, NoCaseDict, Request, Response - - -class MicrodotASGI: - """Dispatch HTTP requests to a :class:`Microdot` application.""" - - def __init__(self, microdot_app: Microdot): - self.app = microdot_app - - async def __call__(self, scope: dict, receive: Any, send: Any) -> None: - if scope.get("type") != "http": - return - - body = b"" - while True: - message = await receive() - if message["type"] != "http.request": - continue - body += message.get("body", b"") - if not message.get("more_body"): - break - - headers = NoCaseDict() - for key, value in scope.get("headers", ()): - headers[key.decode("latin-1")] = value.decode("latin-1") - - path = scope.get("path", "/") or "/" - query = scope.get("query_string", b"").decode("latin-1") - url = path + (f"?{query}" if query else "") - - client = scope.get("client") or ("127.0.0.1", 0) - req = Request( - self.app, - client, - scope.get("method", "GET"), - url, - "1.1", - headers, - body=body, - ) - - res = await self.app.dispatch_request(req) - if res is Response.already_handled: - return - await _send_microdot_response(res, send) - - -async def _send_microdot_response(res: Response, send: Any) -> None: - res.complete() - headers: list[tuple[bytes, bytes]] = [] - for header, value in res.headers.items(): - values = value if isinstance(value, list) else [value] - for item in values: - headers.append( - (header.lower().encode("latin-1"), str(item).encode("latin-1")) - ) - - body = res.body - if isinstance(body, str): - payload = body.encode() - elif isinstance(body, bytes): - payload = body - else: - parts: list[bytes] = [] - async for chunk in res.body_iter(): - if isinstance(chunk, str): - chunk = chunk.encode() - parts.append(chunk) - payload = b"".join(parts) - - await send( - { - "type": "http.response.start", - "status": res.status_code, - "headers": headers, - } - ) - await send({"type": "http.response.body", "body": payload}) diff --git a/src/models/sequence.py b/src/models/sequence.py index bd35eb0..5d82b8f 100644 --- a/src/models/sequence.py +++ b/src/models/sequence.py @@ -57,16 +57,6 @@ class Sequence(Model): if doc.get("advance_mode") != "beats": doc["advance_mode"] = "beats" changed = True - if "simulated_bpm" not in doc: - doc["simulated_bpm"] = 120 - changed = True - else: - try: - sb = int(float(doc["simulated_bpm"])) - doc["simulated_bpm"] = max(30, min(300, sb)) - except (TypeError, ValueError): - doc["simulated_bpm"] = 120 - changed = True if "sequence_transition" not in doc: doc["sequence_transition"] = 500 changed = True @@ -115,7 +105,6 @@ class Sequence(Model): "advance_mode": "beats", "steps": [], "step_duration_ms": 3000, - "simulated_bpm": 120, "sequence_transition": 500, "loop": True, } diff --git a/src/settings.py b/src/settings.py index 78995d3..f2dd772 100644 --- a/src/settings.py +++ b/src/settings.py @@ -67,6 +67,21 @@ class Settings(dict): self['bridge_serial_port'] = '' if 'bridge_serial_baudrate' not in self: self['bridge_serial_baudrate'] = 115200 + # Wi-Fi LED drivers: controller opens WebSocket to device (firmware serves /ws) + if 'wifi_driver_ws_port' not in self: + self['wifi_driver_ws_port'] = 80 + if 'wifi_driver_ws_path' not in self: + self['wifi_driver_ws_path'] = '/ws' + if 'wifi_driver_hello_interval_s' not in self: + self['wifi_driver_hello_interval_s'] = 10.0 + if 'wifi_driver_connect_retry_window_s' not in self: + self['wifi_driver_connect_retry_window_s'] = 120.0 + if 'wifi_driver_connect_stagger_max_s' not in self: + self['wifi_driver_connect_stagger_max_s'] = 2.5 + if 'wifi_driver_ws_open_timeout' not in self: + self['wifi_driver_ws_open_timeout'] = 45.0 + if 'wifi_driver_connect_retry_interval_s' not in self: + self['wifi_driver_connect_retry_interval_s'] = 2.0 # Zone UI global brightness (0–255); shared across browsers/devices. if 'global_brightness' not in self: self['global_brightness'] = 255 @@ -81,6 +96,13 @@ class Settings(dict): # Input gain for beat detection (percent, 0–200). if 'audio_input_volume' not in self: self['audio_input_volume'] = 100 + # BPM used for sequences when the audio detector is not running. + from util.bpm_limits import clamp_bpm + + if 'audio_simulated_bpm' not in self: + self['audio_simulated_bpm'] = int(clamp_bpm(120)) + else: + self['audio_simulated_bpm'] = int(clamp_bpm(self['audio_simulated_bpm'])) def save(self): try: diff --git a/src/static/app.js b/src/static/app.js index 3bc4de8..156a202 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -1382,7 +1382,7 @@ class LightingController { const presetNames = Object.keys(this.state.presets); if (presetNames.length === 0) { - presetsList.innerHTML = '

No presets found. Create one to get started.

'; + presetsList.innerHTML = '

No presets found.

'; } else { presetNames.forEach(presetName => { const preset = this.state.presets[presetName]; diff --git a/src/static/audio.js b/src/static/audio.js index 3669a89..0a2b5ee 100644 --- a/src/static/audio.js +++ b/src/static/audio.js @@ -1,7 +1,9 @@ (() => { - let pollTimer = null; + let beatEventSource = null; + let beatEventsReconnectTimer = null; let audioDetectorRunning = false; let lastBeatSeq = 0; + let lastSimulatedBeatTick = 0; /** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */ let prevZoneSequencePlaybackActive = false; /** @@ -14,26 +16,51 @@ let cachedBeatPhaseMs = 0; /** @type {{ device: string|number|null, device_override: string, device_select: string }} */ let cachedAudioRun = { device: null, device_override: "", device_select: "" }; + /** True after client starts sequence playback until server reports stop. */ + let clientSequenceUiActive = false; + /** Last pass readout (e.g. ``6/6``) kept visible briefly after playback ends. */ + let stickySequenceBeatReadout = ""; function el(id) { return document.getElementById(id); } + /** @param {Record} status */ + function resolveBeatReadoutText(status) { + let text = String((status && status.beat_readout) || "").trim(); + if (text) return text; + const seq = /** @type {Record|undefined} */ ( + status && status.sequence + ); + if (seq && seq.active) { + text = String(seq.beat_readout || "").trim(); + if (text) return text; + } + if (stickySequenceBeatReadout) { + return stickySequenceBeatReadout; + } + return ""; + } + /** @param {Record} status */ function updateBeatReadoutDisplays(status) { - const text = String((status && status.beat_readout) || "").trim(); + const text = resolveBeatReadoutText(status); for (const id of ["audio-top-beat-readout", "audio-modal-beat-readout"]) { const n = el(id); if (n) n.textContent = text; } } - function updateBpmDisplay(bpm) { + function updateBpmDisplay(bpm, simulated = false) { const text = Number.isFinite(bpm) ? bpm.toFixed(1) : "--"; for (const id of ["audio-bpm-value", "audio-top-bpm-value"]) { const node = el(id); if (node) node.textContent = text; } + for (const id of ["audio-top-indicator", "audio-modal-beat-sync"]) { + const node = el(id); + if (node) node.classList.toggle("audio-simulated", !!simulated); + } } /** Zone sequence playback (server); only when `active === true` is beat X/Y meaningful. */ @@ -44,6 +71,43 @@ return !!(seq && seq.active); } + /** Sequence playing or waiting on beat/downbeat before start (simulated beats still run). */ + function sequenceBeatUiActiveFromStatus(status) { + if (sequencePlaybackActiveFromStatus(status)) return true; + const pending = /** @type {Record|undefined} */ ( + status && status.sequence_pending + ); + return !!(pending && pending.pending); + } + + function resolveSeqUiActive(status) { + return sequenceBeatUiActiveFromStatus(status) || clientSequenceUiActive; + } + + /** @param {Record} status */ + function updateTopIndicatorFromStatus(status) { + const running = !!(status && status.running); + const bpmSimulated = !!(status && status.bpm_simulated); + const seqUiActive = resolveSeqUiActive(status); + const show = running || seqUiActive || bpmSimulated; + setTopBpmVisible(show); + if (!show || running) return; + const simBpm = + status && status.audio_simulated_bpm != null + ? Number(status.audio_simulated_bpm) + : getSimulatedBpmPercent(); + updateBpmDisplay(Number.isFinite(simBpm) ? simBpm : null, true); + } + + /** @param {Record} status */ + function shouldKeepStatusPolling(status) { + return ( + !!(status && status.running) || + resolveSeqUiActive(status) || + !!(status && status.bpm_simulated) + ); + } + function updateHitTypeDisplay(hitType, confidence) { const node = el("audio-hit-type-value"); if (!node) return; @@ -57,6 +121,8 @@ const readout = String((status && status.bar_phase_readout) || "").trim(); const phaseConf = Number((status && status.phase_confidence) || 0); const downbeat = !!(status && status.is_downbeat); + const simulated = !!(status && status.bpm_simulated); + const showPhase = !!(status && status.running) || simulated; let text = readout || "--"; if (readout && Number.isFinite(phaseConf) && phaseConf > 0) { text = `${text} (${Math.round(phaseConf * 100)}%)`; @@ -64,8 +130,8 @@ for (const id of ["audio-bar-phase-value", "audio-top-bar-phase"]) { const node = el(id); if (!node) continue; - node.textContent = status && status.running ? text : ""; - node.classList.toggle("is-downbeat", downbeat && !!readout); + node.textContent = showPhase ? text : ""; + node.classList.toggle("is-downbeat", downbeat && !!readout && showPhase); } } @@ -75,6 +141,50 @@ top.classList.toggle("audio-running", !!on); } + function closeBeatEvents() { + if (beatEventsReconnectTimer != null) { + clearTimeout(beatEventsReconnectTimer); + beatEventsReconnectTimer = null; + } + if (beatEventSource) { + beatEventSource.close(); + beatEventSource = null; + } + } + + function scheduleBeatEventsReconnect() { + if (beatEventsReconnectTimer != null) return; + beatEventsReconnectTimer = setTimeout(() => { + beatEventsReconnectTimer = null; + void fetchAudioStatusOnce() + .then((status) => { + applyAudioStatus(status); + if (shouldKeepStatusPolling(status)) ensureBeatEvents(); + }) + .catch((e) => { + console.warn("audio status reconnect fetch failed", e); + }); + }, 2000); + } + + function ensureBeatEvents() { + if (beatEventSource) return; + const es = new EventSource("/api/audio/events"); + beatEventSource = es; + es.onmessage = (ev) => { + try { + const data = JSON.parse(String(ev.data || "")); + if (data && data.status) applyAudioStatus(data.status); + } catch (e) { + console.warn("audio beat event parse failed", e); + } + }; + es.onerror = () => { + closeBeatEvents(); + scheduleBeatEventsReconnect(); + }; + } + function setResetDetectorEnabled(on) { const btn = el("audio-reset-btn"); if (btn) btn.disabled = !on; @@ -150,21 +260,25 @@ return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable; } - function flashBeatSyncButton(btn) { + function flashBeatSyncButton(btn, simulated = false) { if (!btn) return; - btn.classList.add("flash"); - setTimeout(() => btn.classList.remove("flash"), 90); + btn.classList.add(simulated ? "flash-simulated" : "flash"); + setTimeout(() => btn.classList.remove(simulated ? "flash-simulated" : "flash"), 90); } - function flashBeat() { + function flashBeat(simulated = false) { const top = el("audio-top-indicator"); const topSync = el("audio-top-beat-sync"); - if (topSync && top && top.classList.contains("audio-running")) { - flashBeatSyncButton(topSync); + if ( + topSync && + top && + (top.classList.contains("audio-running") || simulated) + ) { + flashBeatSyncButton(topSync, simulated); } const modalSync = el("audio-modal-beat-sync"); - if (modalSync && audioDetectorRunning) { - flashBeatSyncButton(modalSync); + if (modalSync && (audioDetectorRunning || simulated)) { + flashBeatSyncButton(modalSync, simulated); } } @@ -214,6 +328,38 @@ } } + const SIMULATED_BPM_MIN = 60; + const SIMULATED_BPM_MAX = 200; + + function clampSimulatedBpm(n) { + if (!Number.isFinite(n)) return 120; + return Math.min(SIMULATED_BPM_MAX, Math.max(SIMULATED_BPM_MIN, Math.round(n))); + } + + function clampLiveBpm(n) { + if (!Number.isFinite(n)) return null; + return Math.min(SIMULATED_BPM_MAX, Math.max(SIMULATED_BPM_MIN, n)); + } + + function getSimulatedBpmPercent() { + const inp = el("audio-simulated-bpm"); + if (!inp) return 120; + return clampSimulatedBpm(parseInt(String(inp.value).trim(), 10)); + } + + async function persistSimulatedBpm() { + const bpm = getSimulatedBpmPercent(); + try { + await fetch("/settings", { + method: "PUT", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ audio_simulated_bpm: bpm }), + }); + } catch (e) { + console.warn("simulated bpm save failed", e); + } + } + async function persistInputVolume() { const vol = getInputVolumePercent(); updateInputVolumeReadout(); @@ -228,11 +374,11 @@ } } - function scheduleBeatPhaseFire(seq, delayMs) { + function scheduleBeatPhaseFire(seq, delayMs, simulated = false) { let tid = null; const run = () => { if (tid != null) pendingBeatPhaseTimers.delete(tid); - flashBeat(); + flashBeat(simulated); try { window.dispatchEvent( new CustomEvent("ledControllerAudioBeat", { detail: { beatSeq: seq } }), @@ -252,23 +398,23 @@ /** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */ async function stopAudioOnly() { audioDetectorRunning = false; - setTopBpmVisible(false); setResetDetectorEnabled(false); clearBeatPhaseTimers(); - if (pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - } lastBeatSeq = 0; + lastSimulatedBeatTick = 0; prevZoneSequencePlaybackActive = false; headerBeatStickyIdleAfterSeq = false; updateBeatReadoutDisplays({}); updateInputLevelDisplay(0); + setTopBpmVisible(true); + updateBpmDisplay(getSimulatedBpmPercent(), true); try { await fetch("/api/audio/stop", { method: "POST" }); } catch (e) { console.warn("audio stop failed", e); } + ensureBeatEvents(); + await pollStatus(); } /** User-initiated stop (run intent cleared on server). */ @@ -276,11 +422,24 @@ await stopAudioOnly(); } + async function fetchAudioStatusOnce() { + const res = await fetch("/api/audio/status", { cache: "no-store" }); + const data = await res.json(); + return data?.status || {}; + } + async function pollStatus() { try { - const res = await fetch("/api/audio/status", { cache: "no-store" }); - const data = await res.json(); - const status = data?.status || {}; + const status = await fetchAudioStatusOnce(); + applyAudioStatus(status); + } catch (e) { + console.warn("audio status fetch failed", e); + } + } + + /** @param {Record} status */ + function applyAudioStatus(status) { + try { if (status.error && String(status.error).trim()) { const node = el("audio-hit-type-value"); if (node) { @@ -288,28 +447,41 @@ } updateBeatReadoutDisplays({}); audioDetectorRunning = !!status.running; - updateBpmDisplay(null); updateInputLevelDisplay(0); - setTopBpmVisible(!!status.running); + updateTopIndicatorFromStatus(status); setResetDetectorEnabled(!!status.running); - if (!status.running && pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - } + if (!shouldKeepStatusPolling(status)) closeBeatEvents(); return; } audioDetectorRunning = !!status.running; const zoneSeqActive = sequencePlaybackActiveFromStatus(status); - setTopBpmVisible(!!status.running || zoneSeqActive); + const seqUiActive = resolveSeqUiActive(status); + const bpmSimulated = !!status.bpm_simulated; + if (sequenceBeatUiActiveFromStatus(status)) { + clientSequenceUiActive = false; + } + updateTopIndicatorFromStatus(status); setResetDetectorEnabled(!!status.running); - updateSequenceSyncControls(zoneSeqActive); - updateBpmDisplay(status.bpm); + updateSequenceSyncControls(zoneSeqActive || clientSequenceUiActive); + const displayBpm = + bpmSimulated && status.audio_simulated_bpm != null + ? clampSimulatedBpm(Number(status.audio_simulated_bpm)) + : status.bpm != null + ? clampLiveBpm(Number(status.bpm)) + : null; + updateBpmDisplay( + Number.isFinite(displayBpm) ? displayBpm : null, + bpmSimulated, + ); updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence)); updateBarPhaseDisplay(status); updateInputLevelDisplay( status.running ? Number(status.input_level) : 0, ); applyServerAudioUiFields(status); + if (typeof window.setSequenceSwitchSimulatedMode === "function") { + window.setSequenceSwitchSimulatedMode(bpmSimulated); + } if (typeof window.applySequenceSwitchWaitFromServer === "function") { window.applySequenceSwitchWaitFromServer(status.sequence_switch_wait); } @@ -319,30 +491,56 @@ * `sequence` on each poll. */ const beatSeq = Number(status.beat_seq || 0); + const simTick = Number(status.simulated_beat_tick || 0); const endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive; const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive; prevZoneSequencePlaybackActive = zoneSeqActive; if (startedSeq) { headerBeatStickyIdleAfterSeq = false; + stickySequenceBeatReadout = ""; + if (bpmSimulated) { + lastSimulatedBeatTick = Math.max(0, simTick - 1); + } + } + if (zoneSeqActive) { + const liveReadout = String((status.beat_readout || "") || "").trim() + || String((status.sequence && status.sequence.beat_readout) || "").trim(); + if (liveReadout) { + stickySequenceBeatReadout = liveReadout; + } } if (endedSeq) { + clientSequenceUiActive = false; headerBeatStickyIdleAfterSeq = true; clearBeatPhaseTimers(); lastBeatSeq = beatSeq; + lastSimulatedBeatTick = simTick; + if (!stickySequenceBeatReadout) { + const tail = String((status.beat_readout || "") || "").trim(); + if (tail) stickySequenceBeatReadout = tail; + } } - if (!zoneSeqActive && headerBeatStickyIdleAfterSeq) { + if (bpmSimulated && simTick > lastSimulatedBeatTick) { + lastSimulatedBeatTick = simTick; + scheduleBeatPhaseFire(simTick, getBeatPhaseDelayMs(), true); + } else if (!zoneSeqActive && headerBeatStickyIdleAfterSeq) { if (beatSeq > lastBeatSeq) { lastBeatSeq = beatSeq; - scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs()); + scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs(), false); headerBeatStickyIdleAfterSeq = false; } - } else if (beatSeq > lastBeatSeq) { + } else if (!bpmSimulated && beatSeq > lastBeatSeq) { lastBeatSeq = beatSeq; - scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs()); + scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs(), false); } updateBeatReadoutDisplays(status); + if (shouldKeepStatusPolling(status)) { + ensureBeatEvents(); + } else { + closeBeatEvents(); + } } catch (e) { - console.warn("audio status poll failed", e); + console.warn("audio status apply failed", e); } } @@ -479,7 +677,7 @@ setSelectedDeviceId(selected); updateBpmDisplay(null); updateHitTypeDisplay("unknown", NaN); - pollTimer = setInterval(pollStatus, 250); + ensureBeatEvents(); await pollStatus(); } @@ -594,6 +792,17 @@ }); updateInputVolumeReadout(); } + const simBpmInp = el("audio-simulated-bpm"); + if (simBpmInp) { + const onSimBpmChange = () => { + void persistSimulatedBpm(); + if (!audioDetectorRunning) { + updateBpmDisplay(getSimulatedBpmPercent(), true); + } + }; + simBpmInp.addEventListener("input", onSimBpmChange); + simBpmInp.addEventListener("change", onSimBpmChange); + } for (const id of ["audio-top-beat-sync", "audio-modal-beat-sync"]) { const btn = el(id); @@ -614,22 +823,24 @@ }); } - async function resumePollingIfDetectorRunning() { + async function resumeBeatEventsIfNeeded() { try { - const res = await fetch("/api/audio/status", { cache: "no-store" }); - const data = await res.json(); - const status = data?.status || {}; + const status = await fetchAudioStatusOnce(); audioDetectorRunning = !!status.running; - if (status.running && !pollTimer) { - pollTimer = setInterval(pollStatus, 250); + updateTopIndicatorFromStatus(status); + if (shouldKeepStatusPolling(status)) { lastBeatSeq = Number(status.beat_seq || 0); + lastSimulatedBeatTick = Number(status.simulated_beat_tick || 0); prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status); - await pollStatus(); + applyAudioStatus(status); + ensureBeatEvents(); } else { - updateSequenceSyncControls(sequencePlaybackActiveFromStatus(status)); + updateSequenceSyncControls( + sequencePlaybackActiveFromStatus(status) || clientSequenceUiActive, + ); } } catch (e) { - console.warn("audio resume poll check failed", e); + console.warn("audio resume status check failed", e); } } @@ -662,6 +873,17 @@ updateInputVolumeReadout(); } } + const simBpmInp = el("audio-simulated-bpm"); + if ( + simBpmInp && + status.audio_simulated_bpm != null && + document.activeElement !== simBpmInp + ) { + const bpm = parseInt(String(status.audio_simulated_bpm), 10); + if (Number.isFinite(bpm)) { + simBpmInp.value = String(clampSimulatedBpm(bpm)); + } + } } async function loadServerAudioUiFields() { @@ -677,27 +899,38 @@ setSelectedDeviceId(saved); } updateInputLevelDisplay(status.running ? Number(status.input_level) : 0); + updateTopIndicatorFromStatus(status); + if (typeof window.setSequenceSwitchSimulatedMode === "function") { + window.setSequenceSwitchSimulatedMode(!!status.bpm_simulated); + } + if (!status.running) { + lastSimulatedBeatTick = Number(status.simulated_beat_tick || 0); + applyAudioStatus(status); + } } catch (e) { console.warn("audio status load failed", e); } } - /** Called from sequences.js when server playback starts/stops without audio polling. */ + /** Called from sequences.js when server playback starts/stops. */ window.ledControllerSequencePlaybackChanged = (active) => { + clientSequenceUiActive = !!active; updateSequenceSyncControls(!!active); if (active) { setTopBpmVisible(true); + if (!audioDetectorRunning) { + updateBpmDisplay(getSimulatedBpmPercent(), true); + } + ensureBeatEvents(); + void pollStatus(); return; } - if (!pollTimer) { - setTopBpmVisible(false); - updateSequenceSyncControls(false); - } + void pollStatus(); }; document.addEventListener("DOMContentLoaded", async () => { bind(); await loadServerAudioUiFields(); - await resumePollingIfDetectorRunning(); + await resumeBeatEventsIfNeeded(); }); })(); diff --git a/src/static/devices.js b/src/static/devices.js index 5767704..3caaeaf 100644 --- a/src/static/devices.js +++ b/src/static/devices.js @@ -1,6 +1,5 @@ // Device registry: name, id (storage key), type (led), transport (wifi|espnow), address -const HEX_BOX_COUNT = 12; /** Last TCP snapshot from WebSocket (so we can apply after async list render). */ let lastTcpSnapshotIps = null; @@ -290,75 +289,51 @@ function mergeTcpSnapshotPresence(ip, connected) { lastTcpSnapshotIps = Array.from(set); } -function makeHexAddressBoxes(container) { - if (!container || container.querySelector('.hex-addr-box')) return; - container.innerHTML = ''; - for (let i = 0; i < HEX_BOX_COUNT; i++) { - const input = document.createElement('input'); - input.type = 'text'; - input.className = 'hex-addr-box'; - input.maxLength = 1; - input.autocomplete = 'off'; - input.setAttribute('data-index', i); - input.setAttribute('inputmode', 'numeric'); - input.setAttribute('aria-label', `Hex digit ${i + 1}`); - input.addEventListener('input', (e) => { - const v = e.target.value.replace(/[^0-9a-fA-F]/g, ''); - e.target.value = v; - if (v && e.target.nextElementSibling && e.target.nextElementSibling.classList.contains('hex-addr-box')) { - e.target.nextElementSibling.focus(); - } - }); - input.addEventListener('keydown', (e) => { - if (e.key === 'Backspace' && !e.target.value && e.target.previousElementSibling) { - e.target.previousElementSibling.focus(); - } - }); - input.addEventListener('paste', (e) => { - e.preventDefault(); - const pasted = (e.clipboardData.getData('text') || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT); - const boxes = container.querySelectorAll('.hex-addr-box'); - for (let j = 0; j < pasted.length && j < boxes.length; j++) { - boxes[j].value = pasted[j]; - } - if (pasted.length > 0) { - const nextIdx = Math.min(pasted.length, boxes.length - 1); - boxes[nextIdx].focus(); - } - }); - container.appendChild(input); - } -} - -function setAddressToBoxes(container, addrStr) { - if (!container) return; - const s = (addrStr || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT); - const boxes = container.querySelectorAll('.hex-addr-box'); - boxes.forEach((b, i) => { - b.value = s[i] || ''; - }); -} - function applyTransportVisibility(transport) { const isWifi = transport === 'wifi'; const esp = document.getElementById('edit-device-address-espnow'); + const espDrv = document.getElementById('edit-device-espnow-driver-wrap'); const wifiWrap = document.getElementById('edit-device-address-wifi-wrap'); const drvWrap = document.getElementById('edit-device-wifi-driver-wrap'); if (esp) esp.hidden = isWifi; + if (espDrv) espDrv.hidden = isWifi; if (wifiWrap) wifiWrap.hidden = !isWifi; if (drvWrap) drvWrap.hidden = !isWifi; } +function getDriverConfigPushFields(transport, registryName) { + const push = {}; + if (registryName) push.name = registryName; + if (transport === 'wifi') { + const nl = document.getElementById('edit-device-wifi-num-leds'); + const co = document.getElementById('edit-device-wifi-color-order'); + const ws = document.getElementById('edit-device-wifi-startup-mode'); + if (nl && nl.value !== '') { + const n = parseInt(nl.value, 10); + if (!Number.isNaN(n) && n >= 1) push.num_leds = n; + } + if (co && co.value) push.color_order = co.value; + if (ws && ws.value) push.startup_mode = ws.value; + } else { + const nl = document.getElementById('edit-device-espnow-num-leds'); + const co = document.getElementById('edit-device-espnow-color-order'); + if (nl && nl.value !== '') { + const n = parseInt(nl.value, 10); + if (!Number.isNaN(n) && n >= 1) push.num_leds = n; + } + if (co && co.value) push.color_order = co.value; + } + return push; +} + function getAddressForPayload(transport) { if (transport === 'wifi') { const el = document.getElementById('edit-device-address-wifi'); const v = (el && el.value.trim()) || ''; return v || null; } - const boxEl = document.getElementById('edit-device-address-boxes'); - if (!boxEl) return null; - const boxes = boxEl.querySelectorAll('.hex-addr-box'); - const hex = Array.from(boxes).map((b) => b.value).join('').toLowerCase(); + const macEl = document.getElementById('edit-device-address-mac'); + const hex = normalizeMacInput(macEl && macEl.value); return hex || null; } @@ -395,30 +370,18 @@ function collectDeviceEditPayload() { } if (co && co.value) payload.wifi_color_order = co.value; if (ws && ws.value) payload.wifi_startup_mode = ws.value; + } else { + const nl = document.getElementById('edit-device-espnow-num-leds'); + const co = document.getElementById('edit-device-espnow-color-order'); + if (nl && nl.value !== '') { + const n = parseInt(nl.value, 10); + if (!Number.isNaN(n) && n >= 1) payload.num_leds = n; + } + if (co && co.value) payload.color_order = co.value; } return { devId, payload }; } -function refreshEditDeviceDebug() { - const ta = document.getElementById('edit-device-debug'); - if (!ta) return; - try { - const { devId, payload } = collectDeviceEditPayload(); - const loaded = window.__editDeviceLoadedSnapshot; - ta.value = JSON.stringify( - { - device_id: devId || null, - loaded_from_server: loaded != null ? loaded : null, - save_payload_preview: payload, - }, - null, - 2, - ); - } catch (e) { - ta.value = String(e); - } -} - async function loadDevicesModal() { const container = document.getElementById('devices-list-modal'); if (!container) return; @@ -508,7 +471,7 @@ function renderDevicesList(devices) { if (ids.length === 0) { const p = document.createElement('p'); p.className = 'muted-text'; - p.textContent = 'No devices yet. Wi-Fi drivers will appear here when they connect over TCP.'; + p.textContent = 'No devices yet.'; container.appendChild(p); return; } @@ -622,18 +585,13 @@ function renderDevicesList(devices) { } function openEditDeviceModal(devId, dev) { - try { - window.__editDeviceLoadedSnapshot = dev ? JSON.parse(JSON.stringify(dev)) : null; - } catch (e) { - window.__editDeviceLoadedSnapshot = dev || null; - } const modal = document.getElementById('edit-device-modal'); const idInput = document.getElementById('edit-device-id'); const storageLabel = document.getElementById('edit-device-storage-id'); const nameInput = document.getElementById('edit-device-name'); const typeSel = document.getElementById('edit-device-type'); const transportSel = document.getElementById('edit-device-transport'); - const addressBoxes = document.getElementById('edit-device-address-boxes'); + const macInput = document.getElementById('edit-device-address-mac'); const wifiInput = document.getElementById('edit-device-address-wifi'); if (!modal || !idInput) return; idInput.value = devId; @@ -643,8 +601,22 @@ function openEditDeviceModal(devId, dev) { const tr = (dev && dev.transport) || 'espnow'; if (transportSel) transportSel.value = tr; applyTransportVisibility(tr); - setAddressToBoxes(addressBoxes, tr === 'espnow' ? ((dev && dev.address) || '') : ''); + if (macInput) macInput.value = tr === 'espnow' ? ((dev && dev.address) || '') : ''; if (wifiInput) wifiInput.value = tr === 'wifi' ? ((dev && dev.address) || '') : ''; + const eLeds = document.getElementById('edit-device-espnow-num-leds'); + const eCo = document.getElementById('edit-device-espnow-color-order'); + if (eLeds) { + eLeds.value = + tr === 'espnow' && dev && dev.num_leds != null && dev.num_leds !== '' + ? String(dev.num_leds) + : ''; + } + if (eCo) { + const co = (dev && dev.color_order) || 'rgb'; + eCo.value = ['rgb', 'rbg', 'grb', 'gbr', 'brg', 'bgr'].includes(String(co).toLowerCase()) + ? String(co).toLowerCase() + : 'rgb'; + } const wName = document.getElementById('edit-device-wifi-driver-name'); const wLeds = document.getElementById('edit-device-wifi-num-leds'); const wCo = document.getElementById('edit-device-wifi-color-order'); @@ -689,35 +661,11 @@ function openEditDeviceModal(devId, dev) { obr.value = String(bv); if (obv) obv.textContent = String(bv); } - refreshEditDeviceDebug(); modal.classList.add('active'); } -async function updateDevice(devId, name, type, transport, address, wifiDriverFields, outputBrightness) { +async function updateDevice(devId, payload) { try { - const payload = { - name, - type: type || 'led', - transport: transport || 'espnow', - address, - }; - if (typeof outputBrightness === 'number') { - payload.output_brightness = Math.max(0, Math.min(255, Math.round(outputBrightness))); - } - if (transport === 'wifi' && wifiDriverFields && typeof wifiDriverFields === 'object') { - if (wifiDriverFields.wifi_driver_display_name != null) { - payload.wifi_driver_display_name = wifiDriverFields.wifi_driver_display_name; - } - if (wifiDriverFields.wifi_driver_num_leds != null) { - payload.wifi_driver_num_leds = wifiDriverFields.wifi_driver_num_leds; - } - if (wifiDriverFields.wifi_color_order != null) { - payload.wifi_color_order = wifiDriverFields.wifi_color_order; - } - if (wifiDriverFields.wifi_startup_mode != null) { - payload.wifi_startup_mode = wifiDriverFields.wifi_startup_mode; - } - } const res = await fetch(`/devices/${encodeURIComponent(devId)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -796,8 +744,6 @@ document.addEventListener('DOMContentLoaded', () => { refreshDevicesListQuiet(); }); - makeHexAddressBoxes(document.getElementById('edit-device-address-boxes')); - const devOutBr = document.getElementById('edit-device-output-brightness'); const devOutBrVal = document.getElementById('edit-device-output-brightness-value'); if (devOutBr && devOutBrVal) { @@ -810,7 +756,6 @@ document.addEventListener('DOMContentLoaded', () => { if (transportEdit) { transportEdit.addEventListener('change', () => { applyTransportVisibility(transportEdit.value); - refreshEditDeviceDebug(); }); } @@ -878,38 +823,11 @@ document.addEventListener('DOMContentLoaded', () => { } if (editForm) { - editForm.addEventListener('input', () => refreshEditDeviceDebug()); - editForm.addEventListener('change', () => refreshEditDeviceDebug()); editForm.addEventListener('submit', async (e) => { e.preventDefault(); const { devId, payload } = collectDeviceEditPayload(); if (!devId) return; - const transport = payload.transport || 'espnow'; - let wifiDriverFields = null; - if (transport === 'wifi') { - wifiDriverFields = {}; - if (payload.wifi_driver_display_name != null) { - wifiDriverFields.wifi_driver_display_name = payload.wifi_driver_display_name; - } - if (payload.wifi_driver_num_leds != null) { - wifiDriverFields.wifi_driver_num_leds = payload.wifi_driver_num_leds; - } - if (payload.wifi_color_order != null) { - wifiDriverFields.wifi_color_order = payload.wifi_color_order; - } - if (payload.wifi_startup_mode != null) { - wifiDriverFields.wifi_startup_mode = payload.wifi_startup_mode; - } - } - const ok = await updateDevice( - devId, - payload.name, - payload.type, - transport, - payload.address, - wifiDriverFields, - payload.output_brightness, - ); + const ok = await updateDevice(devId, payload); if (!ok) return; try { const brRes = await fetch(`/devices/${encodeURIComponent(devId)}/brightness`, { @@ -925,21 +843,12 @@ document.addEventListener('DOMContentLoaded', () => { } catch (e) { console.warn('brightness push failed', e); } - if (transport === 'wifi' && wifiDriverFields) { - const dn = document.getElementById('edit-device-wifi-driver-name'); - const nl = document.getElementById('edit-device-wifi-num-leds'); - const co = document.getElementById('edit-device-wifi-color-order'); - const ws = document.getElementById('edit-device-wifi-startup-mode'); - const pushRes = await pushWifiDriverConfig(devId, { - name: dn ? dn.value : '', - num_leds: nl ? nl.value : '', - color_order: co ? co.value : '', - startup_mode: ws ? ws.value : '', - }); - if (!pushRes.ok) return; - } + const pushRes = await pushWifiDriverConfig( + devId, + getDriverConfigPushFields(payload.transport || 'espnow', payload.name), + ); + if (!pushRes.ok) return; await loadDevicesModal(); - refreshEditDeviceDebug(); }); } if (editCloseBtn) { diff --git a/src/static/groups.js b/src/static/groups.js index bd28b31..a2ab3a1 100644 --- a/src/static/groups.js +++ b/src/static/groups.js @@ -117,7 +117,6 @@ function renderGroupDevicesEditor(containerEl, macRows, devicesMap) { } else { containerEl.appendChild(addWrap); } - refreshEditGroupDebug(); } function collectGroupEditPayload() { @@ -153,26 +152,6 @@ function collectGroupEditPayload() { return { gid, payload }; } -function refreshEditGroupDebug() { - const ta = document.getElementById('edit-group-debug'); - if (!ta) return; - try { - const { gid, payload } = collectGroupEditPayload(); - const loaded = window.__editGroupLoadedSnapshot; - ta.value = JSON.stringify( - { - group_id: gid || null, - loaded_from_server: loaded != null ? loaded : null, - save_payload_preview: payload, - }, - null, - 2, - ); - } catch (e) { - ta.value = String(e); - } -} - function syncGroupShareCheckboxFromDoc(g) { const cb = document.getElementById('edit-group-share-all-profiles'); if (!cb) return; @@ -243,11 +222,6 @@ async function openEditGroupModal(groupId, groupDoc) { } } g = g || {}; - try { - window.__editGroupLoadedSnapshot = JSON.parse(JSON.stringify(g)); - } catch (e) { - window.__editGroupLoadedSnapshot = g; - } if (idInput) idInput.value = groupId; if (nameInput) nameInput.value = g.name || ''; @@ -265,7 +239,6 @@ async function openEditGroupModal(groupId, groupDoc) { renderGroupDevicesEditor(editor, window.__editGroupDeviceRows, dm); loadWifiFieldsFromGroup(g); syncGroupShareCheckboxFromDoc(g); - refreshEditGroupDebug(); if (modal) modal.classList.add('active'); } @@ -290,7 +263,7 @@ function renderGroupsList(groups) { if (ids.length === 0) { const p = document.createElement('p'); p.className = 'muted-text'; - p.textContent = 'No groups yet. Create one to assign devices and Wi‑Fi defaults.'; + p.textContent = 'No groups yet.'; container.appendChild(p); return; } @@ -510,8 +483,6 @@ document.addEventListener('DOMContentLoaded', () => { } if (editForm) { - editForm.addEventListener('input', () => refreshEditGroupDebug()); - editForm.addEventListener('change', () => refreshEditGroupDebug()); editForm.addEventListener('submit', async (e) => { e.preventDefault(); const { gid, payload } = collectGroupEditPayload(); @@ -548,7 +519,6 @@ document.addEventListener('DOMContentLoaded', () => { /* ignore push errors after save */ } await loadGroupsModal(); - refreshEditGroupDebug(); } catch (err) { console.error(err); alert('Save failed'); diff --git a/src/static/patterns.js b/src/static/patterns.js index fffde7f..9afa1c3 100644 --- a/src/static/patterns.js +++ b/src/static/patterns.js @@ -404,13 +404,7 @@ document.addEventListener('DOMContentLoaded', () => { row.appendChild(label); - if (isFirmwareBuiltinPattern(patternName)) { - const note = document.createElement('span'); - note.className = 'muted-text'; - note.style.fontSize = '0.85em'; - note.textContent = 'Built-in (no OTA module)'; - row.appendChild(note); - } else { + if (!isFirmwareBuiltinPattern(patternName)) { const sendBtn = document.createElement('button'); sendBtn.className = 'btn btn-primary btn-small'; sendBtn.textContent = 'Send'; diff --git a/src/static/presets.js b/src/static/presets.js index 4679036..6e312eb 100644 --- a/src/static/presets.js +++ b/src/static/presets.js @@ -269,7 +269,6 @@ document.addEventListener('DOMContentLoaded', () => { const presetBackgroundInput = document.getElementById('preset-background-input'); const presetBackgroundButton = document.getElementById('preset-background-btn'); const presetManualModeInput = document.getElementById('preset-manual-mode-input'); - const presetManualModeHint = document.getElementById('preset-manual-mode-hint'); const presetManualModeLabel = document.getElementById('preset-manual-mode-label'); const presetManualBeatNWrap = document.getElementById('preset-manual-beat-n-wrap'); const presetManualBeatNInput = document.getElementById('preset-manual-beat-n-input'); @@ -447,16 +446,6 @@ document.addEventListener('DOMContentLoaded', () => { if (presetManualModeLabel) { presetManualModeLabel.style.opacity = ok ? '' : '0.55'; } - if (presetManualModeHint) { - if (!patternName || ok) { - presetManualModeHint.style.display = 'none'; - presetManualModeHint.textContent = ''; - } else { - presetManualModeHint.style.display = ''; - presetManualModeHint.textContent = - 'This pattern is a poor fit for manual mode or audio beat triggers; use auto mode for best results.'; - } - } if (!ok) { presetManualModeInput.checked = false; } @@ -521,12 +510,11 @@ document.addEventListener('DOMContentLoaded', () => { // Get max colors for current pattern const maxColors = getMaxColors(); - const maxColorsText = maxColors !== Infinity ? ` (max ${maxColors})` : ''; - + if (currentPresetColors.length === 0) { const empty = document.createElement('p'); empty.className = 'muted-text'; - empty.textContent = `No colors added. Use the color picker to add colors.${maxColorsText}`; + empty.textContent = 'No colours yet.'; presetColorsContainer.appendChild(empty); return; } @@ -536,7 +524,7 @@ document.addEventListener('DOMContentLoaded', () => { const info = document.createElement('p'); info.className = 'muted-text'; info.style.cssText = 'font-size: 0.85em; margin-bottom: 0.5rem; color: #ffa500;'; - info.textContent = `Maximum ${maxColors} color${maxColors !== 1 ? 's' : ''} reached for this pattern.`; + info.textContent = 'Maximum colours reached.'; presetColorsContainer.appendChild(info); } @@ -1443,7 +1431,7 @@ document.addEventListener('DOMContentLoaded', () => { const availableToAdd = presetNames.filter(presetId => !currentTabPresets.includes(presetId)); if (availableToAdd.length === 0) { - listContainer.innerHTML = '

No presets to add. All presets are already in this zone, or create a preset first.

'; + listContainer.innerHTML = '

No presets to add.

'; } else { availableToAdd.forEach(presetId => { const preset = allPresets[presetId]; @@ -2421,8 +2409,7 @@ const renderTabPresets = async (zoneId, options = {}) => { const empty = document.createElement('p'); empty.className = 'muted-text'; empty.style.gridColumn = '1 / -1'; - empty.textContent = - "No presets or sequences on this zone yet. Open Edit to add presets or sequences."; + empty.textContent = 'No presets on this zone yet.'; presetsList.appendChild(empty); } } else { diff --git a/src/static/sequences.js b/src/static/sequences.js index 1f78d8b..843a44e 100644 --- a/src/static/sequences.js +++ b/src/static/sequences.js @@ -1,4 +1,4 @@ -// Sequences: lanes (parallel preset chains); advance is always by audio beats or simulated BPM. +// Sequences: lanes (parallel preset chains); advance by audio beats or global simulated BPM. // Debug: in the browser console run setSequenceDebug(true) — session only, not persisted. /** @type {'beat'|'downbeat'} */ @@ -6,6 +6,8 @@ let sequenceSwitchWaitFor = 'beat'; let sequenceDebugEnabled = false; let sequenceSwitchSaveInFlight = false; +/** When true (simulated BPM / audio off), downbeat is disabled and switch is beat-only. */ +let sequenceSwitchSimulatedMode = false; async function loadSequenceSwitchWaitForFromServer() { try { @@ -49,32 +51,82 @@ function getSequenceSwitchWaitFor() { } async function setSequenceSwitchWaitFor(waitFor) { + if (sequenceSwitchSimulatedMode) { + sequenceSwitchWaitFor = 'beat'; + updateSequenceSwitchToggleUI(); + return; + } sequenceSwitchWaitFor = waitFor === 'downbeat' ? 'downbeat' : 'beat'; updateSequenceSwitchToggleUI(); await persistSequenceSwitchWaitFor(); } function updateSequenceSwitchToggleUI() { - const mode = getSequenceSwitchWaitFor(); + const mode = sequenceSwitchSimulatedMode ? 'beat' : getSequenceSwitchWaitFor(); const ariaLabels = { beat: 'Switch sequence on beat', downbeat: 'Switch sequence on downbeat', }; + document.documentElement.classList.toggle( + 'simulated-bpm-mode', + sequenceSwitchSimulatedMode, + ); + document.querySelectorAll('.seq-switch-toggle-wrap').forEach((wrap) => { + wrap.hidden = sequenceSwitchSimulatedMode; + wrap.setAttribute('aria-hidden', sequenceSwitchSimulatedMode ? 'true' : 'false'); + wrap.classList.toggle('nav-slide-toggle-wrap--downbeat', mode === 'downbeat'); + }); + if (sequenceSwitchSimulatedMode) { + return; + } document.querySelectorAll('.seq-switch-toggle').forEach((btn) => { + btn.disabled = false; + btn.removeAttribute('aria-disabled'); btn.setAttribute('aria-pressed', mode === 'beat' ? 'false' : 'true'); btn.setAttribute('aria-label', ariaLabels[mode] || ariaLabels.beat); btn.classList.toggle('seq-switch-toggle--downbeat', mode === 'downbeat'); - }); - document.querySelectorAll('.seq-switch-toggle-wrap').forEach((wrap) => { - wrap.classList.toggle('nav-slide-toggle-wrap--downbeat', mode === 'downbeat'); + btn.title = + mode === 'downbeat' + ? 'When starting a sequence: wait for downbeat' + : 'When starting a sequence: wait for beat'; }); } +/** @param {boolean} simulated */ +function setSequenceSwitchSimulatedMode(simulated) { + const next = !!simulated; + if (next === sequenceSwitchSimulatedMode) { + if (next) updateSequenceSwitchToggleUI(); + return; + } + sequenceSwitchSimulatedMode = next; + if (next) { + sequenceSwitchWaitFor = 'beat'; + updateSequenceSwitchToggleUI(); + void persistSequenceSwitchWaitFor(); + return; + } + void loadSequenceSwitchWaitForFromServer().then(() => updateSequenceSwitchToggleUI()); +} + +async function syncSequenceSwitchSimulatedModeFromStatus() { + try { + const res = await fetch('/api/audio/status', { cache: 'no-store' }); + const data = await res.json(); + const simulated = !!(data && data.status && data.status.bpm_simulated); + setSequenceSwitchSimulatedMode(simulated); + } catch { + setSequenceSwitchSimulatedMode(true); + } +} + async function initSequenceSwitchToggle() { + await syncSequenceSwitchSimulatedModeFromStatus(); await loadSequenceSwitchWaitForFromServer(); updateSequenceSwitchToggleUI(); document.querySelectorAll('.seq-switch-toggle').forEach((btn) => { btn.addEventListener('click', () => { + if (sequenceSwitchSimulatedMode) return; void setSequenceSwitchWaitFor(getSequenceSwitchWaitFor() === 'beat' ? 'downbeat' : 'beat'); }); }); @@ -82,7 +134,7 @@ async function initSequenceSwitchToggle() { /** Sync toggle when settings changed elsewhere (e.g. another tab via audio status poll). */ function applySequenceSwitchWaitFromServer(raw) { - if (sequenceSwitchSaveInFlight) return; + if (sequenceSwitchSaveInFlight || sequenceSwitchSimulatedMode) return; let mode = 'beat'; if (raw === 'downbeat') mode = 'downbeat'; else if (raw !== 'beat' && raw !== 'phrase') return; @@ -95,49 +147,6 @@ function seqDebugEnabled() { return sequenceDebugEnabled; } -/** @type {ReturnType | null} */ -let sequenceBpmPollTimer = null; - -function stopSequenceEditorBpmPoll() { - if (sequenceBpmPollTimer) { - clearInterval(sequenceBpmPollTimer); - sequenceBpmPollTimer = null; - } -} - -async function refreshSequenceEditorBpmDisplay() { - const live = document.getElementById('sequence-editor-bpm-live'); - const panel = document.getElementById('sequence-editor-beats-panel'); - if (!live || !panel) return; - try { - const res = await fetch('/api/audio/status', { headers: { Accept: 'application/json' } }); - const j = res.ok ? await res.json() : {}; - const st = j && j.status ? j.status : {}; - const running = !!st.running; - const bpmRaw = st.bpm; - const bpm = - typeof bpmRaw === 'number' && Number.isFinite(bpmRaw) - ? bpmRaw - : typeof bpmRaw === 'string' && bpmRaw.trim() - ? parseFloat(bpmRaw) - : NaN; - if (!running) { - live.textContent = - 'Audio detector is stopped — the sequence uses simulated beats at the BPM you set above.'; - return; - } - if (!Number.isFinite(bpm) || bpm <= 0) { - live.textContent = 'Audio detector running; BPM will appear after a few beats.'; - return; - } - const msPer = Math.round(60000 / bpm); - const rounded = Math.round(bpm * 10) / 10; - live.textContent = `Current estimate: ${rounded} BPM (~${msPer} ms per beat).`; - } catch (_) { - live.textContent = 'Could not read audio status.'; - } -} - /** @param {boolean} [clearSequenceTileSelection] When false, leaves the active highlight on sequence tiles (used when restarting playback so the click handler’s selection is not cleared). */ async function stopZoneSequencePlayback(clearSequenceTileSelection = true) { // Clear selection **before** awaiting fetch so overlapping stop() calls cannot finish out of @@ -157,6 +166,9 @@ async function stopZoneSequencePlayback(clearSequenceTileSelection = true) { } catch (e) { console.warn('Sequence stop:', e); } + if (typeof window.ledControllerSequencePlaybackChanged === 'function') { + window.ledControllerSequencePlaybackChanged(false); + } } function normalizeSequenceLanes(doc) { @@ -261,12 +273,6 @@ function renderLaneGroupCheckboxes(groupsMap, selectedIds, zoneGroupIds) { const wrap = document.createElement('div'); wrap.className = 'sequence-lane-groups-wrap'; wrap.style.cssText = 'margin-bottom:0.6rem;'; - const hint = document.createElement('div'); - hint.className = 'muted-text'; - hint.style.fontSize = '0.85em'; - hint.style.marginBottom = '0.35rem'; - hint.textContent = 'Only checked groups are used on this lane'; - wrap.appendChild(hint); const row = document.createElement('div'); row.className = 'sequence-lane-groups'; row.style.cssText = 'display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;'; @@ -343,13 +349,7 @@ async function resolveSequenceSendDeviceNames(zoneId, zoneDoc, groupIds) { async function requestBackendSequencePlay(sequenceId, zoneId, sequenceDoc) { // Do not call stop here: server start() already stops any prior run. A fire-and-forget // client stop can reorder after play and clear the new session (same tile re-click bug). - let bodyBpm; - if (sequenceDoc && typeof sequenceDoc === 'object' && sequenceDoc.simulated_bpm != null) { - const n = parseInt(String(sequenceDoc.simulated_bpm), 10); - if (Number.isFinite(n)) bodyBpm = Math.min(300, Math.max(30, n)); - } const body = { zone_id: String(zoneId) }; - if (bodyBpm != null) body.simulated_bpm = bodyBpm; const res = await fetch(`/sequences/${encodeURIComponent(sequenceId)}/play`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, @@ -360,7 +360,9 @@ async function requestBackendSequencePlay(sequenceId, zoneId, sequenceDoc) { const err = await res.json().catch(() => ({})); throw new Error((err && err.error) || res.statusText); } - console.log(Number(sequenceId)); + if (typeof window.ledControllerSequencePlaybackChanged === 'function') { + window.ledControllerSequencePlaybackChanged(true); + } } async function fetchSequencesMap() { @@ -606,7 +608,7 @@ async function refreshEditTabSequencesUi(zoneId) { const available = allIds.filter((id) => !onSet.has(String(id))); if (!available.length) { addEl.innerHTML = - 'No sequences to add. Create one in Sequences or all are already on this zone.'; + 'No sequences to add.'; } else { const wrap = document.createElement('div'); wrap.className = 'zone-devices-add profiles-actions'; @@ -908,20 +910,10 @@ function collectLanesFromEditor() { return { lanes, lanes_group_ids }; } -function syncSequenceBeatsPanel() { - const panel = document.getElementById('sequence-editor-beats-panel'); - stopSequenceEditorBpmPoll(); - if (panel) { - void refreshSequenceEditorBpmDisplay(); - sequenceBpmPollTimer = setInterval(() => void refreshSequenceEditorBpmDisplay(), 1500); - } -} - async function openSequenceEditor(sequenceId, existing) { sequenceEditorId = sequenceId != null && String(sequenceId).length ? String(sequenceId) : null; const modal = document.getElementById('sequence-editor-modal'); const nameInput = document.getElementById('sequence-editor-name'); - const simBpmInput = document.getElementById('sequence-editor-simulated-bpm'); const lanesHost = document.getElementById('sequence-editor-lanes'); if (!modal || !nameInput || !lanesHost) return; @@ -969,12 +961,6 @@ async function openSequenceEditor(sequenceId, existing) { doc = {}; } nameInput.value = doc.name || ''; - if (simBpmInput) { - const v = parseInt(String(doc.simulated_bpm != null ? doc.simulated_bpm : 120), 10); - const clamped = Number.isFinite(v) ? Math.min(300, Math.max(30, v)) : 120; - simBpmInput.value = String(clamped); - } - syncSequenceBeatsPanel(); const lanes = normalizeSequenceLanes(doc); lanesHost.innerHTML = ''; @@ -1012,7 +998,6 @@ function resolveZoneIdForPresetStripRefresh() { async function saveSequenceEditor() { const nameInput = document.getElementById('sequence-editor-name'); - const simBpmInput = document.getElementById('sequence-editor-simulated-bpm'); const { lanes, lanes_group_ids } = collectLanesFromEditor(); const idxs = []; lanes.forEach((l, i) => { @@ -1024,18 +1009,12 @@ async function saveSequenceEditor() { } const nonEmpty = idxs.map((i) => lanes[i].filter((s) => s && s.preset_id)); const nonEmptyLg = idxs.map((i) => (lanes_group_ids[i] ? [...lanes_group_ids[i]] : [])); - let simulated_bpm = 120; - if (simBpmInput && simBpmInput.value) { - const n = parseInt(String(simBpmInput.value).trim(), 10); - if (Number.isFinite(n)) simulated_bpm = Math.min(300, Math.max(30, n)); - } const payload = { name: nameInput ? nameInput.value.trim() : '', lanes: nonEmpty, lanes_group_ids: nonEmptyLg, group_ids: nonEmptyLg[0] ? [...nonEmptyLg[0]] : [], advance_mode: 'beats', - simulated_bpm, loop: true, steps: nonEmpty.length === 1 ? nonEmpty[0] : [], }; @@ -1094,7 +1073,6 @@ async function deleteCurrentSequence() { if (!res.ok) throw new Error('Delete failed'); const edModal = document.getElementById('sequence-editor-modal'); if (edModal) edModal.classList.remove('active'); - stopSequenceEditorBpmPoll(); sequenceEditorId = null; await loadSequencesModalList(); const zid = resolveZoneIdForPresetStripRefresh(); @@ -1138,7 +1116,7 @@ async function loadSequencesModalList() { }); listEl.innerHTML = ''; if (!ids.length) { - listEl.innerHTML = '

No sequences yet. Click Add.

'; + listEl.innerHTML = '

No sequences yet.

'; return; } ids.forEach((id) => { @@ -1163,6 +1141,7 @@ async function loadSequencesModalList() { } window.applySequenceSwitchWaitFromServer = applySequenceSwitchWaitFromServer; +window.setSequenceSwitchSimulatedMode = setSequenceSwitchSimulatedMode; window.stopZoneSequencePlayback = stopZoneSequencePlayback; /** @param {boolean} on */ window.setSequenceDebug = function setSequenceDebug(on) { @@ -1209,7 +1188,6 @@ document.addEventListener('DOMContentLoaded', () => { const edDel = document.getElementById('sequence-editor-delete-btn'); if (edClose) { edClose.addEventListener('click', () => { - stopSequenceEditorBpmPoll(); document.getElementById('sequence-editor-modal') && document.getElementById('sequence-editor-modal').classList.remove('active'); }); } diff --git a/src/static/style.css b/src/static/style.css index ca26011..75e12ef 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -24,21 +24,6 @@ body { border: 0; } -.hex-address-row { - display: flex; - flex-wrap: wrap; - gap: 0.2rem; - align-items: center; -} - -input.hex-addr-box { - width: 1.35rem; - padding: 0.25rem 0.1rem; - text-align: center; - font-family: ui-monospace, monospace; - font-size: 0.85rem; -} - .device-form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); @@ -46,13 +31,6 @@ input.hex-addr-box { align-items: end; } -.device-field-label { - display: block; - font-size: 0.8rem; - color: #aaa; - margin-bottom: 0.25rem; -} - .device-row-mac { font-size: 0.82em; color: #b0b0b0; @@ -291,6 +269,32 @@ header h1 { text-align: left; } +.audio-top-indicator.audio-simulated .audio-top-bpm-value, +#audio-modal-beat-sync.audio-simulated .audio-top-indicator-value { + color: #e6c200; +} + +.audio-beat-sync-btn.flash-simulated, +.audio-top-beat-sync.flash-simulated { + background-color: #5a4a00; + border-color: #e6c200; +} + +.audio-beat-sync-btn.flash-simulated .audio-top-indicator-value, +.audio-beat-sync-btn.flash-simulated .audio-top-indicator-label, +.audio-beat-sync-btn.flash-simulated .audio-top-beat-readout, +.audio-beat-sync-btn.flash-simulated .audio-top-beat-readout::before, +.audio-beat-sync-btn.flash-simulated .audio-top-bar-phase, +.audio-beat-sync-btn.flash-simulated .audio-top-bar-phase::before, +.audio-top-beat-sync.flash-simulated .audio-top-indicator-value, +.audio-top-beat-sync.flash-simulated .audio-top-indicator-label, +.audio-top-beat-sync.flash-simulated .audio-top-beat-readout, +.audio-top-beat-sync.flash-simulated .audio-top-beat-readout::before, +.audio-top-beat-sync.flash-simulated .audio-top-bar-phase, +.audio-top-beat-sync.flash-simulated .audio-top-bar-phase::before { + color: #fff9c4; +} + .audio-beat-sync-btn:disabled, .audio-top-beat-sync:disabled { cursor: default; @@ -1236,6 +1240,10 @@ body.preset-ui-run .edit-mode-only { flex-shrink: 0; } +html.simulated-bpm-mode .seq-switch-toggle-wrap { + display: none !important; +} + .nav-slide-toggle-side-label { font-size: 0.82rem; color: #888; @@ -2068,9 +2076,6 @@ body.preset-ui-run .edit-mode-only { #help-modal .modal-head { margin-bottom: 0.75rem; } -#help-modal .help-modal-intro { - margin-bottom: 0.25rem; -} #help-modal .help-tabs { display: flex; flex-wrap: wrap; @@ -2412,10 +2417,6 @@ body.preset-ui-run .edit-mode-only { display: none; } -#settings-modal .settings-led-tool-intro { - margin: 0 0 0.75rem; -} - #settings-modal .settings-led-tool-iframe { width: 100%; height: min(75vh, 720px); diff --git a/src/static/zones.js b/src/static/zones.js index 21a1989..6aea897 100644 --- a/src/static/zones.js +++ b/src/static/zones.js @@ -1023,7 +1023,7 @@ async function refreshEditTabPresetsUi(zoneId) { addEl.innerHTML = ""; if (availableToAdd.length === 0) { addEl.innerHTML = - 'No presets to add. All presets are already on this zone.'; + 'No presets to add.'; } else { const addWrap = document.createElement("div"); addWrap.className = "zone-devices-add profiles-actions"; diff --git a/src/templates/index.html b/src/templates/index.html index 5f85652..579c4a5 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -1,5 +1,5 @@ - + @@ -209,7 +209,6 @@ -

Assign drivers to a group, set Wi‑Fi defaults once per group, then attach groups to a zone for standalone presets (sequences use each lane’s groups only). By default new groups are shared across all profiles; tick “this profile only” to hide a group from other profiles.

@@ -369,17 +375,9 @@
-

- Each step runs for the number of beats you set on that step. - When the header Audio detector is running, real beats advance the sequence. - When it is stopped, the server uses simulated beats at the BPM below. -

- - -

-
@@ -433,19 +431,18 @@
-
-

Add a driver .py file and editor metadata (stored in the pattern database).

Readable parameter names

- +
@@ -575,7 +571,7 @@
- +
-

How to use the LED controller UI. Previews use the same styles as the live interface.

-
@@ -1114,7 +1108,10 @@
- Same sources as PulseAudio volume control. Pick a monitor source to follow playback. +
+
+ +
@@ -1124,7 +1121,6 @@ - Flashes on each beat (same as the header). Tap on a downbeat while a sequence is playing to sync (S).
@@ -1145,7 +1141,6 @@
- Gain before beat detection (saved on the controller). The bar shows live input level while running.