From b87382d2be569694093d47b29b416acfe9b2c144 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sun, 24 May 2026 01:44:28 +1200 Subject: [PATCH] feat(espnow): broadcast delivery with group-filtered routing Send presets and select on broadcast with groups; unicast only for per-device settings. V1 select as [preset_id, step?]. Sequence steps use beat counts; manual presets get select each beat, auto only on step change. Bridge downlink router, Pi envelope delivery, and tests. Co-authored-by: Cursor --- db/group.json | 2 +- db/preset.json | 2 +- db/sequence.json | 2 +- docs/espnow-architecture.md | 52 ++-- espnow-sender/msg.json | 30 ++- espnow-sender/src/downlink_router.py | 153 ++++++++++++ espnow-sender/src/espnow_wire.py | 11 + espnow-sender/src/main.py | 31 ++- espnow-sender/src/peer_table.py | 43 ++++ espnow-sender/src/settings.py | 2 +- espnow-sender/src/v1_wire.py | 81 +++++++ led-driver | 2 +- src/controllers/device.py | 104 ++++---- src/controllers/group.py | 14 +- src/controllers/preset.py | 93 ++++++-- src/main.py | 12 +- src/models/bridge_ws_client.py | 96 ++++---- src/models/transport.py | 96 +++++--- src/settings.py | 2 +- src/static/patterns.js | 47 ++-- src/static/presets.js | 88 ++++--- src/static/zones.js | 22 +- src/util/beat_driver_route.py | 125 ++++++---- src/util/bridge_envelope.py | 151 ++++++++++++ src/util/driver_delivery.py | 291 ++++++++++++++--------- src/util/espnow_message.py | 29 +-- src/util/espnow_registry.py | 124 ++++++++-- src/util/sequence_playback.py | 93 +++++--- src/util/v1_wire.py | 123 ++++++++++ tests/bridge_broadcast_test.py | 150 +++++------- tests/test_beat_driver_route_suppress.py | 58 ++++- tests/test_bridge_envelope.py | 173 ++++++++++++++ tests/test_bridge_ws_client.py | 36 +++ tests/test_endpoints_pytest.py | 4 +- tests/test_sequence_step_beats.py | 51 ++++ 35 files changed, 1802 insertions(+), 591 deletions(-) create mode 100644 espnow-sender/src/downlink_router.py create mode 100644 espnow-sender/src/peer_table.py create mode 100644 espnow-sender/src/v1_wire.py create mode 100644 src/util/bridge_envelope.py create mode 100644 src/util/v1_wire.py create mode 100644 tests/test_bridge_envelope.py create mode 100644 tests/test_bridge_ws_client.py create mode 100644 tests/test_sequence_step_beats.py diff --git a/db/group.json b/db/group.json index ca98339..7debd04 100644 --- a/db/group.json +++ b/db/group.json @@ -1 +1 @@ -{"1": {"name": "group1", "devices": ["e8f60a16fb00", "e8f60a170794"], "wifi_driver_display_name": "desk", "wifi_driver_num_leds": 59, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "output_brightness": 255}, "2": {"name": "group2", "devices": ["188b0e1560a8"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0}, "3": {"name": "group3", "devices": ["e8f60a16f288"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0}, "4": {"name": "group4", "devices": ["e8f60a16e79c"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0}, "5": {"name": "desk", "devices": ["188b0e1560a8", "e4b323c15c20"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": null}, "6": {"name": "winter top-left", "devices": ["a0b100000001"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "7": {"name": "winter top-centre", "devices": ["a0b100000002"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "8": {"name": "winter top-right", "devices": ["a0b100000003"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "9": {"name": "winter bottom-left", "devices": ["a0b100000004"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "10": {"name": "winter bottom-centre", "devices": ["a0b100000005"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "11": {"name": "winter bottom-right", "devices": ["a0b100000006"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "12": {"name": "winter top row", "devices": ["a0b100000001", "a0b100000002", "a0b100000003"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "13": {"name": "winter bottom row", "devices": ["a0b100000004", "a0b100000005", "a0b100000006"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "14": {"name": "winter left column", "devices": ["a0b100000001", "a0b100000004"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "15": {"name": "winter centre column", "devices": ["a0b100000002", "a0b100000005"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "16": {"name": "winter right column", "devices": ["a0b100000003", "a0b100000006"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "17": {"name": "winter grid (all)", "devices": ["a0b100000001", "a0b100000002", "a0b100000003", "a0b100000004", "a0b100000005", "a0b100000006"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "18": {"name": "test", "devices": ["588c81a37458", "983daead21ec", "e4b323c5ad6c", "e8f60a16dad0", "e8f60a16db34", "e8f60a16e57c", "e8f60a16eba4", "e8f60a16f050", "e8f60a16f288", "e8f60a16f640", "e8f60a16f94c", "e8f60a170794", "e8f60a1707c0", "e8f60a170874"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 19, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}} \ No newline at end of file +{"6": {"name": "winter top-left", "devices": ["a0b100000001"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "7": {"name": "winter top-centre", "devices": ["a0b100000002"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "8": {"name": "winter top-right", "devices": ["a0b100000003"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "9": {"name": "winter bottom-left", "devices": ["a0b100000004"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "10": {"name": "winter bottom-centre", "devices": ["a0b100000005"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "11": {"name": "winter bottom-right", "devices": ["a0b100000006"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "12": {"name": "winter top row", "devices": ["a0b100000001", "a0b100000002", "a0b100000003"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "13": {"name": "winter bottom row", "devices": ["a0b100000004", "a0b100000005", "a0b100000006"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "14": {"name": "winter left column", "devices": ["a0b100000001", "a0b100000004"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "15": {"name": "winter centre column", "devices": ["a0b100000002", "a0b100000005"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "16": {"name": "winter right column", "devices": ["a0b100000003", "a0b100000006"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "17": {"name": "winter grid (all)", "devices": ["a0b100000001", "a0b100000002", "a0b100000003", "a0b100000004", "a0b100000005", "a0b100000006"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "18": {"name": "test", "devices": ["e8f60a16dad0", "e8f60a1707c0"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}} \ No newline at end of file diff --git a/db/preset.json b/db/preset.json index 9dd88ae..ab14a8b 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": true, "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": true, "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": 110, "n2": 2, "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}, "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}, "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": true, "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": 110, "n2": 2, "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}, "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}, "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 diff --git a/db/sequence.json b/db/sequence.json index f99ee9a..66221ef 100644 --- a/db/sequence.json +++ b/db/sequence.json @@ -1 +1 @@ -{"1":{"name":"Pulse (manual)","profile_id":"1","group_ids":["5"],"lanes":[[{"preset_id":"6","beats":1}]],"lanes_group_ids":[["5"]],"advance_mode":"beats","steps":[{"preset_id":"6","beats":1}],"step_duration_ms":3000,"simulated_bpm":120,"sequence_transition":500,"loop":true},"2":{"name":"Off (1 beat)","profile_id":"1","group_ids":["5"],"lanes":[[{"preset_id":"2","beats":1}]],"lanes_group_ids":[["5"]],"advance_mode":"beats","steps":[{"preset_id":"2","beats":1}],"step_duration_ms":3000,"simulated_bpm":120,"sequence_transition":500,"loop":false},"3":{"name":"On (1 beat)","profile_id":"1","group_ids":["5"],"lanes":[[{"preset_id":"1","beats":1}]],"lanes_group_ids":[["5"]],"advance_mode":"beats","steps":[{"preset_id":"1","beats":1}],"step_duration_ms":3000,"simulated_bpm":120,"sequence_transition":500,"loop":false},"4":{"name":"Rainbow \u2192 transition \u2192 off","profile_id":"1","group_ids":["5"],"lanes":[[{"preset_id":"3","beats":4},{"preset_id":"4","beats":4},{"preset_id":"2","beats":2}]],"lanes_group_ids":[["5"]],"advance_mode":"beats","steps":[{"preset_id":"3","beats":4},{"preset_id":"4","beats":4},{"preset_id":"2","beats":2}],"step_duration_ms":3000,"simulated_bpm":100,"sequence_transition":500,"loop":true},"5":{"name":"Manual pulse + chase","profile_id":"1","group_ids":["5"],"lanes":[[{"preset_id":"6","beats":2},{"preset_id":"5","beats":2}]],"lanes_group_ids":[["5"]],"advance_mode":"beats","steps":[{"preset_id":"6","beats":2},{"preset_id":"5","beats":2}],"step_duration_ms":3000,"simulated_bpm":120,"sequence_transition":500,"loop":false},"6":{"name":"RGB solid cycle","profile_id":"1","group_ids":["5"],"lanes":[[{"preset_id":"11","beats":2},{"preset_id":"12","beats":2},{"preset_id":"9","beats":2},{"preset_id":"10","beats":2}]],"lanes_group_ids":[["5"]],"advance_mode":"beats","steps":[{"preset_id":"11","beats":2},{"preset_id":"12","beats":2},{"preset_id":"9","beats":2},{"preset_id":"10","beats":2}],"step_duration_ms":3000,"simulated_bpm":90,"sequence_transition":500,"loop":true},"7":{"name":"Winter trio","profile_id":"1","group_ids":["5"],"lanes":[[{"preset_id":"64","beats":8},{"preset_id":"65","beats":8},{"preset_id":"66","beats":8}]],"lanes_group_ids":[["5"]],"advance_mode":"beats","steps":[{"preset_id":"64","beats":8},{"preset_id":"65","beats":8},{"preset_id":"66","beats":8}],"step_duration_ms":3000,"simulated_bpm":80,"sequence_transition":500,"loop":true},"8":{"name":"Fast rainbow","profile_id":"1","group_ids":[],"lanes":[[{"preset_id":"3","beats":4}]],"lanes_group_ids":[[]],"advance_mode":"beats","steps":[{"preset_id":"3","beats":4}],"step_duration_ms":3000,"simulated_bpm":180,"sequence_transition":500,"loop":true},"9":{"name":"Off then on","profile_id":"1","group_ids":["5"],"lanes":[[{"preset_id":"2","beats":2},{"preset_id":"1","beats":4}]],"lanes_group_ids":[["5"]],"advance_mode":"beats","steps":[{"preset_id":"2","beats":2},{"preset_id":"1","beats":4}],"step_duration_ms":3000,"simulated_bpm":120,"sequence_transition":500,"loop":false},"10":{"name":"Twinkle + flame","profile_id":"1","group_ids":["5"],"lanes":[[{"preset_id":"41","beats":6}]],"lanes_group_ids":[["5"]],"advance_mode":"beats","steps":[{"preset_id":"41","beats":6}],"step_duration_ms":3000,"simulated_bpm":110,"sequence_transition":500,"loop":true},"11":{"name":"radiate chase","profile_id":"1","group_ids":["5"],"lanes":[[{"preset_id":"42","beats":12},{"preset_id":"5","beats":4}]],"lanes_group_ids":[["5"]],"advance_mode":"beats","steps":[{"preset_id":"42","beats":12},{"preset_id":"5","beats":4}],"step_duration_ms":3000,"simulated_bpm":120,"sequence_transition":500,"loop":true},"12":{"name":"Winter cell cascade","profile_id":"3","group_ids":["17"],"lanes":[[{"preset_id":"80","beats":6}],[{"preset_id":"85","beats":6}],[{"preset_id":"81","beats":6}],[{"preset_id":"82","beats":6}],[{"preset_id":"83","beats":6}],[{"preset_id":"84","beats":6}]],"lanes_group_ids":[["6"],["7"],["8"],["9"],["10"],["11"]],"advance_mode":"beats","steps":[{"preset_id":"80","beats":6},{"preset_id":"85","beats":6},{"preset_id":"81","beats":6},{"preset_id":"82","beats":6},{"preset_id":"83","beats":6},{"preset_id":"84","beats":6}],"step_duration_ms":3000,"simulated_bpm":85,"sequence_transition":500,"loop":true},"13":{"name":"Winter row waves","profile_id":"3","group_ids":["17"],"lanes":[[{"preset_id":"81","beats":8},{"preset_id":"80","beats":8}],[{"preset_id":"83","beats":8},{"preset_id":"82","beats":8}]],"lanes_group_ids":[["12"],["13"]],"advance_mode":"beats","steps":[{"preset_id":"81","beats":8},{"preset_id":"80","beats":8},{"preset_id":"83","beats":8},{"preset_id":"82","beats":8}],"step_duration_ms":3000,"simulated_bpm":80,"sequence_transition":500,"loop":true},"14":{"name":"Winter column chase","profile_id":"3","group_ids":["17"],"lanes":[[{"preset_id":"87","beats":12}],[{"preset_id":"79","beats":12}],[{"preset_id":"84","beats":12}]],"lanes_group_ids":[["14"],["15"],["16"]],"advance_mode":"beats","steps":[{"preset_id":"87","beats":12},{"preset_id":"79","beats":12},{"preset_id":"84","beats":12}],"step_duration_ms":3000,"simulated_bpm":95,"sequence_transition":500,"loop":true},"15":{"name":"Winter full blizzard","profile_id":"3","group_ids":["17"],"lanes":[[{"preset_id":"81","beats":16}]],"lanes_group_ids":[["17"]],"advance_mode":"beats","steps":[{"preset_id":"81","beats":16}],"step_duration_ms":3000,"simulated_bpm":75,"sequence_transition":500,"loop":true},"16":{"name":"Winter showcase","profile_id":"3","group_ids":["17"],"lanes":[[{"preset_id":"80","beats":8},{"preset_id":"81","beats":8},{"preset_id":"82","beats":8},{"preset_id":"83","beats":8},{"preset_id":"84","beats":8},{"preset_id":"79","beats":8}]],"lanes_group_ids":[["17"]],"advance_mode":"beats","steps":[{"preset_id":"80","beats":8},{"preset_id":"81","beats":8},{"preset_id":"82","beats":8},{"preset_id":"83","beats":8},{"preset_id":"84","beats":8},{"preset_id":"79","beats":8}],"step_duration_ms":3000,"simulated_bpm":72,"sequence_transition":500,"loop":true}} \ No newline at end of file +{"1": {"name": "Pulse (manual)", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "6", "beats": 1}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "6", "beats": 1}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": true}, "2": {"name": "Off (1 beat)", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "2", "beats": 1}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "2", "beats": 1}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "3": {"name": "On (1 beat)", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "1", "beats": 1}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "1", "beats": 1}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "4": {"name": "Rainbow \u2192 transition \u2192 off", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "3", "beats": 4}, {"preset_id": "4", "beats": 4}, {"preset_id": "2", "beats": 2}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "3", "beats": 4}, {"preset_id": "4", "beats": 4}, {"preset_id": "2", "beats": 2}], "step_duration_ms": 3000, "simulated_bpm": 100, "sequence_transition": 500, "loop": true}, "5": {"name": "Manual pulse + chase", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "6", "beats": 2}, {"preset_id": "5", "beats": 2}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "6", "beats": 2}, {"preset_id": "5", "beats": 2}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "6": {"name": "RGB solid cycle", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "11", "beats": 2}, {"preset_id": "12", "beats": 2}, {"preset_id": "9", "beats": 2}, {"preset_id": "10", "beats": 2}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "11", "beats": 2}, {"preset_id": "12", "beats": 2}, {"preset_id": "9", "beats": 2}, {"preset_id": "10", "beats": 2}], "step_duration_ms": 3000, "simulated_bpm": 90, "sequence_transition": 500, "loop": true}, "7": {"name": "Winter trio", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "64", "beats": 8}, {"preset_id": "65", "beats": 8}, {"preset_id": "66", "beats": 8}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "64", "beats": 8}, {"preset_id": "65", "beats": 8}, {"preset_id": "66", "beats": 8}], "step_duration_ms": 3000, "simulated_bpm": 80, "sequence_transition": 500, "loop": true}, "8": {"name": "Fast rainbow", "profile_id": "1", "group_ids": [], "lanes": [[{"preset_id": "3", "beats": 4}]], "lanes_group_ids": [[]], "advance_mode": "beats", "steps": [{"preset_id": "3", "beats": 4}], "step_duration_ms": 3000, "simulated_bpm": 180, "sequence_transition": 500, "loop": true}, "9": {"name": "Off then on", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "2", "beats": 2}, {"preset_id": "1", "beats": 4}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "2", "beats": 2}, {"preset_id": "1", "beats": 4}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "10": {"name": "Twinkle + flame", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "41", "beats": 6}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "41", "beats": 6}], "step_duration_ms": 3000, "simulated_bpm": 110, "sequence_transition": 500, "loop": true}, "11": {"name": "radiate chase", "profile_id": "1", "group_ids": ["18"], "lanes": [[{"preset_id": "42", "beats": 12}, {"preset_id": "5", "beats": 4}]], "lanes_group_ids": [["18"]], "advance_mode": "beats", "steps": [{"preset_id": "42", "beats": 12}, {"preset_id": "5", "beats": 4}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": true}, "12": {"name": "Winter cell cascade", "profile_id": "3", "group_ids": ["17"], "lanes": [[{"preset_id": "80", "beats": 6}], [{"preset_id": "85", "beats": 6}], [{"preset_id": "81", "beats": 6}], [{"preset_id": "82", "beats": 6}], [{"preset_id": "83", "beats": 6}], [{"preset_id": "84", "beats": 6}]], "lanes_group_ids": [["6"], ["7"], ["8"], ["9"], ["10"], ["11"]], "advance_mode": "beats", "steps": [{"preset_id": "80", "beats": 6}, {"preset_id": "85", "beats": 6}, {"preset_id": "81", "beats": 6}, {"preset_id": "82", "beats": 6}, {"preset_id": "83", "beats": 6}, {"preset_id": "84", "beats": 6}], "step_duration_ms": 3000, "simulated_bpm": 85, "sequence_transition": 500, "loop": true}, "13": {"name": "Winter row waves", "profile_id": "3", "group_ids": ["17"], "lanes": [[{"preset_id": "81", "beats": 8}, {"preset_id": "80", "beats": 8}], [{"preset_id": "83", "beats": 8}, {"preset_id": "82", "beats": 8}]], "lanes_group_ids": [["12"], ["13"]], "advance_mode": "beats", "steps": [{"preset_id": "81", "beats": 8}, {"preset_id": "80", "beats": 8}, {"preset_id": "83", "beats": 8}, {"preset_id": "82", "beats": 8}], "step_duration_ms": 3000, "simulated_bpm": 80, "sequence_transition": 500, "loop": true}, "14": {"name": "Winter column chase", "profile_id": "3", "group_ids": ["17"], "lanes": [[{"preset_id": "87", "beats": 12}], [{"preset_id": "79", "beats": 12}], [{"preset_id": "84", "beats": 12}]], "lanes_group_ids": [["14"], ["15"], ["16"]], "advance_mode": "beats", "steps": [{"preset_id": "87", "beats": 12}, {"preset_id": "79", "beats": 12}, {"preset_id": "84", "beats": 12}], "step_duration_ms": 3000, "simulated_bpm": 95, "sequence_transition": 500, "loop": true}, "15": {"name": "Winter full blizzard", "profile_id": "3", "group_ids": ["17"], "lanes": [[{"preset_id": "81", "beats": 16}]], "lanes_group_ids": [["17"]], "advance_mode": "beats", "steps": [{"preset_id": "81", "beats": 16}], "step_duration_ms": 3000, "simulated_bpm": 75, "sequence_transition": 500, "loop": true}, "16": {"name": "Winter showcase", "profile_id": "3", "group_ids": ["17"], "lanes": [[{"preset_id": "80", "beats": 8}, {"preset_id": "81", "beats": 8}, {"preset_id": "82", "beats": 8}, {"preset_id": "83", "beats": 8}, {"preset_id": "84", "beats": 8}, {"preset_id": "79", "beats": 8}]], "lanes_group_ids": [["17"]], "advance_mode": "beats", "steps": [{"preset_id": "80", "beats": 8}, {"preset_id": "81", "beats": 8}, {"preset_id": "82", "beats": 8}, {"preset_id": "83", "beats": 8}, {"preset_id": "84", "beats": 8}, {"preset_id": "79", "beats": 8}], "step_duration_ms": 3000, "simulated_bpm": 72, "sequence_transition": 500, "loop": true}} \ No newline at end of file diff --git a/docs/espnow-architecture.md b/docs/espnow-architecture.md index cf7d62b..b3e5bc8 100644 --- a/docs/espnow-architecture.md +++ b/docs/espnow-architecture.md @@ -2,7 +2,7 @@ This document describes how **led-controller**, the **bridge ESP32**, and **led-driver** devices work together. Wire-level byte layouts are in [espnow-binary-protocol.md](espnow-binary-protocol.md). -**On the wire:** binary only (no JSON) for ESP-NOW and Pi↔bridge WebSocket. The Pi web UI and `db/*.json` still use JSON internally. +**Pi ↔ bridge WebSocket:** v1 **devices envelope** (JSON) — see [espnow-sender/msg.json](../espnow-sender/msg.json). **ESP-NOW over the air:** JSON driver payloads (≤250 bytes) or legacy binary (`0x4C` wire). The Pi web UI and `db/*.json` use JSON internally. ## System overview @@ -10,8 +10,8 @@ This document describes how **led-controller**, the **bridge ESP32**, and **led- | Component | Firmware / path | Role | |-----------|-----------------|------| -| **led-controller** | Raspberry Pi, `src/` | Web app; WebSocket **client** to bridge; device registry; builds binary commands | -| **Bridge** | [`espnow-sender/`](../espnow-sender/) | WebSocket **server** `/ws`; relays binary ↔ ESP-NOW; max **20** peers (LRU) | +| **led-controller** | Raspberry Pi, `src/` | Web app; WebSocket **client** to bridge (auto-reconnect); device registry; builds devices envelope | +| **Bridge** | [`espnow-sender/`](../espnow-sender/) | WebSocket **server** `/ws`; routes envelope per MAC; max **20** peers (LRU) | | **led-driver** | [`led-driver/`](../led-driver/) submodule | Boot **ANNOUNCE** broadcast; applies **GROUPS**, **CMD**, **GROUP_CMD** | Configure the Pi in `settings.json`: @@ -34,25 +34,47 @@ Connect the Pi to the **bridge access point** (SSID = bridge `name` in `/setting 1. Driver powers on and sends **ANNOUNCE** to broadcast MAC `ff:ff:ff:ff:ff:ff`. 2. Bridge receives it and forwards a **WebSocket uplink** frame to the Pi (peer MAC + packet). 3. Pi **upserts** the device in `db/device.json` (key = 12-char hex MAC). -4. Pi scans `db/group.json` and builds a **GROUPS** packet. -5. Pi sends **GROUPS** unicast to that MAC via the bridge. -6. Driver stores group ids in RAM for **GROUP_CMD** filtering. +4. Pi scans `db/group.json` and sends a **groups** envelope (`set_groups: true`) unicast to that MAC. +5. Driver stores group ids in RAM (`device_groups`) for filtering. +6. Pi bridge client **reconnects** automatically if the WebSocket drops (2 s backoff). If the Pi or bridge is not up yet, the driver re-sends **ANNOUNCE** periodically until **GROUPS** arrives. --- +## Devices envelope (Pi → bridge) + +```json +{ + "v": "1", + "dv": { + "ff:ff:ff:ff:ff:ff": { + "p": { "2": { "p": "on", "c": ["#FFFFFF"], "a": true } }, + "s": ["2", 0], + "g": ["5", "18"], + "sg": false, + "sv": true + } + } +} +``` + +Short wire names (long names still accepted on receive): `dv`=devices, `p`=presets, `s`=select (`["preset_id", step?]` — no device name), `g`=groups, `sg`=set_groups, `sv`=save, `df`=default; preset fields `p/c/d/b/a/bg/n1…`. + +| `set_groups` | Destination | Bridge | Driver | +|--------------|-------------|--------|--------| +| `true` | any | Unicast only (expand `ff:ff:…` to all known peers) | `groups_replace`, then apply body | +| `false` | `ff:ff:ff:ff:ff:ff` | ESP-NOW air broadcast | Apply only if device is in `groups` | +| `false` | specific MAC | Unicast | Same group filter | + +Legacy raw payloads (binary wire or plain v1 JSON without `devices`) are still **broadcast** by the bridge. + ## Sending presets and commands -![Command delivery flow](images/espnow/command-flow.svg) - -1. UI or API triggers a send (e.g. `POST /presets/send`). -2. Pi builds one or more **CMD** packets (v2 binary envelope, chunked to ≤250 bytes). -3. Each packet is wrapped in a **WebSocket downlink** frame (unicast MAC or broadcast). -4. Bridge forwards on ESP-NOW. -5. Driver parses and applies (presets, select, brightness, device_config, etc.). - -For a **group**, Pi may send **GROUP_CMD** on broadcast once per chunk; only drivers that belong to that group apply the payload. +1. UI or API triggers a send (e.g. `POST /presets/push`). +2. Pi builds a **devices envelope** (or legacy binary) and sends it on the bridge WebSocket. +3. Bridge routes each MAC entry to unicast or ESP-NOW broadcast per `set_groups`. +4. Driver `process_data` applies presets, select (`[preset_id, step?]`; legacy name map still accepted), brightness, etc. --- diff --git a/espnow-sender/msg.json b/espnow-sender/msg.json index df9ceae..d28ca24 100644 --- a/espnow-sender/msg.json +++ b/espnow-sender/msg.json @@ -1,24 +1,22 @@ { "v": "1", - "devices": { + "dv": { "ff:ff:ff:ff:ff:ff": { - "presets": { + "p": { "preset_id": { - "pattern": "on", - "colors": ["#FF0000"], - "delay": 100, - "brightness": 255, - "auto": true + "p": "on", + "c": ["#FF0000"], + "d": 100, + "b": 255, + "a": true } - }, - "select": { - "preset": "preset_id", - "step": 0 - }, - "save": true, - "default": "preset_id", - "b": 255 + }, + "s": ["preset_id", 0], + "sv": true, + "df": "preset_id", + "b": 255, + "g": ["5", "18"], + "sg": true } } } - \ No newline at end of file diff --git a/espnow-sender/src/downlink_router.py b/espnow-sender/src/downlink_router.py new file mode 100644 index 0000000..af66b21 --- /dev/null +++ b/espnow-sender/src/downlink_router.py @@ -0,0 +1,153 @@ +"""Route Pi v1 devices envelope to ESP-NOW unicast or broadcast.""" + +import json +import utime + +from espnow_wire import BROADCAST_MAC +from util import parse_mac +from v1_wire import ( + ENV_DEVICES, + K_PRESETS, + K_SELECT, + K_SET_GROUPS, + _WIRE_KEYS, + envelope_devices, + normalize_body, +) + +MAX_ESPNOW_PAYLOAD = 250 +_CHUNK_DELAY_MS = 50 + + +def is_devices_envelope(raw): + if not raw: + return False + if isinstance(raw, str): + raw = raw.encode("utf-8") + if raw[0:1] != b"{": + return False + try: + data = json.loads(raw) + except (ValueError, TypeError): + return False + return ( + isinstance(data, dict) + and data.get("v") == "1" + and envelope_devices(data) is not None + ) + + +def _encode_v1(fields): + out = {"v": "1"} + short = normalize_body(fields) + for key in _WIRE_KEYS: + if key in short: + out[key] = short[key] + return json.dumps(out, separators=(",", ":")).encode("utf-8") + + +def _payload_len(fields): + return len(_encode_v1(fields)) + + +def payloads_from_body(body): + """One or more ESP-NOW payloads (each <= MAX_ESPNOW_PAYLOAD).""" + if not isinstance(body, dict): + raise ValueError("device body must be object") + short = normalize_body(body) + if _payload_len(short) <= MAX_ESPNOW_PAYLOAD: + return [_encode_v1(short)] + + parts = [] + meta = {} + for key in _WIRE_KEYS: + if key in short and key not in (K_PRESETS, K_SELECT): + meta[key] = short[key] + + presets = short.get(K_PRESETS) + select = short.get(K_SELECT) + + if presets and isinstance(presets, dict): + one = dict(meta) + one[K_PRESETS] = presets + if _payload_len(one) <= MAX_ESPNOW_PAYLOAD: + parts.append(_encode_v1(one)) + else: + for pid, pdata in presets.items(): + chunk = dict(meta) + chunk[K_PRESETS] = {pid: pdata} + if _payload_len(chunk) > MAX_ESPNOW_PAYLOAD: + raise ValueError( + "single preset too large (%d B)" % _payload_len(chunk) + ) + parts.append(_encode_v1(chunk)) + + if select is not None: + sel = dict(meta) + sel.pop(K_SAVE, None) + sel[K_SELECT] = select + if _payload_len(sel) > MAX_ESPNOW_PAYLOAD: + raise ValueError("select too large (%d B)" % _payload_len(sel)) + parts.append(_encode_v1(sel)) + + if not parts: + raise ValueError("driver payload too large (%d B)" % _payload_len(short)) + return parts + + +async def ensure_peer(esp, mac_bytes): + try: + esp.add_peer(mac_bytes) + except Exception: + pass + + +async def send_unicast(esp, peer_table, mac_bytes, payload): + await ensure_peer(esp, mac_bytes) + peer_table.touch(mac_bytes) + await esp.asend(mac_bytes, payload) + + +async def _send_payloads(esp, peer_table, dest, payloads): + for i, payload in enumerate(payloads): + if peer_table.is_broadcast_mac(dest): + await ensure_peer(esp, BROADCAST_MAC) + await esp.asend(BROADCAST_MAC, payload) + else: + await send_unicast(esp, peer_table, dest, payload) + if i + 1 < len(payloads): + utime.sleep_ms(_CHUNK_DELAY_MS) + + +async def send_device_body(esp, peer_table, mac_str, body): + dest = parse_mac(mac_str) + payloads = payloads_from_body(body) + set_groups = bool(body.get("set_groups") or body.get("sg")) + + if set_groups: + if peer_table.is_broadcast_mac(dest): + targets = peer_table.peers() + if not targets: + print("set_groups: no peers yet") + return + for peer in targets: + await _send_payloads(esp, peer_table, peer, payloads) + else: + await _send_payloads(esp, peer_table, dest, payloads) + return + + await _send_payloads(esp, peer_table, dest, payloads) + + +async def route_envelope(esp, peer_table, raw): + if isinstance(raw, str): + raw = raw.encode("utf-8") + data = json.loads(raw) + devices = envelope_devices(data) or {} + for mac_str, body in devices.items(): + try: + await send_device_body(esp, peer_table, mac_str, body) + except ValueError as err: + print("downlink skip", mac_str, err) + except Exception as err: + print("downlink err", mac_str, err) diff --git a/espnow-sender/src/espnow_wire.py b/espnow-sender/src/espnow_wire.py index 36b545a..a1b4c98 100644 --- a/espnow-sender/src/espnow_wire.py +++ b/espnow-sender/src/espnow_wire.py @@ -22,6 +22,17 @@ def pack_ws_uplink(peer, espnow_packet): return bytes([0]) + peer + espnow_packet +def pack_ws_downlink(espnow_packet, peer_mac=None, broadcast=False): + flags = WS_FLAG_BROADCAST if broadcast else 0 + if broadcast: + peer = BROADCAST_MAC + else: + if peer_mac is None or len(peer_mac) != 6: + raise ValueError("peer MAC required for unicast downlink") + peer = peer_mac + return bytes([flags]) + peer + espnow_packet + + def parse_bridge_channel(pkt): if len(pkt) >= 3 and pkt[0] == WIRE_MAGIC and pkt[1] == MSG_BRIDGE_CH: return pkt[2] diff --git a/espnow-sender/src/main.py b/espnow-sender/src/main.py index a81516c..ba8710c 100644 --- a/espnow-sender/src/main.py +++ b/espnow-sender/src/main.py @@ -1,4 +1,5 @@ import asyncio +import json from microdot import Microdot from microdot.websocket import WebSocketError, with_websocket @@ -8,6 +9,8 @@ import machine import network from settings import Settings from espnow_wire import BROADCAST_MAC, pack_ws_uplink +from peer_table import PeerTable, load_max_peers +from downlink_router import is_devices_envelope, route_envelope wdt = machine.WDT(timeout=10000) wdt.feed() @@ -16,11 +19,11 @@ print(settings) app = Microdot() -ch = settings.get("wifi_channel", 6) +ch = settings.get("wifi_channel", 1) try: ch = max(1, min(11, int(ch))) except (TypeError, ValueError): - ch = 6 + ch = 1 ap_if = network.WLAN(network.AP_IF) ap_if.active(True) @@ -39,9 +42,23 @@ esp = aioespnow.AIOESPNow() esp.active(True) esp.add_peer(BROADCAST_MAC) +peer_table = PeerTable(load_max_peers()) clients = set() +def _note_uplink_peer(host, msg): + if host and len(host) == 6: + name = None + if msg and msg[0:1] == b"{": + try: + data = json.loads(msg) + if isinstance(data, dict): + name = data.get("name") + except (ValueError, TypeError): + pass + peer_table.touch(host, name) + + @app.route("/ws") @with_websocket async def ws(request, ws): @@ -55,8 +72,15 @@ async def ws(request, ws): break if not raw: break + if isinstance(raw, str): + raw = raw.encode("utf-8") try: - await esp.asend(BROADCAST_MAC, raw) + if is_devices_envelope(raw): + await route_envelope(esp, peer_table, raw) + else: + await esp.asend(BROADCAST_MAC, raw) + print(raw) + print("ws tx", len(raw), "B") except Exception as err: print(err) break @@ -68,6 +92,7 @@ async def _espnow_receive_loop(): async for host, msg in esp: if not host or not msg: continue + _note_uplink_peer(host, msg) print("espnow rx", len(msg), "B") frame = pack_ws_uplink(host, msg) dead = [] diff --git a/espnow-sender/src/peer_table.py b/espnow-sender/src/peer_table.py new file mode 100644 index 0000000..5eabc6e --- /dev/null +++ b/espnow-sender/src/peer_table.py @@ -0,0 +1,43 @@ +"""LRU table of ESP-NOW peer MACs seen on uplink.""" + +from espnow_wire import BROADCAST_MAC + +try: + from settings import Settings +except ImportError: + Settings = None + + +class PeerTable: + def __init__(self, max_peers=20): + self._max = max(1, int(max_peers)) + self._order = [] + self._names = {} + + def touch(self, mac_bytes, name=None): + if not mac_bytes or len(mac_bytes) != 6: + return + if mac_bytes in self._order: + self._order.remove(mac_bytes) + elif len(self._order) >= self._max: + old = self._order.pop(0) + self._names.pop(old, None) + self._order.append(mac_bytes) + if name: + self._names[mac_bytes] = str(name) + + def peers(self): + return list(self._order) + + def is_broadcast_mac(self, mac_bytes): + return mac_bytes == BROADCAST_MAC + + +def load_max_peers(): + if Settings is None: + return 20 + try: + s = Settings() + return int(s.get("max_peers", 20)) + except Exception: + return 20 diff --git a/espnow-sender/src/settings.py b/espnow-sender/src/settings.py index 5f0e363..b508776 100644 --- a/espnow-sender/src/settings.py +++ b/espnow-sender/src/settings.py @@ -40,7 +40,7 @@ class Settings(dict): def set_defaults(self): mac = _sta_mac_hex() self["name"] = "bridge-" + mac - self["wifi_channel"] = 6 + self["wifi_channel"] = 1 self["ap_password"] = "" self["ap_ip"] = "192.168.4.1" self["ws_port"] = 80 diff --git a/espnow-sender/src/v1_wire.py b/espnow-sender/src/v1_wire.py new file mode 100644 index 0000000..03bda57 --- /dev/null +++ b/espnow-sender/src/v1_wire.py @@ -0,0 +1,81 @@ +"""Short v1 wire keys (MicroPython).""" + +K_PRESETS = "p" +K_SELECT = "s" +K_GROUPS = "g" +K_SET_GROUPS = "sg" +K_SAVE = "sv" +K_DEFAULT = "df" +K_DEVICE_CONFIG = "dc" +K_CLEAR_PRESETS = "cp" +K_MANIFEST = "mf" +ENV_DEVICES = "dv" + +_LONG_TO_SHORT = { + "presets": K_PRESETS, + "select": K_SELECT, + "groups": K_GROUPS, + "set_groups": K_SET_GROUPS, + "save": K_SAVE, + "default": K_DEFAULT, + "device_config": K_DEVICE_CONFIG, + "clear_presets": K_CLEAR_PRESETS, + "manifest": K_MANIFEST, +} + +def _normalize_select(val): + if isinstance(val, list): + return val + if isinstance(val, str) and val.strip(): + return [val.strip()] + if isinstance(val, dict) and "preset" in val: + out = [val["preset"]] + if "step" in val: + out.append(val["step"]) + return out + if isinstance(val, dict) and len(val) == 1: + one = next(iter(val.values())) + if isinstance(one, list): + return one + return val + + +_WIRE_KEYS = ( + K_PRESETS, + K_SELECT, + K_SAVE, + K_DEFAULT, + "b", + K_GROUPS, + K_SET_GROUPS, + K_DEVICE_CONFIG, + K_CLEAR_PRESETS, + K_MANIFEST, +) + + +def normalize_body(body): + """Long or short body → short keys for encoding.""" + if not isinstance(body, dict): + return body + out = {} + for long_key, short_key in _LONG_TO_SHORT.items(): + if long_key in body: + val = body[long_key] + if long_key == "select": + val = _normalize_select(val) + out[short_key] = val + elif short_key in body: + out[short_key] = body[short_key] + if "b" in body: + out["b"] = body["b"] + return out + + +def envelope_devices(data): + if not isinstance(data, dict): + return None + devs = data.get("devices") + if devs is None: + devs = data.get(ENV_DEVICES) + return devs if isinstance(devs, dict) else None diff --git a/led-driver b/led-driver index 1fdb2c9..a97f6c7 160000 --- a/led-driver +++ b/led-driver @@ -1 +1 @@ -Subproject commit 1fdb2c944111b9127cc4f6cb9674d3b3c52cec42 +Subproject commit a97f6c7c2cd91a6abd0d00db655e056b817d0f41 diff --git a/src/controllers/device.py b/src/controllers/device.py index 8f8674e..53d8aa6 100644 --- a/src/controllers/device.py +++ b/src/controllers/device.py @@ -11,7 +11,6 @@ from models.transport import get_current_sender from settings import get_settings from util.brightness_combine import effective_brightness_for_mac from util.driver_patterns import driver_patterns_dir -from util.binary_driver_messages import v1_dict_to_cmd_packet from util.espnow_message import build_message import asyncio import json @@ -142,13 +141,24 @@ def _http_post_pattern_source(ip, filename, code_text, reload_patterns=True, tim return b" 2" in first_line -async def _identify_send_off_after_delay(sender, dev_id, name): +async def _identify_send_off_after_delay(sender, dev_id): try: await asyncio.sleep(IDENTIFY_OFF_DELAY_S) - pkt = v1_dict_to_cmd_packet( - {"v": "1", "select": {name: ["off"]}}, + await sender.send( + {"v": "1", "select": ["off"]}, + addr=dev_id, ) - await sender.send(pkt, addr=dev_id) + except Exception: + pass + + +async def _identify_send_off_after_delay_broadcast(sender, group_ids=None): + try: + await asyncio.sleep(IDENTIFY_OFF_DELAY_S) + body = {"v": "1", "select": ["off"]} + if group_ids: + body["groups"] = [str(g) for g in group_ids if str(g).strip()] + await sender.send(body) except Exception: pass @@ -166,36 +176,35 @@ async def send_identify_to_device(dev_id: str) -> tuple[int, str]: sender = get_current_sender() if not sender: return 503, "Transport not configured" - name = str(dev.get("name") or "").strip() - if not name: - return 400, "Device must have a name to identify" - try: - pkt = v1_dict_to_cmd_packet( + ok = await sender.send( { "v": "1", "presets": {_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)}, - "select": {name: [_IDENTIFY_PRESET_KEY]}, - } + "select": [_IDENTIFY_PRESET_KEY], + }, + addr=dev_id, ) - ok = await sender.send(pkt, addr=dev_id) if not ok: return 503, "Send failed" asyncio.create_task( - _identify_send_off_after_delay(sender, dev_id, name) + _identify_send_off_after_delay(sender, dev_id) ) except Exception as e: return 503, str(e) return 200, "" -async def send_identify_to_group_devices(macs: list[str]) -> tuple[int, list[dict]]: +async def send_identify_to_group_devices( + macs: list[str], + *, + group_ids: list[str] | None = None, +) -> tuple[int, list[dict]]: """ - Identify every listed registry MAC in one delivery round: merged ``select`` and a single - ESP-NOW split envelope when multiple peers share the serial bridge (avoids per-device - ``SerialSender`` lock serialisation). Wi-Fi peers are sent in parallel as in - ``deliver_json_messages``. + Identify all drivers in ``group_ids`` via broadcast; members filter on ``groups``. + + ``macs`` is only used for the API ``sent`` count (group member list), not for addressing. """ from util.driver_delivery import deliver_json_messages @@ -204,40 +213,37 @@ async def send_identify_to_group_devices(macs: list[str]) -> tuple[int, list[dic if not sender: return 0, [{"mac": "*", "error": "Transport not configured"}] - merged_select: dict[str, list[str]] = {} - valid_macs: list[str] = [] - for dev_id in macs: - dev = devices.read(dev_id) - if not dev: - errors.append({"mac": dev_id, "error": "Device not found"}) - continue - name = str(dev.get("name") or "").strip() - if not name: - errors.append({"mac": dev_id, "error": "Device must have a name to identify"}) - continue - merged_select[name] = [_IDENTIFY_PRESET_KEY] - valid_macs.append(dev_id) - - if not merged_select: - return 0, errors + body = { + "v": "1", + "presets": {_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)}, + "select": [_IDENTIFY_PRESET_KEY], + } + gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()] + if gids: + body["groups"] = gids try: - msg = _compact_v1_json( - presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)}, - select=merged_select, + deliveries, _chunks = await deliver_json_messages( + sender, + [json.dumps(body, separators=(",", ":"))], + None, + devices, + delay_s=0, ) - await deliver_json_messages(sender, [msg], valid_macs, devices, delay_s=0) except Exception as e: return 0, errors + [{"mac": "*", "error": str(e)}] - for dev_id in valid_macs: - dev = devices.read(dev_id) or {} - name = str(dev.get("name") or "").strip() - asyncio.create_task( - _identify_send_off_after_delay(sender, dev_id, name) - ) + if deliveries < 1: + return 0, errors + [{"mac": "*", "error": "Send failed"}] - return len(valid_macs), errors + asyncio.create_task(_identify_send_off_after_delay_broadcast(sender, gids)) + + seen: set[str] = set() + for raw in macs: + m = normalize_mac(str(raw)) + if m and m not in seen: + seen.add(m) + return len(seen), errors @controller.get("") @@ -448,14 +454,13 @@ async def push_device_output_brightness(request, id): zone_brightness=zb, ) - pkt = v1_dict_to_cmd_packet({"v": "1", "b": b_val, "save": True}) sender = get_current_sender() if not sender: return json.dumps({"error": "Transport not configured"}), 503, { "Content-Type": "application/json", } try: - ok = await sender.send(pkt, addr=id) + ok = await sender.send({"v": "1", "b": b_val, "save": True}, addr=id) if not ok: return json.dumps({"error": "Send failed"}), 503, { "Content-Type": "application/json", @@ -509,8 +514,7 @@ async def push_driver_config(request, id): "error": "Provide at least one of name, num_leds, color_order, startup_mode" } ), 400, {"Content-Type": "application/json"} - pkt = v1_dict_to_cmd_packet({"v": "1", "device_config": dc, "save": True}) - ok = await sender.send(pkt, addr=id) + ok = await sender.send({"v": "1", "device_config": dc, "save": True}, addr=id) if not ok: return json.dumps({"error": "Send failed"}), 503, { "Content-Type": "application/json", diff --git a/src/controllers/group.py b/src/controllers/group.py index 9198e4d..9e70e33 100644 --- a/src/controllers/group.py +++ b/src/controllers/group.py @@ -4,7 +4,6 @@ import asyncio from models.group import Group from models.device import Device from models.transport import get_current_sender -from util.binary_driver_messages import v1_dict_to_cmd_packet from util.espnow_registry import push_groups_for_group_devices from settings import get_settings from util.brightness_combine import effective_brightness_for_mac @@ -221,7 +220,7 @@ async def push_group_driver_config(request, session, id): sender = get_current_sender() if not sender: return json.dumps({"error": "Transport not configured"}), 503 - pkt = v1_dict_to_cmd_packet({"v": "1", "device_config": dc, "save": True}) + body = {"v": "1", "device_config": dc, "save": True} for mac in mac_list: m = str(mac).strip().lower().replace(":", "").replace("-", "") if len(m) != 12: @@ -231,7 +230,7 @@ async def push_group_driver_config(request, session, id): errors.append({"mac": m, "error": "not in registry"}) continue try: - if await sender.send(pkt, addr=m): + if await sender.send(body, addr=m): sent += 1 else: errors.append({"mac": m, "error": "send failed"}) @@ -271,13 +270,10 @@ async def push_group_output_brightness(request, session, id): m, zone_brightness=None, ) - pkt = v1_dict_to_cmd_packet( - {"v": "1", "b": b_val, "save": True}, - ) if not sender: return m, False, "transport not configured" try: - ok = await sender.send(pkt, addr=m) + ok = await sender.send({"v": "1", "b": b_val, "save": True}, addr=m) return m, bool(ok), None if ok else "send failed" except Exception as e: return m, False, str(e) @@ -342,7 +338,9 @@ async def identify_group_devices(request, session, id): {"message": "identify group done", "sent": 0, "errors": errors} ), 200, {"Content-Type": "application/json"} - sent, batch_errors = await send_identify_to_group_devices(normalized) + sent, batch_errors = await send_identify_to_group_devices( + normalized, group_ids=[str(id)] + ) errors.extend(batch_errors) return json.dumps( diff --git a/src/controllers/preset.py b/src/controllers/preset.py index 39861ee..205bb15 100644 --- a/src/controllers/preset.py +++ b/src/controllers/preset.py @@ -5,9 +5,11 @@ from models.profile import Profile from models.pallet import Palette from models.device import Device, normalize_mac from models.transport import get_current_sender -from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device +from util.driver_delivery import ( + build_preset_json_chunks, + deliver_json_messages, +) from util.espnow_message import build_message, build_preset_dict -from util.binary_driver_messages import build_preset_cmd_chunks from util.profile_bundle import export_preset_bundle, import_preset_bundle import json @@ -228,7 +230,7 @@ async def send_presets(request, session): send_delay_s = 0.1 total_presets = len(presets_by_name) - chunk_messages = build_preset_cmd_chunks( + chunk_messages = build_preset_json_chunks( presets_by_name, save=save_flag, default=str(default_id) if default_id is not None else None, @@ -249,20 +251,51 @@ async def send_presets(request, session): dm = normalize_mac(str(destination_mac)) target_list = [dm] if dm else None + group_ids = data.get("group_ids") or data.get("groups") + if isinstance(group_ids, list): + group_ids = [str(g).strip() for g in group_ids if str(g).strip()] + else: + group_ids = None + + unicast = bool(data.get("unicast")) or bool(destination_mac) + try: - if target_list: - deliveries = await deliver_preset_broadcast_then_per_device( - sender, - chunk_messages, - target_list, - Device(), - str(default_id) if default_id is not None else None, - delay_s=send_delay_s, - ) + if unicast and target_list: + deliveries = 0 + for msg in chunk_messages: + d, _chunks = await deliver_json_messages( + sender, + [msg], + target_list, + Device(), + delay_s=send_delay_s, + unicast=True, + ) + deliveries += d + if default_id is not None: + def_msg = json.dumps( + {"v": "1", "default": str(default_id), "save": True}, + separators=(",", ":"), + ) + d, _chunks = await deliver_json_messages( + sender, + [def_msg], + target_list, + Device(), + delay_s=send_delay_s, + unicast=True, + ) + deliveries += d else: + wire_messages = [] + for msg in chunk_messages: + body = json.loads(msg) + if group_ids: + body["groups"] = list(group_ids) + wire_messages.append(json.dumps(body, separators=(",", ":"))) deliveries, _chunks = await deliver_json_messages( sender, - chunk_messages, + wire_messages, None, Device(), delay_s=send_delay_s, @@ -315,13 +348,32 @@ async def push_driver_messages(request, session): return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'} messages = [] - for item in seq: - if isinstance(item, dict): - messages.append(json.dumps(item)) - elif isinstance(item, str): - messages.append(item) - else: + i = 0 + while i < len(seq): + item = seq[i] + if not isinstance(item, dict): + if isinstance(item, str): + messages.append(item) + i += 1 + continue return json.dumps({"error": "sequence items must be objects or strings"}), 400, {'Content-Type': 'application/json'} + nxt = seq[i + 1] if i + 1 < len(seq) else None + if ( + isinstance(nxt, dict) + and "presets" in item + and "select" not in item + and "select" in nxt + and "presets" not in nxt + ): + combined = dict(item) + combined["select"] = nxt["select"] + combined_str = json.dumps(combined, separators=(",", ":")) + if len(combined_str.encode("utf-8")) <= 248: + messages.append(combined_str) + i += 2 + continue + messages.append(json.dumps(item, separators=(",", ":"))) + i += 1 delay_s = data.get("delay_s", 0.05) try: @@ -329,6 +381,8 @@ async def push_driver_messages(request, session): except (TypeError, ValueError): delay_s = 0.05 + unicast = bool(data.get("unicast")) + try: deliveries, _chunks = await deliver_json_messages( sender, @@ -336,6 +390,7 @@ async def push_driver_messages(request, session): target_list, Device(), delay_s=delay_s, + unicast=unicast, ) except Exception: return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'} diff --git a/src/main.py b/src/main.py index 279d0de..caeb998 100644 --- a/src/main.py +++ b/src/main.py @@ -23,8 +23,7 @@ import controllers.led_tool as led_tool_controller from models.transport import get_sender, set_sender, get_current_sender from models.device import Device from models.bridge_ws_client import init_bridge_client -from util.espnow_registry import handle_espnow_announce -from util.binary_driver_messages import v1_dict_to_cmd_packet +from util.espnow_registry import handle_bridge_uplink from util.audio_detector import AudioBeatDetector @@ -44,11 +43,11 @@ async def main(port=80): bridge_url = str(settings.get("bridge_ws_url") or "").strip() if bridge_url: try: - ch = int(settings.get("wifi_channel", 6)) + ch = int(settings.get("wifi_channel", 1)) except (TypeError, ValueError): - ch = 6 + ch = 1 bridge = init_bridge_client(bridge_url, wifi_channel=ch) - bridge.set_uplink_handler(handle_espnow_announce) + bridge.set_uplink_handler(handle_bridge_uplink) bridge.start() app = Microdot() @@ -278,8 +277,7 @@ async def main(port=80): continue parsed = json.loads(data) addr = parsed.pop("to", None) - pkt = v1_dict_to_cmd_packet(parsed) - await sender.send(pkt, addr=addr) + await sender.send(parsed, addr=addr) except json.JSONDecodeError: pass except Exception: diff --git a/src/models/bridge_ws_client.py b/src/models/bridge_ws_client.py index cc920e4..6755237 100644 --- a/src/models/bridge_ws_client.py +++ b/src/models/bridge_ws_client.py @@ -1,39 +1,47 @@ -"""Persistent WebSocket client to the ESP-NOW bridge (binary frames).""" +"""Persistent WebSocket client to the ESP-NOW bridge.""" from __future__ import annotations import asyncio -from typing import Awaitable, Callable, Optional +import json +from typing import Awaitable, Callable, Optional, Union import websockets from websockets.exceptions import ConnectionClosed -from util.espnow_wire import ( - MSG_ANNOUNCE, - WIRE_MAGIC, - pack_bridge_channel, - pack_ws_downlink, - parse_ws_frame, - wire_msg_type, -) +from util.espnow_wire import parse_ws_frame UplinkHandler = Callable[[bytes, bytes], Awaitable[None]] class BridgeWsClient: - def __init__(self, url: str, *, wifi_channel: int = 6): + def __init__(self, url: str, *, wifi_channel: int = 1, reconnect_delay_s: float = 2.0): self._url = url.strip() self._wifi_channel = wifi_channel + self._reconnect_delay_s = reconnect_delay_s self._ws: Optional[websockets.WebSocketClientProtocol] = None self._send_lock = asyncio.Lock() self._uplink_handler: Optional[UplinkHandler] = None self._task: Optional[asyncio.Task] = None self._connected = asyncio.Event() - self._ack_waiter: Optional[asyncio.Future] = None + self._disconnect_event = asyncio.Event() def set_uplink_handler(self, handler: Optional[UplinkHandler]) -> None: self._uplink_handler = handler + def _signal_disconnect(self) -> None: + self._connected.clear() + self._disconnect_event.set() + + async def _close_ws(self) -> None: + ws = self._ws + self._ws = None + if ws is not None: + try: + await ws.close() + except Exception: + pass + async def run_forever(self) -> None: while True: try: @@ -42,9 +50,11 @@ class BridgeWsClient: raise except Exception as e: print(f"[bridge] connection error: {e!r}") - self._connected.clear() - self._ws = None - await asyncio.sleep(2.0) + self._signal_disconnect() + self._disconnect_event.clear() + await self._close_ws() + print("[bridge] disconnected, reconnecting...") + await asyncio.sleep(self._reconnect_delay_s) async def _reader_loop(self) -> None: ws = self._ws @@ -52,40 +62,41 @@ class BridgeWsClient: return try: async for message in ws: - if isinstance(message, str): + if self._uplink_handler is None: continue - if len(message) == 1: - fut = self._ack_waiter - if fut is not None and not fut.done(): - fut.set_result(message[0] == 0x01) + if isinstance(message, str): + message = message.encode("utf-8") + if not message: continue try: peer, pkt, _bcast = parse_ws_frame(message) except ValueError: continue - if wire_msg_type(pkt) == MSG_ANNOUNCE and self._uplink_handler: - await self._uplink_handler(peer, pkt) + await self._uplink_handler(peer, pkt) except ConnectionClosed: pass + finally: + self._signal_disconnect() async def _connect_once(self) -> None: - print(f"[bridge] connecting to {self._url}") + print(f"[bridge] connecting to {self._url} (channel {self._wifi_channel} on bridge)") async with websockets.connect(self._url, ping_interval=20, ping_timeout=20) as ws: self._ws = ws - ch_pkt = pack_bridge_channel(self._wifi_channel) - await ws.send(pack_ws_downlink(ch_pkt, broadcast=True)) self._connected.set() + self._disconnect_event.clear() print("[bridge] connected") reader = asyncio.create_task(self._reader_loop()) try: - while True: - await asyncio.sleep(3600) + while not self._disconnect_event.is_set(): + await asyncio.sleep(0.5) finally: reader.cancel() try: await reader except asyncio.CancelledError: pass + except Exception: + pass async def wait_connected(self, timeout: float = 30.0) -> bool: try: @@ -94,34 +105,35 @@ class BridgeWsClient: except asyncio.TimeoutError: return False - async def send_frame(self, frame: bytes) -> bool: - await self._connected.wait() + async def send_packet(self, packet: Union[bytes, str, dict]) -> bool: + if isinstance(packet, dict): + packet = json.dumps(packet, separators=(",", ":")) + if isinstance(packet, str): + packet = packet.encode("utf-8") + if not await self.wait_connected(timeout=30.0): + return False ws = self._ws if ws is None: return False async with self._send_lock: - loop = asyncio.get_running_loop() - self._ack_waiter = loop.create_future() try: - await ws.send(frame) - return bool(await asyncio.wait_for(self._ack_waiter, timeout=5.0)) - except (ConnectionClosed, asyncio.TimeoutError, OSError) as e: + await ws.send(packet) + return True + except (ConnectionClosed, OSError) as e: print(f"[bridge] send failed: {e!r}") + self._signal_disconnect() + await self._close_ws() return False - finally: - self._ack_waiter = None async def send_espnow( self, packet: bytes, *, - peer_mac: Optional[str] = None, + peer_mac: Optional[bytes] = None, broadcast: bool = False, ) -> bool: - if not packet or packet[0] != WIRE_MAGIC: - raise ValueError("packet must be espnow wire format") - frame = pack_ws_downlink(packet, peer_mac=peer_mac, broadcast=broadcast) - return await self.send_frame(frame) + del peer_mac, broadcast + return await self.send_packet(packet) def start(self) -> asyncio.Task: if self._task is None or self._task.done(): @@ -136,7 +148,7 @@ def get_bridge_client() -> Optional[BridgeWsClient]: return _client -def init_bridge_client(url: str, *, wifi_channel: int = 6) -> BridgeWsClient: +def init_bridge_client(url: str, *, wifi_channel: int = 1) -> BridgeWsClient: global _client _client = BridgeWsClient(url, wifi_channel=wifi_channel) return _client diff --git a/src/models/transport.py b/src/models/transport.py index fbb4668..568f583 100644 --- a/src/models/transport.py +++ b/src/models/transport.py @@ -1,24 +1,18 @@ """Transport to LED drivers via ESP-NOW bridge WebSocket.""" -import asyncio -from typing import Optional, Union +import json +from typing import Any, Dict, List, Optional, Union from models.bridge_ws_client import get_bridge_client -from util.espnow_wire import WIRE_MAGIC, pack_ws_downlink - -BROADCAST_MAC_HEX = "ffffffffffff" - - -def _parse_mac(addr) -> Optional[bytes]: - if addr is None or addr == "": - return None - if isinstance(addr, bytes) and len(addr) == 6: - return addr - if isinstance(addr, str): - s = addr.strip().lower().replace(":", "").replace("-", "") - if len(s) == 12: - return bytes.fromhex(s) - return None +from util.bridge_envelope import ( + BROADCAST_HEX, + BROADCAST_MAC, + build_devices_envelope, + format_mac_key, + is_broadcast_mac, + normalize_mac_key, +) +from util.espnow_wire import WIRE_MAGIC class NullSender: @@ -29,25 +23,69 @@ class NullSender: class BridgeWsSender: - """Send binary ESP-NOW packets via bridge WebSocket client.""" + """Send v1 JSON or devices envelope via bridge WebSocket.""" - async def send(self, data: Union[bytes, str, dict], addr=None) -> bool: + async def send(self, data: Union[bytes, str, Dict[str, Any]], addr=None) -> bool: client = get_bridge_client() if client is None: return False - if isinstance(data, (bytes, bytearray)): + + if isinstance(data, dict): + if data.get("v") == "1" and ("devices" in data or "dv" in data): + from util.v1_wire import compact_envelope + + return await client.send_packet(compact_envelope(data)) + packet = json.dumps(data, separators=(",", ":")).encode("utf-8") + elif isinstance(data, str): + packet = data.encode("utf-8") + elif isinstance(data, (bytes, bytearray)): packet = bytes(data) else: return False - if not packet or packet[0] != WIRE_MAGIC: + + if not packet: return False - peer = _parse_mac(addr) - broadcast = peer is None or addr == BROADCAST_MAC_HEX - return await client.send_espnow( - packet, - peer_mac=peer, - broadcast=broadcast, - ) + + if packet[0] == WIRE_MAGIC: + return await client.send_packet(packet) + + if packet[0:1] != b"{": + return False + + mac_key = _addr_to_envelope_key(addr) + if mac_key is None: + return await client.send_packet(packet) + + try: + body = json.loads(packet.decode("utf-8")) + except (UnicodeError, ValueError, TypeError): + return False + if not isinstance(body, dict) or body.get("v") != "1": + return False + + envelope = build_devices_envelope({mac_key: body}) + return await client.send_packet(envelope) + + async def send_envelope(self, envelope: Dict[str, Any]) -> bool: + client = get_bridge_client() + if client is None: + return False + return await client.send_packet(envelope) + + +def _addr_to_envelope_key(addr) -> Optional[str]: + if addr is None: + return BROADCAST_MAC + s = str(addr).strip().lower() + if is_broadcast_mac(s): + return BROADCAST_MAC + h = normalize_mac_key(s) + if h: + try: + return format_mac_key(h) + except ValueError: + return None + return None _current_sender = None @@ -69,5 +107,5 @@ def get_sender(settings): "[startup] bridge disabled (set bridge_ws_url in settings.json, e.g. ws://192.168.4.1/ws)" ) return NullSender() - print(f"[startup] ESP-NOW via bridge WebSocket {url!r}") + print(f"[startup] ESP-NOW via bridge WebSocket {url!r} (devices envelope)") return BridgeWsSender() diff --git a/src/settings.py b/src/settings.py index 890d304..1bcca2b 100644 --- a/src/settings.py +++ b/src/settings.py @@ -51,7 +51,7 @@ class Settings(dict): self.save() # ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 1–11 if 'wifi_channel' not in self: - self['wifi_channel'] = 6 + self['wifi_channel'] = 1 # WebSocket URL of ESP-NOW bridge (Pi is client), e.g. ws://192.168.4.1/ws if 'bridge_ws_url' not in self: self['bridge_ws_url'] = '' diff --git a/src/static/patterns.js b/src/static/patterns.js index dccb476..2cb8a2e 100644 --- a/src/static/patterns.js +++ b/src/static/patterns.js @@ -98,12 +98,17 @@ document.addEventListener('DOMContentLoaded', () => { : []; }; - const postDriverSequence = async (sequence, targetMacs, delayS = 0.05) => { - const body = { - sequence, - targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined, - delay_s: delayS, - }; + const postDriverSequence = async (sequence, targetMacs, delayS = 0.05, pushOptions = {}) => { + if (typeof window.postDriverSequence === 'function') { + return window.postDriverSequence(sequence, targetMacs, delayS, pushOptions); + } + const body = { sequence, delay_s: delayS }; + if (pushOptions && pushOptions.unicast === true) { + body.unicast = true; + if (Array.isArray(targetMacs) && targetMacs.length) { + body.targets = [...new Set(targetMacs)]; + } + } const res = await fetch('/presets/push', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, @@ -586,26 +591,28 @@ document.addEventListener('DOMContentLoaded', () => { return; } - const select = {}; - deviceNames.forEach((name) => { - if (name) { - select[name] = zonePresetIds.slice(); - } - }); - const targetMacs = - typeof window.tabsManager !== 'undefined' && - typeof window.tabsManager.resolveTabDeviceMacs === 'function' - ? await window.tabsManager.resolveTabDeviceMacs(deviceNames) - : []; + const groupIds = + typeof window.zonesManager !== 'undefined' && + typeof window.zonesManager.effectiveGroupIdsForZonePreset === 'function' + ? window.zonesManager.effectiveGroupIdsForZonePreset(zoneData) + : Array.isArray(zoneData.group_ids) + ? zoneData.group_ids.map((g) => String(g).trim()).filter((g) => g.length > 0) + : []; const sequence = [ { v: '1', clear_presets: true, save: true }, { v: '1', presets: wirePresets, save: true }, ]; - if (Object.keys(select).length) { - sequence.push({ v: '1', select }); + if (groupIds.length) { + sequence[0].groups = groupIds; + sequence[1].groups = groupIds; } - await postDriverSequence(sequence, targetMacs, 0.05); + if (deviceNames.length > 0 && zonePresetIds.length > 0) { + const sel = { v: '1', select: zonePresetIds.slice() }; + if (groupIds.length) sel.groups = groupIds; + sequence.push(sel); + } + await postDriverSequence(sequence, [], 0.05, { groupIds }); } catch (error) { console.error('Send all patterns failed:', error); alert('Failed to send all patterns.'); diff --git a/src/static/presets.js b/src/static/presets.js index 8ff8a0c..54ce020 100644 --- a/src/static/presets.js +++ b/src/static/presets.js @@ -176,6 +176,17 @@ function tabDeviceNamesFromSection(section) { : []; } +/** Group ids for preset broadcast targeting on a zone tab. */ +function zoneGroupIdsFromTabData(tabData) { + const zm = window.zonesManager; + if (zm && typeof zm.effectiveGroupIdsForZonePreset === 'function') { + return zm.effectiveGroupIdsForZonePreset(tabData || {}); + } + return Array.isArray(tabData && tabData.group_ids) + ? tabData.group_ids.map((g) => String(g).trim()).filter((g) => g.length > 0) + : []; +} + /** Device names for ``presetId`` on the current zone tab (zone ``group_ids`` for presets, else tab devices). */ async function deviceNamesForPresetOnCurrentZone(presetId) { const section = document.querySelector('.presets-section[data-zone-id]'); @@ -216,8 +227,13 @@ function formatPresetTargetGroupsLine(zoneDoc, groupsMap) { async function postDriverSequence(sequence, targetMacs, delayS, pushOptions) { const body = { sequence, - targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined, }; + if (pushOptions && pushOptions.unicast === true) { + body.unicast = true; + if (Array.isArray(targetMacs) && targetMacs.length) { + body.targets = [...new Set(targetMacs)]; + } + } if (delayS != null && delayS >= 0) { body.delay_s = delayS; } @@ -1361,12 +1377,17 @@ document.addEventListener('DOMContentLoaded', () => { return; } try { - const targetMacs = - typeof window.tabsManager !== 'undefined' && - typeof window.tabsManager.resolveTabDeviceMacs === 'function' - ? await window.tabsManager.resolveTabDeviceMacs(deviceNames) - : []; - await postDriverSequence([{ v: '1', clear_presets: true, save: true }], targetMacs); + const zoneId = section && section.dataset.zoneId; + let groupIds = []; + if (zoneId) { + const zr = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }); + if (zr.ok) { + groupIds = zoneGroupIdsFromTabData(await zr.json()); + } + } + const clearMsg = { v: '1', clear_presets: true, save: true }; + if (groupIds.length) clearMsg.groups = groupIds; + await postDriverSequence([clearMsg], [], 0.05, { groupIds }); } catch (error) { console.error('Clear device presets failed:', error); alert('Failed to clear presets on devices.'); @@ -2040,29 +2061,23 @@ const sendPresetViaEspNow = async ( presetMessage.default = wirePresetId; } - const names = Array.isArray(deviceNames) ? deviceNames : []; - const targetMacs = - names.length > 0 && - typeof window.tabsManager !== 'undefined' && - typeof window.tabsManager.resolveTabDeviceMacs === 'function' - ? await window.tabsManager.resolveTabDeviceMacs(names) - : []; - - const sequence = [presetMessage]; - // Auto: apply preset immediately via select. Manual: load definition only — first step is on the next audio beat. - if (names.length > 0 && presetAuto) { - const select = {}; - names.forEach((name) => { - if (name) { - select[name] = [wirePresetId]; - } - }); - if (Object.keys(select).length > 0) { - sequence.push({ v: '1', select }); - } + const forceSelect = pushOptions && pushOptions.select === true; + const shouldSelect = + forceSelect || (pushOptions && pushOptions.select === false ? false : presetAuto); + // Apply on driver in the same message as presets (split on bridge keeps presets before select). + if (shouldSelect) { + presetMessage.select = [wirePresetId]; } - await postDriverSequence(sequence, targetMacs, 0.05, pushOptions); + const groupIds = + pushOptions && Array.isArray(pushOptions.groupIds) + ? pushOptions.groupIds.map((g) => String(g).trim()).filter((g) => g.length > 0) + : []; + if (groupIds.length > 0) { + presetMessage.groups = groupIds; + } + + await postDriverSequence([presetMessage], [], 0.05, pushOptions); } catch (error) { console.error('Failed to send preset to devices:', error); alert('Failed to send preset to devices.'); @@ -2106,17 +2121,13 @@ const sendPresetSelectViaEspNow = async (presetId, deviceNames) => { if (!nameTargets.length) { return; } - const select = {}; - nameTargets.forEach((name) => { - select[name] = [String(presetId)]; - }); const macTargets = nameTargets.length > 0 && typeof window.tabsManager !== 'undefined' && typeof window.tabsManager.resolveTabDeviceMacs === 'function' ? await window.tabsManager.resolveTabDeviceMacs(nameTargets) : []; - await postDriverSequence([{ v: '1', select }], macTargets); + await postDriverSequence([{ v: '1', select: [String(presetId)] }], macTargets); }; // Expose for other scripts (zones.js) so they can reuse the shared WebSocket. @@ -2168,11 +2179,16 @@ async function sendZonePresetSelection(zoneId, tabData, presetId, preset, allPre const pid = String(presetId); const body = (allPresets && allPresets[pid]) || preset; if (!body) return; + const zm = window.zonesManager; const names = - window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function' - ? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid) + zm && typeof zm.resolveDeviceNamesForZonePreset === 'function' + ? await zm.resolveDeviceNamesForZonePreset(tabData, pid) : []; - await sendPresetViaEspNow(pid, body, names, false, false, '2'); + const groupIds = zoneGroupIdsFromTabData(tabData); + await sendPresetViaEspNow(pid, body, names, false, false, '2', { + select: true, + groupIds, + }); } // Store selected preset per zone diff --git a/src/static/zones.js b/src/static/zones.js index 2ab7f6d..cfa61b0 100644 --- a/src/static/zones.js +++ b/src/static/zones.js @@ -107,6 +107,7 @@ function sendZoneBrightness(zoneId, value) { [{ v: '1', b: bv, save: true }], [mac], 0, + { unicast: true }, ); } return; @@ -304,6 +305,18 @@ async function resolveDeviceNamesForZonePreset(zoneDoc, presetId) { return Array.isArray(zt.names) ? zt.names.slice() : []; } +/** Registry MACs for preset push (same targeting as ``resolveDeviceNamesForZonePreset``). */ +async function resolveMacsForZonePreset(zoneDoc, presetId) { + void presetId; + const gids = effectiveGroupIdsForZonePreset(zoneDoc); + if (gids.length) { + const t = await resolveTargetsFromGroupIds(gids); + if (t.macs.length) return [...new Set(t.macs)]; + } + const zt = await computeZoneTargets(zoneDoc); + return Array.isArray(zt.macs) ? [...new Set(zt.macs.filter(Boolean))] : []; +} + /** Union of devices targeted by standalone presets on the zone (same as zone preset targeting). */ async function computeZonePresetUnionTargets(zoneDoc) { return await computeZoneTargets(zoneDoc); @@ -951,13 +964,15 @@ async function sendProfilePresets() { continue; } zonesWithPresets += 1; - const targets = await resolveZoneDeviceMacsFromZoneData(tabData); const payload = { preset_ids: presetIds }; if (tabData.default_preset) { payload.default = tabData.default_preset; } - if (targets.length > 0) { - payload.targets = targets; + const gids = Array.isArray(tabData.group_ids) + ? tabData.group_ids.map((g) => String(g).trim()).filter((g) => g.length > 0) + : []; + if (gids.length > 0) { + payload.group_ids = gids; } const response = await fetch('/presets/send', { method: 'POST', @@ -1425,6 +1440,7 @@ window.zonesManager = { computeZonePresetUnionTargets, effectiveGroupIdsForZonePreset, resolveDeviceNamesForZonePreset, + resolveMacsForZonePreset, resolveSequenceStepDeviceNames, fetchGroupsMap, renderZoneGroupsEditor, diff --git a/src/util/beat_driver_route.py b/src/util/beat_driver_route.py index 932dc3f..6c20180 100644 --- a/src/util/beat_driver_route.py +++ b/src/util/beat_driver_route.py @@ -232,10 +232,12 @@ def _apply_manual_beat_route( device_names: List[str], wire_preset_id: str, preset_body: Any, + group_ids: Optional[List[str]] = None, ) -> None: """Enable audio→driver routing for one manual preset (clears all lanes, including sequence).""" global _lane_manual - if not device_names: + gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()] + if not device_names and not gids: with _route_lock: _lane_manual.clear() _sync_public_beat_route_from_lane_table() @@ -265,6 +267,7 @@ def _apply_manual_beat_route( "pattern": pattern, "manual_beat_n": _coerce_manual_beat_n(preset_body), "beat_counter": 0, + "group_ids": gids, } _sync_public_beat_route_from_lane_table() @@ -273,10 +276,12 @@ def _apply_manual_beat_route_standalone_overlay( device_names: List[str], wire_preset_id: str, preset_body: Any, + group_ids: Optional[List[str]] = None, ) -> None: """Register manual beat routing on lane ``-1`` only, keeping sequence lanes ``0..n`` intact.""" global _lane_manual - if not device_names: + gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()] + if not device_names and not gids: with _route_lock: _lane_manual.pop(-1, None) _sync_public_beat_route_from_lane_table() @@ -309,6 +314,7 @@ def _apply_manual_beat_route_standalone_overlay( "pattern": pattern, "manual_beat_n": _coerce_manual_beat_n(preset_body), "beat_counter": 0, + "group_ids": gids, } _sync_public_beat_route_from_lane_table() @@ -318,11 +324,13 @@ def set_sequence_manual_lane_route( device_names: List[str], wire_preset_id: str, preset_body: Any, + group_ids: Optional[List[str]] = None, ) -> None: """Register or update one sequence lane's manual beat route (parallel lanes, independent strides).""" global _lane_manual names = [str(n).strip() for n in (device_names or []) if str(n).strip()] - if not names or not isinstance(preset_body, dict) or _coerce_auto_from_body(preset_body): + gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()] + if (not names and not gids) or not isinstance(preset_body, dict) or _coerce_auto_from_body(preset_body): with _route_lock: if lane_index in _lane_manual: del _lane_manual[lane_index] @@ -353,6 +361,7 @@ def set_sequence_manual_lane_route( "pattern": pattern, "manual_beat_n": mn, "beat_counter": bc, + "group_ids": gids, } overlay = _lane_manual.get(-1) if overlay and _lane_route_targets_key(names, wid) == _lane_route_targets_key( @@ -423,7 +432,8 @@ def sync_beat_route_from_push_sequence( """ Update beat routing from a ``/presets/push`` body ``sequence`` (list of v1 dicts). - With a ``select`` map: use its keys as device names (existing behaviour). + With ``select`` as ``[preset_id, step?]``: use ``target_macs`` for device names. + Legacy name-map ``select`` still uses map keys as device names. Without ``select`` (e.g. manual preset loaded without immediate select): if ``target_macs`` is set and the merged ``presets`` contain exactly one manual preset, enable routing using @@ -435,7 +445,9 @@ def sync_beat_route_from_push_sequence( sequence lanes ``0..n`` keep their stride counters and wire ids. """ merged_presets: Dict[str, Any] = {} - last_select: Optional[Dict[str, Any]] = None + last_select_list: Optional[List[Any]] = None + last_select_map: Optional[Dict[str, Any]] = None + last_group_ids: Optional[List[str]] = None for item in sequence: if isinstance(item, str): try: @@ -448,11 +460,27 @@ def sync_beat_route_from_push_sequence( if isinstance(pr, dict): merged_presets.update(pr) sel = item.get("select") - if isinstance(sel, dict) and sel: - last_select = sel + if isinstance(sel, list) and sel: + last_select_list = sel + elif isinstance(sel, dict) and sel: + last_select_map = sel + gr = item.get("groups") + if isinstance(gr, list) and gr: + last_group_ids = [str(g).strip() for g in gr if str(g).strip()] - if last_select: - device_names = [str(k).strip() for k in last_select.keys() if str(k).strip()] + if last_select_list: + device_names = _registry_names_for_macs(target_macs) + if not device_names and not last_group_ids: + if not preserve_parallel_lane_routes: + update_beat_route({"enabled": False}) + return + wire_preset_id = str(last_select_list[0]).strip() + if not wire_preset_id: + if not preserve_parallel_lane_routes: + update_beat_route({"enabled": False}) + return + elif last_select_map: + device_names = [str(k).strip() for k in last_select_map.keys() if str(k).strip()] if not device_names: if not preserve_parallel_lane_routes: update_beat_route({"enabled": False}) @@ -460,7 +488,7 @@ def sync_beat_route_from_push_sequence( wire_ids: Set[str] = set() for name in device_names: - val = last_select.get(name) + val = last_select_map.get(name) if isinstance(val, list) and val: wire_ids.add(str(val[0]).strip()) elif val is not None: @@ -470,6 +498,10 @@ def sync_beat_route_from_push_sequence( update_beat_route({"enabled": False}) return wire_preset_id = wire_ids.pop() + else: + wire_preset_id = None + + if wire_preset_id is not None: preset_body = merged_presets.get(wire_preset_id) if preset_body is None: for k, v in merged_presets.items(): @@ -486,10 +518,12 @@ def sync_beat_route_from_push_sequence( return if preserve_parallel_lane_routes: _apply_manual_beat_route_standalone_overlay( - device_names, wire_preset_id, preset_body + device_names, wire_preset_id, preset_body, group_ids=last_group_ids ) else: - _apply_manual_beat_route(device_names, wire_preset_id, preset_body) + _apply_manual_beat_route( + device_names, wire_preset_id, preset_body, group_ids=last_group_ids + ) mark_manual_select_sent_for_targets(device_names, wire_preset_id) return @@ -497,9 +531,11 @@ def sync_beat_route_from_push_sequence( if wire_id and body is not None: names = _registry_names_for_macs(target_macs) if preserve_parallel_lane_routes: - _apply_manual_beat_route_standalone_overlay(names, wire_id, body) + _apply_manual_beat_route_standalone_overlay( + names, wire_id, body, group_ids=last_group_ids + ) else: - _apply_manual_beat_route(names, wire_id, body) + _apply_manual_beat_route(names, wire_id, body, group_ids=last_group_ids) return if not preserve_parallel_lane_routes: @@ -553,9 +589,11 @@ def remap_beat_route_device_name(old_name: str, new_name: str) -> None: _sync_public_beat_route_from_lane_table() -async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None: +async def _deliver_select( + wire_preset_id: str, + group_ids: Optional[List[str]] = None, +) -> None: from models.device import Device - from models.device import resolve_device_mac_for_select_routing from models.transport import get_current_sender from util.driver_delivery import deliver_json_messages @@ -563,39 +601,30 @@ async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None: if not sender: return devices = Device() - seen_macs: List[str] = [] - seen_set: Set[str] = set() - for n in device_names: - mac = resolve_device_mac_for_select_routing(devices, n) - if mac and mac not in seen_set: - seen_set.add(mac) - seen_macs.append(mac) - if not seen_macs: - return - select: Dict[str, Any] = {} - for mac in seen_macs: - doc = devices.read(mac) or {} - nm = str(doc.get("name") or "").strip() - if nm: - select[nm] = [wire_preset_id] - if not select: - return - msg = json.dumps({"v": "1", "select": select}, separators=(",", ":")) + gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()] + body: Dict[str, Any] = {"v": "1", "select": [wire_preset_id]} + if gids: + body["groups"] = gids + msg = json.dumps(body, separators=(",", ":")) try: - await deliver_json_messages(sender, [msg], seen_macs, devices, delay_s=0.05) + await deliver_json_messages(sender, [msg], None, devices, delay_s=0.05) except Exception as e: print(f"[beat-route] deliver failed: {e}") -async def _deliver_select_batch(pairs: List[Tuple[List[str], str]]) -> None: - for names, pid in pairs: - await _deliver_select(names, pid) +async def _deliver_select_batch(pairs: List[Tuple[str, Optional[List[str]]]]) -> None: + for pid, gids in pairs: + await _deliver_select(pid, gids) def notify_beat_detected() -> None: - """Invoked from the audio thread when a beat is detected.""" + """Invoked from the audio thread when a beat is detected. + + Only manual presets are registered in ``_lane_manual`` (auto presets are cleared on step + change and get ``select`` from sequence/UI only when the preset changes). + """ global _preset_session_beats - work: List[Tuple[List[str], str]] = [] + work: List[Tuple[str, Optional[List[str]]]] = [] with _route_lock: if not _lane_manual: return @@ -604,7 +633,15 @@ def notify_beat_detected() -> None: for key in sorted(_lane_manual.keys()): e = _lane_manual[key] names = e.get("device_names") or [] - if not isinstance(names, list) or not names: + if not isinstance(names, list): + names = [] + gids_raw = e.get("group_ids") or [] + gids = ( + [str(g).strip() for g in gids_raw if str(g).strip()] + if isinstance(gids_raw, list) + else [] + ) + if not names and not gids: continue pattern = str(e.get("pattern") or "") if pattern and not _pattern_supports_manual(pattern): @@ -621,11 +658,13 @@ def notify_beat_detected() -> None: if (c - 1) % n != 0: continue wire = str(e.get("wire_preset_id") or "2") - target_key = _lane_route_targets_key(names, wire) + target_key = ( + (tuple(sorted(gids)), wire) if gids else _lane_route_targets_key(names, wire) + ) if target_key in seen_targets: continue seen_targets.add(target_key) - work.append((list(names), wire)) + work.append((wire, gids or None)) if work: _preset_session_beats += 1 if not work: diff --git a/src/util/bridge_envelope.py b/src/util/bridge_envelope.py new file mode 100644 index 0000000..4d7bd79 --- /dev/null +++ b/src/util/bridge_envelope.py @@ -0,0 +1,151 @@ +"""Build v1 devices envelope for Pi → bridge WebSocket (short wire keys).""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional, Union + +from util.v1_wire import ( + ENV_DEVICES, + K_GROUPS, + K_SAVE, + K_SET_GROUPS, + compact_body, + compact_envelope, + wire_json_size, +) + +BROADCAST_MAC = "ff:ff:ff:ff:ff:ff" +BROADCAST_HEX = "ffffffffffff" +MAX_ESPNOW_PAYLOAD = 250 + + +def normalize_mac_key(mac: Optional[str]) -> Optional[str]: + if mac is None: + return None + s = str(mac).strip().lower().replace(":", "").replace("-", "") + if len(s) == 12 and all(c in "0123456789abcdef" for c in s): + return s + return None + + +def format_mac_key(mac_hex: str) -> str: + h = normalize_mac_key(mac_hex) + if not h: + raise ValueError("invalid mac") + return ":".join(h[i : i + 2] for i in range(0, 12, 2)) + + +def is_broadcast_mac(mac: Optional[str]) -> bool: + h = normalize_mac_key(mac) + return h == BROADCAST_HEX + + +def build_devices_envelope(devices: Dict[str, Dict[str, Any]]) -> Dict[str, Any]: + """Wrap per-MAC bodies in a v1 envelope (short ``dv`` key).""" + compact_devices = { + mac: compact_body(body) for mac, body in devices.items() if isinstance(body, dict) + } + return {"v": "1", ENV_DEVICES: compact_devices} + + +def build_groups_envelope(mac_hex: str, group_ids: List[str]) -> Dict[str, Any]: + key = format_mac_key(mac_hex) + return build_devices_envelope( + { + key: { + K_GROUPS: [str(g) for g in group_ids], + K_SET_GROUPS: True, + } + } + ) + + +def build_v1_body( + *, + presets: Optional[Dict[str, Any]] = None, + select: Optional[Union[List[Any], Dict[str, Any], str]] = None, + save: bool = False, + default: Optional[str] = None, + brightness: Optional[int] = None, + groups: Optional[List[str]] = None, + set_groups: bool = False, +) -> Dict[str, Any]: + body: Dict[str, Any] = {} + if presets: + body["presets"] = presets + if select is not None: + body["select"] = select + if save: + body["save"] = True + if default is not None: + body["default"] = str(default) + if brightness is not None: + body["b"] = max(0, min(255, int(brightness))) + if groups is not None: + body["groups"] = [str(g) for g in groups] + if set_groups: + body["set_groups"] = True + return compact_body(body) + + +def v1_body_size(body: Dict[str, Any]) -> int: + return wire_json_size({"v": "1", **compact_body(body)}) + + +def envelope_payload_size(envelope: Dict[str, Any]) -> int: + return wire_json_size(compact_envelope(envelope)) + + +def split_v1_body_for_espnow(body: Dict[str, Any]) -> List[Dict[str, Any]]: + """Split a device body into chunks each <= MAX_ESPNOW_PAYLOAD bytes on the wire.""" + from util.v1_wire import K_PRESETS, K_SAVE, K_SELECT, expand_body + + long_body = expand_body(body) + compact = compact_body(long_body) + if v1_body_size(long_body) <= MAX_ESPNOW_PAYLOAD: + return [compact] + + chunks: List[Dict[str, Any]] = [] + meta = {k: v for k, v in compact.items() if k not in (K_PRESETS, K_SELECT)} + presets = compact.get(K_PRESETS) + select = compact.get(K_SELECT) + + if presets and isinstance(presets, dict): + preset_msg = {**meta, K_PRESETS: presets} + if wire_json_size({"v": "1", **preset_msg}) <= MAX_ESPNOW_PAYLOAD: + chunks.append(preset_msg) + else: + for pid, pdata in presets.items(): + one = {**meta, K_PRESETS: {pid: pdata}} + if wire_json_size({"v": "1", **one}) > MAX_ESPNOW_PAYLOAD: + raise ValueError(f"preset {pid!r} too large for ESP-NOW") + chunks.append(one) + + if select is not None: + sel_meta = {k: v for k, v in meta.items() if k != K_SAVE} + sel_msg = {**sel_meta, K_SELECT: select} + if wire_json_size({"v": "1", **sel_msg}) > MAX_ESPNOW_PAYLOAD: + raise ValueError("select payload too large for ESP-NOW") + chunks.append(sel_msg) + + if not chunks: + raise ValueError("device body too large to split for ESP-NOW") + return chunks + + +def merge_preset_and_select( + preset_body: Dict[str, Any], + select_body: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + """Merge preset + select bodies if combined envelope fits ESP-NOW limit.""" + merged = dict(preset_body) + if "select" in select_body: + merged["select"] = select_body["select"] + for key in ("groups", "set_groups"): + if key in select_body and key not in merged: + merged[key] = select_body[key] + env = build_devices_envelope({BROADCAST_MAC: merged}) + if envelope_payload_size(env) <= MAX_ESPNOW_PAYLOAD: + return compact_body(merged) + return None diff --git a/src/util/driver_delivery.py b/src/util/driver_delivery.py index 19cc6e3..b20fc5f 100644 --- a/src/util/driver_delivery.py +++ b/src/util/driver_delivery.py @@ -1,13 +1,97 @@ -"""Deliver binary ESP-NOW messages via bridge WebSocket.""" +"""Deliver v1 JSON to drivers via bridge devices envelope.""" import asyncio -from typing import List, Optional, Union +import json +from typing import Any, Dict, List, Optional, Union -from models.device import normalize_mac -from util.binary_driver_messages import build_preset_cmd_chunks, v1_dict_to_cmd_packet -from util.espnow_wire import BROADCAST_MAC, pack_group_cmd +from util.bridge_envelope import ( + BROADCAST_MAC, + build_devices_envelope, + format_mac_key, + normalize_mac_key, + split_v1_body_for_espnow, +) +from util.espnow_message import build_message +from util.espnow_wire import WIRE_MAGIC, pack_group_cmd -_BROADCAST_HEX = "ffffffffffff" +_MAX_JSON_ESPNOW = 240 + + +def v1_message_bytes(body: Dict[str, Any]) -> bytes: + return json.dumps(body, separators=(",", ":")).encode("utf-8") + + +def _body_from_message(msg: Union[str, bytes, bytearray, Dict[str, Any]]) -> Optional[Dict[str, Any]]: + if isinstance(msg, dict): + if msg.get("v") == "1" and "devices" not in msg: + return dict(msg) + return None + if isinstance(msg, str): + try: + data = json.loads(msg) + except (ValueError, TypeError): + return None + return data if isinstance(data, dict) else None + if isinstance(msg, (bytes, bytearray)): + raw = bytes(msg) + if not raw or raw[0] != ord("{"): + return None + try: + data = json.loads(raw.decode("utf-8")) + except (UnicodeError, ValueError, TypeError): + return None + return data if isinstance(data, dict) else None + return None + + +async def deliver_envelope(sender, envelope: Dict[str, Any], delay_s: float = 0.1) -> int: + if not envelope or not isinstance(envelope.get("devices"), dict): + return 0 + if await sender.send(envelope): + if delay_s > 0: + await asyncio.sleep(delay_s) + return 1 + return 0 + + +async def _deliver_v1_body(sender, mac_key: str, body: Dict[str, Any], delay_s: float) -> int: + deliveries = 0 + try: + chunks = split_v1_body_for_espnow(body) + except ValueError: + return 0 + for chunk in chunks: + env = build_devices_envelope({mac_key: chunk}) + if await sender.send(env): + deliveries += 1 + if delay_s > 0: + await asyncio.sleep(delay_s) + return deliveries + + +async def deliver_packets( + sender, + packets: List[bytes], + *, + delay_s: float = 0.1, + target_macs: Optional[List[str]] = None, + unicast: bool = False, +) -> int: + if not packets: + return 0 + deliveries = 0 + mac_keys = _unicast_mac_keys(target_macs) if unicast and target_macs else [BROADCAST_MAC] + for mac_key in mac_keys: + for pkt in packets: + body = _body_from_message(pkt) + if body: + deliveries += await _deliver_v1_body(sender, mac_key, body, delay_s) + else: + if await sender.send(pkt): + deliveries += 1 + if delay_s > 0: + await asyncio.sleep(delay_s) + return deliveries async def deliver_binary_packets( @@ -16,33 +100,11 @@ async def deliver_binary_packets( target_macs: Optional[List[str]] = None, *, delay_s: float = 0.1, + unicast: bool = False, ) -> int: - """Send binary CMD packets unicast per MAC or broadcast when no targets.""" - if not packets: - return 0 - deliveries = 0 - if not target_macs: - for pkt in packets: - if await sender.send(pkt, addr=_BROADCAST_HEX): - deliveries += 1 - await asyncio.sleep(delay_s) - return deliveries - - seen = set() - ordered: List[str] = [] - for raw in target_macs: - m = normalize_mac(str(raw)) if raw else None - if not m or m in seen: - continue - seen.add(m) - ordered.append(m) - - for pkt in packets: - for mac in ordered: - if await sender.send(pkt, addr=mac): - deliveries += 1 - await asyncio.sleep(delay_s) - return deliveries + return await deliver_packets( + sender, packets, delay_s=delay_s, target_macs=target_macs, unicast=unicast + ) async def deliver_group_binary_packets( @@ -52,7 +114,7 @@ async def deliver_group_binary_packets( *, delay_s: float = 0.1, ) -> int: - """Broadcast GROUP_CMD packets (one ESP-NOW send per packet).""" + """Broadcast GROUP_CMD wire packets (legacy binary passthrough on bridge).""" from util.espnow_wire import parse_cmd deliveries = 0 @@ -64,12 +126,54 @@ async def deliver_group_binary_packets( g_pkt = pack_group_cmd(str(group_id), env, save=save) except ValueError: continue - if await sender.send(g_pkt, addr=_BROADCAST_HEX): + if await sender.send(g_pkt): deliveries += 1 await asyncio.sleep(delay_s) return deliveries +def build_preset_json_chunks( + presets_by_name: Dict[str, Any], + *, + save: bool = False, + default: Optional[str] = None, + max_payload: int = _MAX_JSON_ESPNOW, +) -> List[str]: + entries = list(presets_by_name.items()) + chunks: List[str] = [] + batch: Dict[str, Any] = {} + + def _msg_for(presets_map: Dict[str, Any], *, final_save: bool, def_id: Optional[str]) -> str: + return build_message( + presets=presets_map, + save=final_save, + default=def_id, + ) + + for name, preset_obj in entries: + trial = dict(batch) + trial[name] = preset_obj + try: + msg = _msg_for(trial, final_save=False, def_id=None) + except (TypeError, ValueError): + msg = "" + if len(msg.encode("utf-8")) <= max_payload or not batch: + batch = trial + else: + chunks.append(_msg_for(batch, final_save=False, def_id=None)) + batch = {name: preset_obj} + + if batch: + chunks.append( + _msg_for( + batch, + final_save=save, + def_id=str(default) if default else None, + ) + ) + return [c for c in chunks if c] + + async def deliver_preset_broadcast_then_per_device( sender, chunk_messages, @@ -78,88 +182,59 @@ async def deliver_preset_broadcast_then_per_device( default_id, delay_s=0.1, ): - """ - chunk_messages: list of v1 JSON strings OR binary CMD bytes. - Converts JSON strings to binary when needed. - """ - packets: List[bytes] = [] + del devices_model, target_macs + deliveries = 0 for msg in chunk_messages: - if isinstance(msg, (bytes, bytearray)): - packets.append(bytes(msg)) - else: - import json - - try: - body = json.loads(msg) - except Exception: - continue - if isinstance(body, dict): - packets.append(v1_dict_to_cmd_packet(body)) - - if not packets: - return 0 - - seen = set() - ordered = [] - for raw in target_macs: - m = normalize_mac(str(raw)) if raw else None - if not m or m in seen: + body = _body_from_message(msg) + if not body: continue - seen.add(m) - ordered.append(m) - - deliveries = await deliver_binary_packets( - sender, packets, ordered, delay_s=delay_s - ) + deliveries += await _deliver_v1_body(sender, BROADCAST_MAC, body, delay_s) if default_id: - did = str(default_id) - for mac in ordered: - doc = devices_model.read(mac) or {} - name = str(doc.get("name") or "").strip() or mac - body = {"v": "1", "default": did, "save": True, "targets": [name]} - pkt = v1_dict_to_cmd_packet(body) - if await sender.send(pkt, addr=mac): - deliveries += 1 - await asyncio.sleep(delay_s) + body = {"default": str(default_id), "save": True} + deliveries += await _deliver_v1_body(sender, BROADCAST_MAC, body, delay_s) return deliveries -async def deliver_json_messages(sender, messages, target_macs, devices_model, delay_s=0.1): - """ - Convert v1 JSON message strings to binary CMD packets and deliver. - Returns (delivery_count, chunk_count). - """ - packets: List[bytes] = [] - import json - - for msg in messages: - if isinstance(msg, (bytes, bytearray)): - packets.append(bytes(msg)) - continue - try: - body = json.loads(msg) - except Exception: - continue - if isinstance(body, dict): - packets.append(v1_dict_to_cmd_packet(body)) - - if not packets: - return 0, 0 - +def _unicast_mac_keys(target_macs: Optional[List[str]]) -> List[str]: + """One formatted MAC per target; empty list means broadcast.""" if not target_macs: - n = await deliver_binary_packets(sender, packets, None, delay_s=delay_s) - return n, len(packets) - - seen = set() - ordered_macs = [] + return [BROADCAST_MAC] + keys: List[str] = [] + seen: set = set() for raw in target_macs: - m = normalize_mac(str(raw)) if raw else None - if not m or m in seen: - continue - seen.add(m) - ordered_macs.append(m) + h = normalize_mac_key(raw) + if h and h not in seen: + seen.add(h) + keys.append(format_mac_key(h)) + return keys if keys else [BROADCAST_MAC] - n = await deliver_binary_packets(sender, packets, ordered_macs, delay_s=delay_s) - return n, len(packets) + +async def deliver_json_messages( + sender, + messages, + target_macs, + devices_model, + delay_s=0.1, + *, + unicast: bool = False, +): + """ + Deliver v1 JSON to drivers. Default: ESP-NOW broadcast (``ff:ff:…``); drivers + filter on ``groups`` in the body. Set ``unicast=True`` only for per-device settings + or single-device identify. + """ + del devices_model + deliveries = 0 + if unicast and target_macs: + mac_keys = _unicast_mac_keys(target_macs) + else: + mac_keys = [BROADCAST_MAC] + for mac_key in mac_keys: + for msg in messages: + body = _body_from_message(msg) + if not body: + continue + deliveries += await _deliver_v1_body(sender, mac_key, body, delay_s) + return deliveries, len(messages) diff --git a/src/util/espnow_message.py b/src/util/espnow_message.py index bfc491e..7b22847 100644 --- a/src/util/espnow_message.py +++ b/src/util/espnow_message.py @@ -55,27 +55,22 @@ def build_message(presets=None, select=None, save=False, default=None): return json.dumps(message) -def build_select_message(device_name, preset_name, step=None): +def build_select_list(preset_name, step=None): """ - Build a select message for a single device. - - Args: - device_name: Name of the device - preset_name: Name of the preset to select - step: Optional step value for synchronization - - Returns: - Dictionary with select field ready to use in build_message - - Example: - select = build_select_message("device1", "rainbow_preset", step=10) - message = build_message(select=select) + Build a select list for one driver (unicast / per-MAC envelope). + + Wire shape: ``["preset_id"]`` or ``["preset_id", step]`` — no device name. """ - select_list = [preset_name] + select_list = [str(preset_name)] if step is not None: select_list.append(step) - - return {device_name: select_list} + return select_list + + +def build_select_message(device_name, preset_name, step=None): + """Legacy name-map select; prefer :func:`build_select_list` for ESP-NOW.""" + del device_name + return build_select_list(preset_name, step=step) def _hex_from_background_raw(bg_raw): diff --git a/src/util/espnow_registry.py b/src/util/espnow_registry.py index 93abc86..9c35ca1 100644 --- a/src/util/espnow_registry.py +++ b/src/util/espnow_registry.py @@ -1,14 +1,92 @@ -"""Handle ESP-NOW ANNOUNCE uplink and push GROUPS to drivers.""" +"""Handle ESP-NOW uplink from bridge and push group membership.""" from __future__ import annotations +import json +from typing import Any, Dict, Optional + from models.device import Device, normalize_mac # noqa: F401 — re-export for callers from models.group import Group -from models.bridge_ws_client import get_bridge_client -from util.espnow_wire import mac_bytes_to_hex, pack_groups, parse_announce +from models.transport import get_current_sender +from util.bridge_envelope import build_groups_envelope +from util.espnow_wire import ( + MSG_ANNOUNCE, + WIRE_MAGIC, + mac_bytes_to_hex, + parse_announce, + wire_msg_type, +) from util.groups_for_device import groups_for_mac +async def handle_bridge_uplink(peer_mac: bytes, payload: bytes) -> None: + """Dispatch binary wire or JSON v1 hello from bridge uplink.""" + if not payload: + return + if payload[0] == WIRE_MAGIC: + if wire_msg_type(payload) == MSG_ANNOUNCE: + await handle_espnow_announce(peer_mac, payload) + return + if payload[:1] == b"{": + try: + data = json.loads(payload.decode("utf-8")) + except (UnicodeError, ValueError, TypeError): + return + if isinstance(data, dict): + await handle_json_hello(peer_mac, data) + + +async def _after_device_registered(mac_hex: str) -> None: + await push_groups_to_mac(mac_hex) + + +async def handle_json_hello(peer_mac: bytes, data: Dict[str, Any]) -> None: + """Register device from driver JSON boot hello.""" + if data.get("v") != "1": + return + mac_hex = mac_bytes_to_hex(peer_mac) + if not mac_hex: + return + + name = data.get("name") + nested = data.get("settings") + if not name and isinstance(nested, dict): + name = nested.get("name") + name = str(name or mac_hex).strip() or mac_hex + + num_leds = None + color_order = None + startup_mode = None + brightness = None + if isinstance(nested, dict): + try: + num_leds = int(nested.get("num_leds")) if nested.get("num_leds") is not None else None + except (TypeError, ValueError): + pass + color_order = nested.get("color_order") + startup_mode = nested.get("startup_mode") + try: + brightness = int(nested.get("brightness")) if nested.get("brightness") is not None else None + except (TypeError, ValueError): + pass + + devices = Device() + did, persisted = devices.upsert_espnow_announced( + mac_hex, + name, + device_type=data.get("type", "led"), + num_leds=num_leds, + color_order=color_order, + startup_mode=startup_mode, + brightness=brightness, + ) + if not did: + return + if persisted: + print(f"[espnow] registered mac={did} name={name!r} (json hello)") + await _after_device_registered(mac_hex) + + async def handle_espnow_announce(peer_mac: bytes, packet: bytes) -> None: info = parse_announce(packet) if not info: @@ -31,24 +109,13 @@ async def handle_espnow_announce(peer_mac: bytes, packet: bytes) -> None: return if persisted: print(f"[espnow] registered mac={did} name={info['name']!r}") - - groups = Group() - gids = groups_for_mac(did, groups) - groups_pkt = pack_groups(gids) - - client = get_bridge_client() - if client is None: - print("[espnow] bridge client not configured; groups not sent") - return - ok = await client.send_espnow(groups_pkt, peer_mac=peer_mac) - if ok: - print(f"[espnow] groups -> {did}: {gids}") - else: - print(f"[espnow] groups send failed for {did}") + await _after_device_registered(mac_hex) async def push_groups_for_group_devices(gdoc: dict) -> None: - """Refresh GROUPS on every MAC listed on a group document.""" + """Push group membership to each device MAC listed on the group.""" + if not isinstance(gdoc, dict): + return mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else [] for mac in mac_list: m = normalize_mac(str(mac)) @@ -56,15 +123,22 @@ async def push_groups_for_group_devices(gdoc: dict) -> None: await push_groups_to_mac(m) +async def push_groups_broadcast() -> bool: + """No aggregate broadcast for group assignment; use per-device push.""" + return False + + async def push_groups_to_mac(mac_hex: str) -> bool: - """Re-send GROUPS packet to one device (after group membership change).""" + """Unicast groups envelope to one driver (set_groups true).""" mac = normalize_mac(mac_hex) if not mac: return False - client = get_bridge_client() - if client is None: + gids = groups_for_mac(mac, Group()) + sender = get_current_sender() + if sender is None: return False - groups = Group() - gids = groups_for_mac(mac, groups) - pkt = pack_groups(gids) - return await client.send_espnow(pkt, peer_mac=bytes.fromhex(mac)) + envelope = build_groups_envelope(mac, gids) + ok = await sender.send(envelope) + if ok: + print(f"[espnow] groups sent mac={mac} groups={gids!r}") + return bool(ok) diff --git a/src/util/sequence_playback.py b/src/util/sequence_playback.py index 77b46e1..f42504b 100644 --- a/src/util/sequence_playback.py +++ b/src/util/sequence_playback.py @@ -452,8 +452,7 @@ async def _prime_lane(lane_index: int, ctx: Dict[str, Any]) -> None: return device_names = _resolve_lane_device_names(lane_index, ctx) - macs = _device_names_to_macs(device_names, ctx["devices"]) - if not macs: + if not device_names: return sender = get_current_sender() @@ -462,26 +461,33 @@ async def _prime_lane(lane_index: int, ctx: Dict[str, Any]) -> None: zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {} devices_model = ctx["devices"] + num_lanes = int(ctx["num_lanes"]) + sequence_doc = ctx["sequence_doc"] + gids = _group_ids_for_lane_step( + sequence_doc, step0, lane_index, num_lanes, zone_doc=zone_doc + ) + if not gids and isinstance(zone_doc, dict): + zg = zone_doc.get("group_ids") + if isinstance(zg, list): + gids = [str(g).strip() for g in zg if str(g).strip()] wire = str(preset_id) auto = _coerce_auto(display_preset) - sel: Dict[str, Any] = {} - for n in device_names: - if n: - sel[str(n)] = [wire] - delay_s = 0.05 - for mac in macs: - body: Dict[str, Any] = {"v": "1", "presets": dict(inner_by_wire)} - if sel: - body["select"] = sel - msg = json.dumps(body, separators=(",", ":")) - await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=delay_s) + body: Dict[str, Any] = {"v": "1", "presets": dict(inner_by_wire)} + if gids: + body["groups"] = list(gids) + if auto: + body["select"] = [wire] + msg = json.dumps(body, separators=(",", ":")) + await deliver_json_messages(sender, [msg], None, devices_model, delay_s=delay_s) if auto: clear_sequence_manual_lane_route(lane_index) else: inner = _preset_inner_from_display_preset(display_preset) - set_sequence_manual_lane_route(lane_index, device_names, wire, inner) + set_sequence_manual_lane_route( + lane_index, device_names, wire, inner, group_ids=gids or None + ) mark_sequence_manual_lane_select_sent(lane_index) @@ -534,7 +540,9 @@ async def _deliver_zone_brightness_for_sequence(ctx: Dict[str, Any]) -> None: zone_brightness=zb, ) msg = json.dumps({"v": "1", "b": eff, "save": True}, separators=(",", ":")) - await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=0.05) + await deliver_json_messages( + sender, [msg], [mac], devices_model, delay_s=0.05, unicast=True + ) def _device_names_to_macs(device_names: List[str], devices: Any) -> List[str]: @@ -700,33 +708,25 @@ async def _send_lane( if not sender: raise RuntimeError("Transport not configured") - macs = _device_names_to_macs(device_names, devices) - if not macs: + if not device_names and not gids: return wire = str(preset_id) auto = _coerce_auto(display_preset) + body: Dict[str, Any] = {"v": "1", "select": [wire]} + if gids: + body["groups"] = [str(g) for g in gids] + msg = json.dumps(body, separators=(",", ":")) if auto: clear_sequence_manual_lane_route(lane_index) - sel: Dict[str, Any] = {} - for n in device_names: - if n: - sel[str(n)] = [wire] - if not sel: - return - msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":")) - await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05) + await deliver_json_messages(sender, [msg], None, devices, delay_s=0.05) else: inner = _preset_inner_from_display_preset(display_preset) - set_sequence_manual_lane_route(lane_index, device_names, wire, inner) - sel: Dict[str, Any] = {} - for n in device_names: - if n: - sel[str(n)] = [wire] - if sel: - msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":")) - await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05) - mark_sequence_manual_lane_select_sent(lane_index) + set_sequence_manual_lane_route( + lane_index, device_names, wire, inner, group_ids=gids or None + ) + await deliver_json_messages(sender, [msg], None, devices, delay_s=0.05) + mark_sequence_manual_lane_select_sent(lane_index) async def _send_all_lanes(ctx: Dict[str, Any]) -> None: @@ -772,6 +772,12 @@ def _build_ctx( } +def playback_active() -> bool: + """True while a zone sequence run is active (step timing owned by ``process_active_beat_advance``).""" + with _beat_run_lock: + return _beat_run is not None + + def playback_status() -> Dict[str, Any]: """Snapshot for UI (e.g. audio status poll): lane 0 step + beats within step, total steps sum.""" with _beat_run_lock: @@ -917,11 +923,20 @@ async def _clear_devices_after_sequence(ctx: Dict[str, Any]) -> None: if not sender: return devices = ctx.get("devices") - macs = _union_macs_for_sequence(ctx) - if not macs: - return - msg = json.dumps({"v": "1", "clear_presets": True, "save": True}, separators=(",", ":")) - await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05) + zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {} + gids: List[str] = [] + zg = zone_doc.get("group_ids") if isinstance(zone_doc, dict) else None + if isinstance(zg, list): + gids = [str(g).strip() for g in zg if str(g).strip()] + if not gids: + macs = _union_macs_for_sequence(ctx) + if not macs: + return + body: Dict[str, Any] = {"v": "1", "clear_presets": True, "save": True} + if gids: + body["groups"] = gids + msg = json.dumps(body, separators=(",", ":")) + await deliver_json_messages(sender, [msg], None, devices, delay_s=0.05) def _halt_playback_state() -> Optional[Dict[str, Any]]: diff --git a/src/util/v1_wire.py b/src/util/v1_wire.py new file mode 100644 index 0000000..093d1de --- /dev/null +++ b/src/util/v1_wire.py @@ -0,0 +1,123 @@ +"""Short v1 field names for ESP-NOW JSON (≤250 B). Long names still accepted on receive.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Union + +# Envelope: devices map +ENV_DEVICES = "dv" + +# Device body +K_PRESETS = "p" +K_SELECT = "s" +K_GROUPS = "g" +K_SET_GROUPS = "sg" +K_SAVE = "sv" +K_DEFAULT = "df" +K_DEVICE_CONFIG = "dc" +K_CLEAR_PRESETS = "cp" +K_MANIFEST = "mf" + +_BODY_LONG_TO_SHORT = { + "presets": K_PRESETS, + "select": K_SELECT, + "groups": K_GROUPS, + "set_groups": K_SET_GROUPS, + "save": K_SAVE, + "default": K_DEFAULT, + "device_config": K_DEVICE_CONFIG, + "clear_presets": K_CLEAR_PRESETS, + "manifest": K_MANIFEST, +} + +_BODY_SHORT_TO_LONG = {v: k for k, v in _BODY_LONG_TO_SHORT.items()} + + +def wire_select_list(preset_id: Union[str, int], step: Optional[Union[int, str]] = None) -> List[Any]: + """Preset id (+ optional step) for ``select`` on unicast/broadcast to one driver.""" + out: List[Any] = [str(preset_id)] + if step is not None: + out.append(step) + return out + + +def normalize_select_for_wire(select: Any) -> Any: + """Long or legacy shapes → wire list ``[preset_id, step?]``.""" + if isinstance(select, list): + return select + if isinstance(select, str) and select.strip(): + return [select.strip()] + if not isinstance(select, dict): + return select + if "preset" in select: + out: List[Any] = [str(select["preset"])] + if "step" in select: + out.append(select["step"]) + return out + # Legacy {device_name: [preset, step?]} — unicast only; keep dict for expand on driver + if len(select) == 1: + val = next(iter(select.values())) + if isinstance(val, list) and val: + return list(val) + return select + + +def compact_body(body: Dict[str, Any]) -> Dict[str, Any]: + """Long-key device body → short keys for the wire.""" + out: Dict[str, Any] = {} + for long_key, short_key in _BODY_LONG_TO_SHORT.items(): + if long_key in body: + val = body[long_key] + if long_key == "select": + val = normalize_select_for_wire(val) + out[short_key] = val + for short_key in _BODY_SHORT_TO_LONG: + if short_key in body and short_key not in out: + val = body[short_key] + if short_key == K_SELECT: + val = normalize_select_for_wire(val) + out[short_key] = val + if "b" in body: + out["b"] = body["b"] + return out + + +def expand_body(body: Dict[str, Any]) -> Dict[str, Any]: + """Short or long device body → long keys for driver logic.""" + out: Dict[str, Any] = dict(body) + for short_key, long_key in _BODY_SHORT_TO_LONG.items(): + if short_key in body and long_key not in out: + out[long_key] = body[short_key] + if short_key in out: + del out[short_key] + return out + + +def compact_envelope(envelope: Dict[str, Any]) -> Dict[str, Any]: + if envelope.get("v") != "1": + return envelope + devices = envelope.get("devices") + if devices is None: + devices = envelope.get(ENV_DEVICES) + if not isinstance(devices, dict): + return envelope + compact_devices = {mac: compact_body(body) for mac, body in devices.items() if isinstance(body, dict)} + return {"v": "1", ENV_DEVICES: compact_devices} + + +def expand_envelope(envelope: Dict[str, Any]) -> Dict[str, Any]: + if envelope.get("v") != "1": + return envelope + devices = envelope.get("devices") + if devices is None: + devices = envelope.get(ENV_DEVICES) + if not isinstance(devices, dict): + return envelope + expanded = {mac: expand_body(body) for mac, body in devices.items() if isinstance(body, dict)} + return {"v": "1", "devices": expanded} + + +def wire_json_size(obj: Dict[str, Any]) -> int: + import json + + return len(json.dumps(obj, separators=(",", ":")).encode("utf-8")) diff --git a/tests/bridge_broadcast_test.py b/tests/bridge_broadcast_test.py index 7c48799..9153123 100644 --- a/tests/bridge_broadcast_test.py +++ b/tests/bridge_broadcast_test.py @@ -1,13 +1,9 @@ #!/usr/bin/env python3 -"""Send binary ESP-NOW packets via the bridge (broadcast passthrough). +"""Send v1 JSON to drivers via the bridge (broadcast passthrough). -The simplified ``espnow-sender`` forwards each WebSocket **binary** message -unchanged to ESP-NOW ``ff:ff:ff:ff:ff:ff``. No ``pack_ws_downlink`` wrapper -and no 1-byte ack — raw wire packets only (see ``docs/espnow-binary-protocol.md``). - -Group membership is expected to be configured on each **led-driver**; this -script only broadcasts **CMD** (and optional **GROUPS** / **GROUP_CMD** for -manual testing). +The simplified ``espnow-sender`` forwards each WebSocket message unchanged to +ESP-NOW ``ff:ff:ff:ff:ff:ff``. Drivers accept JSON when the payload starts with +``{`` (see ``led-driver/src/main.py``). Examples:: @@ -18,8 +14,6 @@ Examples:: pipenv run python tests/bridge_broadcast_test.py --brightness 200 pipenv run python tests/bridge_broadcast_test.py --select led-abc --state on - - pipenv run python tests/bridge_broadcast_test.py --groups 5,18 --group-cmd 18 --brightness 64 """ from __future__ import annotations @@ -33,20 +27,7 @@ from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(PROJECT_ROOT / "src")) -from util.espnow_wire import ( # noqa: E402 - pack_cmd_from_kwargs, - pack_group_cmd_from_kwargs, - pack_groups, - wire_msg_type, -) - -MSG_TYPE_NAMES = { - 0x01: "ANNOUNCE", - 0x02: "GROUPS", - 0x03: "CMD", - 0x04: "GROUP_CMD", - 0x10: "BRIDGE_CH", -} +from util.espnow_message import build_message # noqa: E402 def _load_bridge_url(explicit: str | None) -> str: @@ -64,80 +45,73 @@ def _load_bridge_url(explicit: str | None) -> str: return "ws://192.168.4.1/ws" -def _describe_packet(pkt: bytes) -> str: - if len(pkt) < 2: - return f"{len(pkt)} B" - name = MSG_TYPE_NAMES.get(pkt[1], f"0x{pkt[1]:02x}") - return f"{name} {len(pkt)} B" - - -async def _send_packets(url: str, packets: list[bytes], delay_s: float) -> None: +async def _send_messages(url: str, messages: list[bytes], delay_s: float) -> None: import websockets print(f"connecting to {url}") async with websockets.connect(url, ping_interval=20, ping_timeout=20) as ws: - print("connected (broadcast passthrough)") - for i, pkt in enumerate(packets): - print(f" send [{i + 1}/{len(packets)}] {_describe_packet(pkt)}") + print("connected (broadcast JSON passthrough)") + for i, pkt in enumerate(messages): + preview = pkt[:80].decode("utf-8", errors="replace") + if len(pkt) > 80: + preview += "…" + print(f" send [{i + 1}/{len(messages)}] {len(pkt)} B {preview!r}") await ws.send(pkt) - if delay_s > 0 and i + 1 < len(packets): + if delay_s > 0 and i + 1 < len(messages): await asyncio.sleep(delay_s) print("done") -def _build_packets(args: argparse.Namespace) -> list[bytes]: - packets: list[bytes] = [] +def _build_messages(args: argparse.Namespace) -> list[bytes]: + messages: list[bytes] = [] - if args.groups: - gids = [g.strip() for g in args.groups.split(",") if g.strip()] - if gids: - packets.append(pack_groups(gids)) - - if args.group_cmd: - packets.append( - pack_group_cmd_from_kwargs( - args.group_cmd, - brightness_0_255=args.brightness, - select={args.select: [args.state]} if args.select else None, - save=args.save, - ) - ) - - if args.brightness is not None and not args.group_cmd: - packets.append( - pack_cmd_from_kwargs(brightness_0_255=args.brightness, save=args.save) - ) + if args.brightness is not None: + body: dict = { + "v": "1", + "b": max(0, min(255, int(args.brightness))), + } + if args.save: + body["save"] = True + messages.append(json.dumps(body, separators=(",", ":")).encode("utf-8")) if args.select: - packets.append( - pack_cmd_from_kwargs( + messages.append( + build_message( select={args.select: [args.state]}, save=args.save, - ) + ).encode("utf-8") ) if args.off: if args.select: - packets.append( - pack_cmd_from_kwargs(select={args.select: ["off"]}, save=args.save) + messages.append( + build_message(select={args.select: ["off"]}, save=args.save).encode("utf-8") ) else: - packets.append(pack_cmd_from_kwargs(select={"all": ["off"]}, save=args.save)) + messages.append( + build_message(select={"all": ["off"]}, save=args.save).encode("utf-8") + ) - if not packets: - packets.append(pack_cmd_from_kwargs(brightness_0_255=128)) - packets.append(pack_cmd_from_kwargs(select={"all": ["on"]})) - packets.append(pack_cmd_from_kwargs(select={"all": ["off"]})) + if not messages: + messages.append( + json.dumps({"v": "1", "b": 128}, separators=(",", ":")).encode("utf-8") + ) + messages.append( + build_message(select={"all": ["on"]}).encode("utf-8") + ) + messages.append( + build_message(select={"all": ["off"]}).encode("utf-8") + ) - for pkt in packets: - if wire_msg_type(pkt) is None: - raise ValueError("built packet is not valid wire format") - return packets + for pkt in messages: + if not pkt or pkt[0:1] != b"{": + raise ValueError("built message is not v1 JSON") + return messages def main() -> int: parser = argparse.ArgumentParser( - description="Broadcast binary ESP-NOW packets through the bridge WebSocket.", + description="Broadcast v1 JSON to LED drivers through the bridge WebSocket.", ) parser.add_argument( "--url", @@ -148,7 +122,7 @@ def main() -> int: "--delay", type=float, default=0.5, - help="Seconds between packets (default: 0.5)", + help="Seconds between messages (default: 0.5)", ) parser.add_argument( "--brightness", @@ -156,12 +130,12 @@ def main() -> int: type=int, default=None, metavar="0-255", - help="Broadcast CMD: global brightness", + help="Global brightness (b field)", ) parser.add_argument( "--select", metavar="DEVICE_NAME", - help="Broadcast CMD: device name in select map (must match driver settings name)", + help="Device name in select map (must match driver settings name)", ) parser.add_argument( "--state", @@ -171,46 +145,36 @@ def main() -> int: parser.add_argument( "--off", action="store_true", - help="After other commands, send select off (all devices if --select omitted)", - ) - parser.add_argument( - "--groups", - metavar="ID,ID", - help="Optional GROUPS broadcast (normally configured on device instead)", - ) - parser.add_argument( - "--group-cmd", - metavar="GROUP_ID", - help="Optional GROUP_CMD broadcast (driver must list this group locally)", + help="Send select off (all devices if --select omitted)", ) parser.add_argument( "--save", action="store_true", - help="Set save flag on CMD / GROUP_CMD envelopes", + help="Set save flag on messages", ) parser.add_argument( "--dry-run", action="store_true", - help="Print packets only; do not connect", + help="Print messages only; do not connect", ) args = parser.parse_args() url = _load_bridge_url(args.url) try: - packets = _build_packets(args) + messages = _build_messages(args) except ValueError as e: print(f"error: {e}", file=sys.stderr) return 1 - print(f"url={url!r} packets={len(packets)}") - for pkt in packets: - print(f" {_describe_packet(pkt)} hex={pkt.hex()}") + print(f"url={url!r} messages={len(messages)}") + for pkt in messages: + print(f" {pkt.decode('utf-8')}") if args.dry_run: return 0 try: - asyncio.run(_send_packets(url, packets, args.delay)) + asyncio.run(_send_messages(url, messages, args.delay)) except KeyboardInterrupt: print("interrupted") return 130 diff --git a/tests/test_beat_driver_route_suppress.py b/tests/test_beat_driver_route_suppress.py index b14d635..f8da912 100644 --- a/tests/test_beat_driver_route_suppress.py +++ b/tests/test_beat_driver_route_suppress.py @@ -43,7 +43,7 @@ def test_suppress_next_notify_skips_one_select(monkeypatch): assert delivered == [] bdr.notify_beat_detected() - assert delivered == [(["desk"], "5")] + assert delivered == [("5", None)] def test_suppress_does_not_advance_beat_counter(monkeypatch): @@ -52,8 +52,8 @@ def test_suppress_does_not_advance_beat_counter(monkeypatch): bdr.set_sequence_manual_lane_route( 0, ["desk"], - "42", - {"p": "radiate", "a": False, "manual_beat_n": 2}, + "5", + {"p": "chase", "a": False, "manual_beat_n": 2}, ) bdr.mark_sequence_manual_lane_select_sent(0) @@ -61,14 +61,14 @@ def test_suppress_does_not_advance_beat_counter(monkeypatch): assert delivered == [] bdr.notify_beat_detected() - assert delivered == [(["desk"], "42")] + assert delivered == [("5", None)] delivered.clear() bdr.notify_beat_detected() assert delivered == [] bdr.notify_beat_detected() - assert delivered == [(["desk"], "42")] + assert delivered == [("5", None)] def test_duplicate_lanes_dedupe_to_one_select_per_beat(monkeypatch): @@ -87,19 +87,57 @@ def test_duplicate_lanes_dedupe_to_one_select_per_beat(monkeypatch): bdr._lane_manual[0] = dict(entry) bdr.notify_beat_detected() - assert delivered == [(["desk"], "42")] + assert delivered == [("42", None)] + + +def test_sequence_lane_manual_delivers_per_beat_select(monkeypatch): + delivered = _patch_delivery(monkeypatch) + bdr.set_sequence_manual_lane_route( + 0, + ["desk"], + "42", + {"p": "radiate", "a": False, "manual_beat_n": 1}, + ) + bdr.notify_beat_detected() + assert delivered == [("42", None)] + + +def test_sequence_auto_lane_skips_per_beat_select(monkeypatch): + delivered = _patch_delivery(monkeypatch) + bdr.set_sequence_manual_lane_route( + 0, + ["desk"], + "3", + {"p": "colour_cycle", "a": True, "manual_beat_n": 1}, + ) + with bdr._route_lock: + assert 0 not in bdr._lane_manual + bdr.notify_beat_detected() + assert delivered == [] + + +def test_sequence_lane_chase_delivers_per_beat_select(monkeypatch): + delivered = _patch_delivery(monkeypatch) + bdr.set_sequence_manual_lane_route( + 0, + ["desk"], + "5", + {"p": "chase", "a": False, "manual_beat_n": 1}, + ) + bdr.notify_beat_detected() + assert delivered == [("5", None)] def test_standalone_overlay_skipped_when_sequence_lane_covers(monkeypatch): delivered = _patch_delivery(monkeypatch) - body = {"p": "radiate", "a": False, "manual_beat_n": 1} + body = {"p": "chase", "a": False, "manual_beat_n": 1} - bdr.set_sequence_manual_lane_route(1, ["desk"], "42", body) - bdr._apply_manual_beat_route_standalone_overlay(["desk"], "42", body) + bdr.set_sequence_manual_lane_route(1, ["desk"], "5", body) + bdr._apply_manual_beat_route_standalone_overlay(["desk"], "5", body) with bdr._route_lock: assert -1 not in bdr._lane_manual assert 1 in bdr._lane_manual bdr.notify_beat_detected() - assert delivered == [(["desk"], "42")] + assert delivered == [("5", None)] diff --git a/tests/test_bridge_envelope.py b/tests/test_bridge_envelope.py new file mode 100644 index 0000000..52c6438 --- /dev/null +++ b/tests/test_bridge_envelope.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Tests for bridge devices envelope (Pi + espnow-sender downlink).""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from util.bridge_envelope import ( # noqa: E402 + BROADCAST_MAC, + build_devices_envelope, + build_groups_envelope, + build_v1_body, + envelope_payload_size, + format_mac_key, + is_broadcast_mac, + split_v1_body_for_espnow, + v1_body_size, +) + + +def test_unicast_mac_keys_per_device(): + from util.driver_delivery import _unicast_mac_keys + + keys = _unicast_mac_keys(["188b0e1560a8", "e8f60a16ea10"]) + assert len(keys) == 2 + assert keys[0] == "18:8b:0e:15:60:a8" + assert keys[1] == "e8:f6:0a:16:ea:10" + assert _unicast_mac_keys(["188b0e1560a8"]) == ["18:8b:0e:15:60:a8"] + assert _unicast_mac_keys(None) == [BROADCAST_MAC] + + +def test_deliver_json_messages_defaults_broadcast(): + from util.driver_delivery import deliver_json_messages + + class _Sender: + def __init__(self): + self.keys = [] + + async def send(self, envelope): + devs = envelope.get("dv") or envelope.get("devices") or {} + self.keys.extend(devs.keys()) + return True + + async def _run(): + sender = _Sender() + await deliver_json_messages( + sender, + [json.dumps({"v": "1", "select": ["2"]})], + ["188b0e1560a8", "e8f60a16ea10"], + None, + ) + return sender.keys + + keys = __import__("asyncio").run(_run()) + assert keys == [BROADCAST_MAC] + + +def is_devices_envelope(raw: bytes) -> bool: + if not raw or raw[0:1] != b"{": + return False + try: + data = json.loads(raw) + except (ValueError, TypeError): + return False + devs = data.get("devices") if isinstance(data, dict) else None + if devs is None and isinstance(data, dict): + devs = data.get("dv") + return isinstance(data, dict) and data.get("v") == "1" and isinstance(devs, dict) + + +def build_driver_payload(body: dict) -> bytes: + out = {"v": "1", **{k: body[k] for k in body if k != "v"}} + raw = json.dumps(out) + if len(raw) > 250: + raise ValueError("too large") + return raw.encode("utf-8") + + +def test_build_groups_envelope(): + env = build_groups_envelope("e8f60a16ea10", ["5", "18"]) + assert env["v"] == "1" + key = format_mac_key("e8f60a16ea10") + devs = env.get("dv") or env.get("devices") + body = devs[key] + assert body["sg"] is True + assert body["g"] == ["5", "18"] + + +def test_is_broadcast_mac(): + assert is_broadcast_mac("ff:ff:ff:ff:ff:ff") + assert is_broadcast_mac("ffffffffffff") + assert not is_broadcast_mac("e8f60a16ea10") + + +def test_is_devices_envelope(): + env = build_devices_envelope( + { + BROADCAST_MAC: build_v1_body( + presets={"1": {"p": "on", "c": ["#FFFFFF"], "a": True}}, + groups=["5"], + set_groups=False, + ) + } + ) + raw = json.dumps(env).encode("utf-8") + assert is_devices_envelope(raw) + assert not is_devices_envelope(b'{"v":"1","s":{}}') + + +def test_build_driver_payload_size(): + body = build_v1_body( + presets={"x": {"pattern": "on", "colors": ["#FF0000"], "auto": True}}, + select=["x", 0], + save=True, + ) + payload = build_driver_payload(body) + assert len(payload) <= 250 + data = json.loads(payload) + assert data["v"] == "1" + assert data["s"] == ["x", 0] + + +def test_split_preset_and_select(): + body = build_v1_body( + presets={ + "2": { + "p": "on", + "c": ["#FFFFFF"], + "bg": "#000000", + "d": 100, + "b": 255, + "a": True, + "n1": 0, + "n2": 0, + } + }, + select=["2", 0], + save=True, + ) + if v1_body_size(body) <= 250: + chunks = split_v1_body_for_espnow(body) + assert len(chunks) == 1 + else: + chunks = split_v1_body_for_espnow(body) + assert len(chunks) >= 2 + assert all(v1_body_size(c) <= 250 for c in chunks) + assert "p" in chunks[0] + assert any("s" in c for c in chunks) + + +def test_envelope_fits_espnow_limit(): + env = build_devices_envelope( + { + BROADCAST_MAC: build_v1_body( + presets={ + "2": { + "pattern": "on", + "colors": ["#FFFFFF"], + "auto": True, + } + }, + select=["2"], + ) + } + ) + assert envelope_payload_size(env) <= 250 diff --git a/tests/test_bridge_ws_client.py b/tests/test_bridge_ws_client.py new file mode 100644 index 0000000..927997a --- /dev/null +++ b/tests/test_bridge_ws_client.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Tests for bridge WebSocket client reconnect behaviour.""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from models.bridge_ws_client import BridgeWsClient # noqa: E402 + + +def test_send_returns_false_when_not_connected(): + async def _run(): + client = BridgeWsClient("ws://127.0.0.1/ws", reconnect_delay_s=0.01) + + async def _no_wait(_timeout=30.0): + return False + + client.wait_connected = _no_wait # type: ignore[method-assign] + return await client.send_packet({"v": "1", "devices": {}}) + + assert asyncio.run(_run()) is False + + +def test_disconnect_clears_connected_event(): + client = BridgeWsClient("ws://127.0.0.1/ws", reconnect_delay_s=0.01) + client._connected.set() + client._signal_disconnect() + assert not client._connected.is_set() diff --git a/tests/test_endpoints_pytest.py b/tests/test_endpoints_pytest.py index 9be096a..d1c7b7e 100644 --- a/tests/test_endpoints_pytest.py +++ b/tests/test_endpoints_pytest.py @@ -676,13 +676,13 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server): assert "presets" in first and "select" in first assert first["presets"]["__identify"]["p"] == "blink" assert first["presets"]["__identify"]["d"] == 50 - assert first["select"]["pytest-dev"] == ["__identify"] + assert first["select"] == ["__identify"] deadline = time.monotonic() + 2.0 while len(sender.sent) < 2 and time.monotonic() < deadline: time.sleep(0.02) assert len(sender.sent) >= 2 second = json.loads(sender.sent[1][0]) - assert second.get("select") == {"pytest-dev": ["off"]} + assert second.get("select") == ["off"] resp = c.post( f"{base_url}/devices", diff --git a/tests/test_sequence_step_beats.py b/tests/test_sequence_step_beats.py new file mode 100644 index 0000000..64ec232 --- /dev/null +++ b/tests/test_sequence_step_beats.py @@ -0,0 +1,51 @@ +"""Sequence step ``beats`` hold (e.g. 12 beats on preset 42, then 4 on preset 5).""" + +import asyncio +import os +import sys + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SRC_PATH = os.path.join(PROJECT_ROOT, "src") +if SRC_PATH not in sys.path: + sys.path.insert(0, SRC_PATH) + +from util import sequence_playback as sp # noqa: E402 + + +def test_step_holds_beats_before_lane_send(monkeypatch): + sent = [] + + async def fake_send_lane(i, st, ctx): + sent.append((int(st.get("stepIdx", 0)), int(st.get("beatCount", 0)))) + + monkeypatch.setattr(sp, "_send_lane", fake_send_lane) + + async def noop_stop(**_kwargs): + with sp._beat_run_lock: + sp._beat_run = None + + monkeypatch.setattr(sp, "stop_playback", noop_stop) + + ctx = { + "num_lanes": 1, + "loop": False, + "lanes": [[{"preset_id": "42", "beats": 12}, {"preset_id": "5", "beats": 4}]], + "lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}], + "sequence_loop_beat": 0, + } + with sp._beat_run_lock: + sp._beat_run = ctx + + async def run(): + for _ in range(11): + await sp.process_active_beat_advance() + await sp.process_active_beat_advance() + for _ in range(3): + await sp.process_active_beat_advance() + await sp.process_active_beat_advance() + + asyncio.run(run()) + assert sent == [(1, 0)] + assert ctx["lane_states"][0]["done"] is True + with sp._beat_run_lock: + sp._beat_run = None