19 Commits

Author SHA1 Message Date
90be198483 Add presets system and convert back to Flask
- Convert from Microdot back to Flask
- Add presets system with CRUD operations
- Store presets in presets.json file
- Replace patterns section with presets grid
- Add preset editor with full configuration
- Add collapse/expand functionality to left panel
- Always show on/off presets in presets list
- Highlight active preset matching current tab settings
- Add 'Create from Current' button in preset editor
2026-01-08 21:45:55 +13:00
ce3b9f4ea5 Add profile deletion feature
- Added DELETE endpoint /api/profiles/<profile_name> to delete profiles
- Prevent deletion of the only remaining profile
- Clear current profile state if the active profile is deleted
- Added Delete button next to each profile in the Profiles modal
- Added confirmation dialog before deleting profiles
- Automatically refresh profile list after deletion
2026-01-05 23:09:10 +13:00
40cfe19759 Add profile color palette feature with quick-select modal
- Added per-profile color palette storage in profile JSON files
- Created Color Palette modal for managing profile colors
- Added quick-select modal window when clicking color pickers
- Implemented palette color selection to apply to active tab colors
- Added 'Use Color Picker' button in quick palette modal
- Fixed pattern selection to properly update UI
- Improved color picker interaction to prevent conflicts between quick palette and native picker
2026-01-05 22:42:58 +13:00
c97ca308a7 Add profile persistence for color changes and data saving
- Added save_current_profile() function to persist lights data to profile files
- Updated all endpoints to save to profile files after changes
- Ensures color changes, pattern changes, and tab modifications are persisted
- Data now saves to both settings.json (patterns) and profile files (lights data)
2026-01-04 16:07:54 +13:00
5aa500a7fb Convert app to Flask web application with color pickers
- Created Flask backend with REST API endpoints
- Built HTML/CSS/JavaScript frontend
- Replaced RGB sliders with color pickers for each palette color
- Reorganized layout: color palette on left, patterns on right
- Added persistence for color changes
- Integrated WebSocket client for lighting controller communication
- Added tab management, profile support, and pattern selection
2026-01-04 15:59:19 +13:00
c8ae113355 Remove associated names label and always show n parameter inputs 2025-11-30 17:23:32 +13:00
2db2d9e120 Fix bottom menu buttons visibility by adjusting packing order 2025-11-30 17:07:21 +13:00
42575b9d2e Fix profile loading to not modify settings.json, preserve patterns 2025-11-30 17:03:43 +13:00
517750e5f6 Add patterns configuration to settings.json 2025-11-30 16:52:09 +13:00
5e4798a9dc Remove scrolling and fix empty space, restore patterns to settings.json 2025-11-30 16:49:54 +13:00
fb4944e475 Add screen resolution scaling and move tab buttons to bottom 2025-11-30 16:44:14 +13:00
c5a76c24a7 Move patterns to settings.json and remove patterns.json 2025-11-30 16:31:22 +13:00
ce8596ca58 Add tab management, profiles, and pattern-specific delay ranges 2025-11-30 16:23:08 +13:00
92526ab05c Move patterns to separate patterns.json file 2025-11-30 14:43:22 +13:00
8dabf852ba Show descriptive names for n parameters based on selected pattern 2025-11-30 13:27:35 +13:00
e803dd4243 Update main 2025-11-21 16:18:08 +13:00
baf3d0b0ff Update settings 2025-11-21 16:17:42 +13:00
7f43b93cb7 Update UI: shorter sliders, add n1-n6 inputs, logarithmic delay scale 2025-11-19 23:05:51 +13:00
9fc58a827b Move to src. Add midi and sound 2025-09-07 21:15:42 +12:00
25 changed files with 11076 additions and 921 deletions

13
Pipfile
View File

@@ -5,8 +5,21 @@ name = "pypi"
[packages]
websockets = "*"
watchfiles = "*"
async-tkinter-loop = "*"
mido = "*"
python-rtmidi = "*"
pyaudio = "*"
aubio = "*"
websocket-client = "*"
microdot = "*"
[dev-packages]
[requires]
python_version = "3.12"
[scripts]
main = "python src/main.py"
dev = 'watchfiles "python src/main.py" src'
web = "python run_web.py"

321
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "f7607d6d57851b07acb91db3698117d8af9ab03600cda23fd56c9ab927904d31"
"sha256": "4aaef0c08e86d190f036736e98ff0e932788c7b461e725840de5699a8758b9d5"
},
"pipfile-spec": 6,
"requires": {
@@ -16,6 +16,325 @@
]
},
"default": {
"anyio": {
"hashes": [
"sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6",
"sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"
],
"markers": "python_version >= '3.9'",
"version": "==4.10.0"
},
"async-tkinter-loop": {
"hashes": [
"sha256:4c69f46ffdbac48dc44c296c3a7b00c4cc4f852c3d43aa2fa329991d1fadea02",
"sha256:509c418139847bcb2e47a5a6b6d24a2e2dca290bc468dad6b6b8029e8a865bfd"
],
"index": "pypi",
"markers": "python_version >= '3.8' and python_version < '4.0'",
"version": "==0.9.3"
},
"aubio": {
"hashes": [
"sha256:df1244f6c4cf5bea382c8c2d35aa43bc31f4cf631fe325ae3992c219546a4202"
],
"index": "pypi",
"version": "==0.4.9"
},
"idna": {
"hashes": [
"sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9",
"sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"
],
"markers": "python_version >= '3.6'",
"version": "==3.10"
},
"mido": {
"hashes": [
"sha256:01033c9b10b049e4436fca2762194ca839b09a4334091dd3c34e7f4ae674fd8a",
"sha256:1aecb30b7f282404f17e43768cbf74a6a31bf22b3b783bdd117a1ce9d22cb74c"
],
"index": "pypi",
"markers": "python_version ~= '3.7'",
"version": "==1.3.3"
},
"numpy": {
"hashes": [
"sha256:07b62978075b67eee4065b166d000d457c82a1efe726cce608b9db9dd66a73a5",
"sha256:087ffc25890d89a43536f75c5fe8770922008758e8eeeef61733957041ed2f9b",
"sha256:092aeb3449833ea9c0bf0089d70c29ae480685dd2377ec9cdbbb620257f84631",
"sha256:095737ed986e00393ec18ec0b21b47c22889ae4b0cd2d5e88342e08b01141f58",
"sha256:0a4f2021a6da53a0d580d6ef5db29947025ae8b35b3250141805ea9a32bbe86b",
"sha256:103ea7063fa624af04a791c39f97070bf93b96d7af7eb23530cd087dc8dbe9dc",
"sha256:11e58218c0c46c80509186e460d79fbdc9ca1eb8d8aee39d8f2dc768eb781089",
"sha256:122bf5ed9a0221b3419672493878ba4967121514b1d7d4656a7580cd11dddcbf",
"sha256:14a91ebac98813a49bc6aa1a0dfc09513dcec1d97eaf31ca21a87221a1cdcb15",
"sha256:1f91e5c028504660d606340a084db4b216567ded1056ea2b4be4f9d10b67197f",
"sha256:20b8200721840f5621b7bd03f8dcd78de33ec522fc40dc2641aa09537df010c3",
"sha256:240259d6564f1c65424bcd10f435145a7644a65a6811cfc3201c4a429ba79170",
"sha256:2738534837c6a1d0c39340a190177d7d66fdf432894f469728da901f8f6dc910",
"sha256:27c9f90e7481275c7800dc9c24b7cc40ace3fdb970ae4d21eaff983a32f70c91",
"sha256:293b2192c6bcce487dbc6326de5853787f870aeb6c43f8f9c6496db5b1781e45",
"sha256:2c3271cc4097beb5a60f010bcc1cc204b300bb3eafb4399376418a83a1c6373c",
"sha256:2f4f0215edb189048a3c03bd5b19345bdfa7b45a7a6f72ae5945d2a28272727f",
"sha256:3dcf02866b977a38ba3ec10215220609ab9667378a9e2150615673f3ffd6c73b",
"sha256:4209f874d45f921bde2cff1ffcd8a3695f545ad2ffbef6d3d3c6768162efab89",
"sha256:448a66d052d0cf14ce9865d159bfc403282c9bc7bb2a31b03cc18b651eca8b1a",
"sha256:4ae6863868aaee2f57503c7a5052b3a2807cf7a3914475e637a0ecd366ced220",
"sha256:4d002ecf7c9b53240be3bb69d80f86ddbd34078bae04d87be81c1f58466f264e",
"sha256:4e6ecfeddfa83b02318f4d84acf15fbdbf9ded18e46989a15a8b6995dfbf85ab",
"sha256:508b0eada3eded10a3b55725b40806a4b855961040180028f52580c4729916a2",
"sha256:546aaf78e81b4081b2eba1d105c3b34064783027a06b3ab20b6eba21fb64132b",
"sha256:572d5512df5470f50ada8d1972c5f1082d9a0b7aa5944db8084077570cf98370",
"sha256:5ad4ebcb683a1f99f4f392cc522ee20a18b2bb12a2c1c42c3d48d5a1adc9d3d2",
"sha256:66459dccc65d8ec98cc7df61307b64bf9e08101f9598755d42d8ae65d9a7a6ee",
"sha256:6936aff90dda378c09bea075af0d9c675fe3a977a9d2402f95a87f440f59f619",
"sha256:69779198d9caee6e547adb933941ed7520f896fd9656834c300bdf4dd8642712",
"sha256:6f1ae3dcb840edccc45af496f312528c15b1f79ac318169d094e85e4bb35fdf1",
"sha256:71669b5daae692189540cffc4c439468d35a3f84f0c88b078ecd94337f6cb0ec",
"sha256:72c6df2267e926a6d5286b0a6d556ebe49eae261062059317837fda12ddf0c1a",
"sha256:72dbebb2dcc8305c431b2836bcc66af967df91be793d63a24e3d9b741374c450",
"sha256:754d6755d9a7588bdc6ac47dc4ee97867271b17cee39cb87aef079574366db0a",
"sha256:76c3e9501ceb50b2ff3824c3589d5d1ab4ac857b0ee3f8f49629d0de55ecf7c2",
"sha256:7a0e27186e781a69959d0230dd9909b5e26024f8da10683bd6344baea1885168",
"sha256:7d6e390423cc1f76e1b8108c9b6889d20a7a1f59d9a60cac4a050fa734d6c1e2",
"sha256:8145dd6d10df13c559d1e4314df29695613575183fa2e2d11fac4c208c8a1f73",
"sha256:8446acd11fe3dc1830568c941d44449fd5cb83068e5c70bd5a470d323d448296",
"sha256:852ae5bed3478b92f093e30f785c98e0cb62fa0a939ed057c31716e18a7a22b9",
"sha256:87c930d52f45df092f7578889711a0768094debf73cfcde105e2d66954358125",
"sha256:8b1224a734cd509f70816455c3cffe13a4f599b1bf7130f913ba0e2c0b2006c0",
"sha256:8dc082ea901a62edb8f59713c6a7e28a85daddcb67454c839de57656478f5b19",
"sha256:906a30249315f9c8e17b085cc5f87d3f369b35fedd0051d4a84686967bdbbd0b",
"sha256:938065908d1d869c7d75d8ec45f735a034771c6ea07088867f713d1cd3bbbe4f",
"sha256:9c144440db4bf3bb6372d2c3e49834cc0ff7bb4c24975ab33e01199e645416f2",
"sha256:9e196ade2400c0c737d93465327d1ae7c06c7cb8a1756121ebf54b06ca183c7f",
"sha256:a3ef07ec8cbc8fc9e369c8dcd52019510c12da4de81367d8b20bc692aa07573a",
"sha256:a7af9ed2aa9ec5950daf05bb11abc4076a108bd3c7db9aa7251d5f107079b6a6",
"sha256:a9f66e7d2b2d7712410d3bc5684149040ef5f19856f20277cd17ea83e5006286",
"sha256:aa098a5ab53fa407fded5870865c6275a5cd4101cfdef8d6fafc48286a96e981",
"sha256:af58de8745f7fa9ca1c0c7c943616c6fe28e75d0c81f5c295810e3c83b5be92f",
"sha256:b05a89f2fb84d21235f93de47129dd4f11c16f64c87c33f5e284e6a3a54e43f2",
"sha256:b5e40e80299607f597e1a8a247ff8d71d79c5b52baa11cc1cce30aa92d2da6e0",
"sha256:b9d0878b21e3918d76d2209c924ebb272340da1fb51abc00f986c258cd5e957b",
"sha256:bc3186bea41fae9d8e90c2b4fb5f0a1f5a690682da79b92574d63f56b529080b",
"sha256:c63d95dc9d67b676e9108fe0d2182987ccb0f11933c1e8959f42fa0da8d4fa56",
"sha256:c771cfac34a4f2c0de8e8c97312d07d64fd8f8ed45bc9f5726a7e947270152b5",
"sha256:c8d9727f5316a256425892b043736d63e89ed15bbfe6556c5ff4d9d4448ff3b3",
"sha256:cbc95b3813920145032412f7e33d12080f11dc776262df1712e1638207dde9e8",
"sha256:cefc2219baa48e468e3db7e706305fcd0c095534a192a08f31e98d83a7d45fb0",
"sha256:d95f59afe7f808c103be692175008bab926b59309ade3e6d25009e9a171f7036",
"sha256:dd937f088a2df683cbb79dda9a772b62a3e5a8a7e76690612c2737f38c6ef1b6",
"sha256:de6ea4e5a65d5a90c7d286ddff2b87f3f4ad61faa3db8dabe936b34c2275b6f8",
"sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48",
"sha256:ee807923782faaf60d0d7331f5e86da7d5e3079e28b291973c545476c2b00d07",
"sha256:efc81393f25f14d11c9d161e46e6ee348637c0a1e8a54bf9dedc472a3fae993b",
"sha256:f0a1a8476ad77a228e41619af2fa9505cf69df928e9aaa165746584ea17fed2b",
"sha256:f75018be4980a7324edc5930fe39aa391d5734531b1926968605416ff58c332d",
"sha256:f92d6c2a8535dc4fe4419562294ff957f83a16ebdec66df0805e473ffaad8bd0",
"sha256:fb1752a3bb9a3ad2d6b090b88a9a0ae1cd6f004ef95f75825e2f382c183b2097",
"sha256:fc927d7f289d14f5e037be917539620603294454130b6de200091e23d27dc9be",
"sha256:fed5527c4cf10f16c6d0b6bee1f89958bccb0ad2522c8cadc2efd318bcd545f5"
],
"markers": "python_version >= '3.11'",
"version": "==2.3.2"
},
"packaging": {
"hashes": [
"sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484",
"sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"
],
"markers": "python_version >= '3.8'",
"version": "==25.0"
},
"pyaudio": {
"hashes": [
"sha256:009f357ee5aa6bc8eb19d69921cd30e98c42cddd34210615d592a71d09c4bd57",
"sha256:126065b5e82a1c03ba16e7c0404d8f54e17368836e7d2d92427358ad44fefe61",
"sha256:12f2f1ba04e06ff95d80700a78967897a489c05e093e3bffa05a84ed9c0a7fa3",
"sha256:2a166fc88d435a2779810dd2678354adc33499e9d4d7f937f28b20cc55893e83",
"sha256:2dac0d6d675fe7e181ba88f2de88d321059b69abd52e3f4934a8878e03a7a074",
"sha256:506b32a595f8693811682ab4b127602d404df7dfc453b499c91a80d0f7bad289",
"sha256:5fce4bcdd2e0e8c063d835dbe2860dac46437506af509353c7f8114d4bacbd5b",
"sha256:692d8c1446f52ed2662120bcd9ddcb5aa2b71f38bda31e58b19fb4672fffba69",
"sha256:78dfff3879b4994d1f4fc6485646a57755c6ee3c19647a491f790a0895bd2f87",
"sha256:858caf35b05c26d8fc62f1efa2e8f53d5fa1a01164842bd622f70ddc41f55000",
"sha256:95328285b4dab57ea8c52a4a996cb52be6d629353315be5bfda403d15932a497",
"sha256:bbeb01d36a2f472ae5ee5e1451cacc42112986abe622f735bb870a5db77cf903",
"sha256:f745109634a7c19fa4d6b8b7d6967c3123d988c9ade0cd35d4295ee1acdb53e9"
],
"index": "pypi",
"version": "==0.2.14"
},
"python-rtmidi": {
"hashes": [
"sha256:052c89933cae4fca354012d8ca7248f4f9e1e3f062471409d48415a7f7d7e59e",
"sha256:1d5da765184150fb946043d59be4039b36a8060ede025f109ef20492dbf99075",
"sha256:25f5a5db7be98911c41ca5bebb262fcf9a7c89600b88fd3c207ceafd3101e721",
"sha256:26149186367341bf5b0a3ac17b495f6a25950bd3da6b4f13d25ac0a9ce8208dd",
"sha256:271d625c489fffb39b3edc5aba67f7c8e29a04a0a0f056ce19e5a888a08b4c59",
"sha256:29661939f9b7bd1a4e29835f50f4790e741dacd21a5cb143297aefb51deefdec",
"sha256:29d9c9d9f82ce679fecad7bb4cb79f3a24574ea84600e377194b4cc1baacec0e",
"sha256:30d117193dcad8af67c600c405f53eb096e4ff84849760be14c97270af334922",
"sha256:46bbf32c8a4bf6c8f0df1c02a68689d0757f13cb7a69f27ccbbed3d7b2365918",
"sha256:4e234dca7f9d783dd3f1e9c9c5c2f295f02b7af3085301d6eed3b428cf49d327",
"sha256:5443634597eb340cdec0734f76267a827c2d366f00a6f9195141c78828016ac2",
"sha256:5966172ed28add6ff2b76d389702931bfc7ff3cc741c0e4b0d1aaae269ab7a8e",
"sha256:7bce7f17c71a71d8ef0bfeae3cb8a7652dd02f0d5067de882e1ee44eb38518db",
"sha256:7f9ade68b068ae09000ecb562ae9521da3a234361ad5449e83fc734544d004fa",
"sha256:82e61bc1b51aa91d9e615827056e80f78dbe364248eecd61698b233f7af903f6",
"sha256:844bd12840c9d4e03dfc89b2cd57c55dcbf5ed7246504d69c6c661732249b19c",
"sha256:878ce085dfb65c0974810a7e919f73708cbb4c0430c7924b78f25aea1dd4ebee",
"sha256:8bbaf7c7164471712a93ac60c8f9ed146b336a294a5103223bbaf8f10709a0bf",
"sha256:a5582983ad57ea7f0a7797ddc3e258efb00f8326113b6ddfa85b5165a4151806",
"sha256:a706e9850e22acc57fa840c60fdc4541baafe462a05ff7631a6d9eb91c65e171",
"sha256:c60dd180e5130fb87571e71aea30e2ef0512131aab45865a7d67063ed8e52ca4",
"sha256:cec30924e305f55284594ccf35a71dee7216fd308dfa2dec1b3ed03e6f243803",
"sha256:cfea32c91752fa7aecfe3d6827535c190ba0e646a9accd6604f4fc70cf4b780f",
"sha256:dd2bcbea822488fca6b8d9fc7e78a91da12914f3b88dc086f051cb65a643449f",
"sha256:efc07413b30b0039c0d35abe25a81d740c7405124eb58eed141a8f24388e6fe0",
"sha256:f2138005c6bd3d8b9af05df383679f6d0827d16056e68a941110732310dcb7dd"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==1.5.8"
},
"sniffio": {
"hashes": [
"sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2",
"sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"
],
"markers": "python_version >= '3.7'",
"version": "==1.3.1"
},
"typing-extensions": {
"hashes": [
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
],
"markers": "python_version >= '3.9'",
"version": "==4.15.0"
},
"watchfiles": {
"hashes": [
"sha256:00645eb79a3faa70d9cb15c8d4187bb72970b2470e938670240c7998dad9f13a",
"sha256:04e4ed5d1cd3eae68c89bcc1a485a109f39f2fd8de05f705e98af6b5f1861f1f",
"sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6",
"sha256:0ece16b563b17ab26eaa2d52230c9a7ae46cf01759621f4fbbca280e438267b3",
"sha256:11ee4444250fcbeb47459a877e5e80ed994ce8e8d20283857fc128be1715dac7",
"sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a",
"sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259",
"sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297",
"sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1",
"sha256:199207b2d3eeaeb80ef4411875a6243d9ad8bc35b07fc42daa6b801cc39cc41c",
"sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a",
"sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b",
"sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb",
"sha256:27f30e14aa1c1e91cb653f03a63445739919aef84c8d2517997a83155e7a2fcc",
"sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b",
"sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339",
"sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9",
"sha256:3366f56c272232860ab45c77c3ca7b74ee819c8e1f6f35a7125556b198bbc6df",
"sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb",
"sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4",
"sha256:3a6fd40bbb50d24976eb275ccb55cd1951dfb63dbc27cae3066a6ca5f4beabd5",
"sha256:3aba215958d88182e8d2acba0fdaf687745180974946609119953c0e112397dc",
"sha256:406520216186b99374cdb58bc48e34bb74535adec160c8459894884c983a149c",
"sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8",
"sha256:42f92befc848bb7a19658f21f3e7bae80d7d005d13891c62c2cd4d4d0abb3433",
"sha256:48aa25e5992b61debc908a61ab4d3f216b64f44fdaa71eb082d8b2de846b7d12",
"sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30",
"sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0",
"sha256:51556d5004887045dba3acdd1fdf61dddea2be0a7e18048b5e853dcd37149b86",
"sha256:51b81e55d40c4b4aa8658427a3ee7ea847c591ae9e8b81ef94a90b668999353c",
"sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5",
"sha256:54062ef956807ba806559b3c3d52105ae1827a0d4ab47b621b31132b6b7e2866",
"sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb",
"sha256:622d6b2c06be19f6e89b1d951485a232e3b59618def88dbeda575ed8f0d8dbf2",
"sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e",
"sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575",
"sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f",
"sha256:7049e52167fc75fc3cc418fc13d39a8e520cbb60ca08b47f6cedb85e181d2f2a",
"sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f",
"sha256:7738027989881e70e3723c75921f1efa45225084228788fc59ea8c6d732eb30d",
"sha256:7a7bd57a1bb02f9d5c398c0c1675384e7ab1dd39da0ca50b7f09af45fa435277",
"sha256:7b3443f4ec3ba5aa00b0e9fa90cf31d98321cbff8b925a7c7b84161619870bc9",
"sha256:7c55b0f9f68590115c25272b06e63f0824f03d4fc7d6deed43d8ad5660cabdbf",
"sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92",
"sha256:8076a5769d6bdf5f673a19d51da05fc79e2bbf25e9fe755c47595785c06a8c72",
"sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b",
"sha256:8412eacef34cae2836d891836a7fff7b754d6bcac61f6c12ba5ca9bc7e427b68",
"sha256:865c8e95713744cf5ae261f3067861e9da5f1370ba91fc536431e29b418676fa",
"sha256:86b1e28d4c37e89220e924305cd9f82866bb0ace666943a6e4196c5df4d58dcc",
"sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b",
"sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd",
"sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4",
"sha256:90ebb429e933645f3da534c89b29b665e285048973b4d2b6946526888c3eb2c7",
"sha256:923fec6e5461c42bd7e3fd5ec37492c6f3468be0499bc0707b4bbbc16ac21792",
"sha256:935f9edd022ec13e447e5723a7d14456c8af254544cefbc533f6dd276c9aa0d9",
"sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0",
"sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297",
"sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef",
"sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179",
"sha256:9f811079d2f9795b5d48b55a37aa7773680a5659afe34b54cc1d86590a51507d",
"sha256:a2726d7bfd9f76158c84c10a409b77a320426540df8c35be172444394b17f7ea",
"sha256:a479466da6db5c1e8754caee6c262cd373e6e6c363172d74394f4bff3d84d7b5",
"sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee",
"sha256:a89c75a5b9bc329131115a409d0acc16e8da8dfd5867ba59f1dd66ae7ea8fa82",
"sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011",
"sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e",
"sha256:aa0cc8365ab29487eb4f9979fd41b22549853389e22d5de3f134a6796e1b05a4",
"sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf",
"sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db",
"sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20",
"sha256:b7c5f6fe273291f4d414d55b2c80d33c457b8a42677ad14b4b47ff025d0893e4",
"sha256:b915daeb2d8c1f5cee4b970f2e2c988ce6514aace3c9296e58dd64dc9aa5d575",
"sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa",
"sha256:bda8136e6a80bdea23e5e74e09df0362744d24ffb8cd59c4a95a6ce3d142f79c",
"sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f",
"sha256:c588c45da9b08ab3da81d08d7987dae6d2a3badd63acdb3e206a42dbfa7cb76f",
"sha256:c600e85f2ffd9f1035222b1a312aff85fd11ea39baff1d705b9b047aad2ce267",
"sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018",
"sha256:c9649dfc57cc1f9835551deb17689e8d44666315f2e82d337b9f07bd76ae3aa2",
"sha256:cb45350fd1dc75cd68d3d72c47f5b513cb0578da716df5fba02fff31c69d5f2d",
"sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd",
"sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47",
"sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb",
"sha256:cd17a1e489f02ce9117b0de3c0b1fab1c3e2eedc82311b299ee6b6faf6c23a29",
"sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147",
"sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8",
"sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670",
"sha256:d1caf40c1c657b27858f9774d5c0e232089bca9cb8ee17ce7478c6e9264d2587",
"sha256:d7642b9bc4827b5518ebdb3b82698ada8c14c7661ddec5fe719f3e56ccd13c97",
"sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c",
"sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5",
"sha256:da71945c9ace018d8634822f16cbc2a78323ef6c876b1d34bbf5d5222fd6a72e",
"sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e",
"sha256:df32d59cb9780f66d165a9a7a26f19df2c7d24e3bd58713108b41d0ff4f929c6",
"sha256:df670918eb7dd719642e05979fc84704af913d563fd17ed636f7c4783003fdcc",
"sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e",
"sha256:ed8fc66786de8d0376f9f913c09e963c66e90ced9aa11997f93bdb30f7c872a8",
"sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895",
"sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7",
"sha256:f2bcdc54ea267fe72bfc7d83c041e4eb58d7d8dc6f578dfddb52f037ce62f432",
"sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc",
"sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633",
"sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f",
"sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77",
"sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12",
"sha256:fe4371595edf78c41ef8ac8df20df3943e13defd0efcb732b2e393b5a8a7a71f"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==1.1.0"
},
"websocket-client": {
"hashes": [
"sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526",
"sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==1.8.0"
},
"websockets": {
"hashes": [
"sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2",

745
main.py
View File

@@ -1,745 +0,0 @@
import asyncio
import tkinter as tk
from tkinter import ttk, messagebox # Import messagebox for confirmations
import json
from async_tkinter_loop import async_handler, async_mainloop
from networking import WebSocketClient
import color_utils
from settings import Settings
import time
# Dark theme colors (unchanged)
bg_color = "#2e2e2e"
fg_color = "white"
trough_color_red = "#4a0000"
trough_color_green = "#004a00"
trough_color_blue = "#00004a"
trough_color_brightness = "#4a4a4a"
trough_color_delay = "#4a4a4a"
active_bg_color = "#4a4a4a"
highlight_pattern_color = "#6a5acd"
# New color for active color in palette
active_palette_color_border = "#FFD700" # Gold color
class App:
def __init__(self) -> None:
self.settings = Settings()
self.root = tk.Tk()
self.root.attributes("-fullscreen", True)
self.root.configure(bg=bg_color)
# Debouncing variables (remain the same)
self.last_rgb_update_time = 0
self.rgb_update_interval_ms = 100
self.pending_rgb_update_id = None
self.last_brightness_update_time = 0
self.brightness_update_interval_ms = 100
self.pending_brightness_update_id = None
self.last_delay_update_time = 0
self.delay_update_interval_ms = 100
self.pending_delay_update_id = None
# --- WebSocketClient ---
self.websocket_client = WebSocketClient("ws://192.168.4.1:80/ws")
self.root.after(100, async_handler(self.websocket_client.connect))
# Configure ttk style (unchanged)
style = ttk.Style()
style.theme_use("alt")
style.configure(".", background=bg_color, foreground=fg_color, font=("Arial", 14))
style.configure("TNotebook", background=bg_color, borderwidth=0)
style.configure(
"TNotebook.Tab", background=bg_color, foreground=fg_color, font=("Arial", 30), padding=[10, 5]
)
style.map("TNotebook.Tab", background=[("selected", active_bg_color)], foreground=[("selected", fg_color)])
style.configure("TFrame", background=bg_color)
# Create Notebook for tabs (unchanged)
self.notebook = ttk.Notebook(self.root)
self.notebook.pack(expand=1, fill="both")
self.tabs = {}
self.create_tabs()
self.notebook.bind("<<NotebookTabChanged>>", self.on_tab_change)
# Add Reload Config Button (unchanged)
reload_button = tk.Button(
self.root,
text="Reload Config",
command=self.reload_config,
bg=active_bg_color,
fg=fg_color,
font=("Arial", 20),
padx=20,
pady=10,
relief=tk.FLAT,
)
reload_button.pack(side=tk.BOTTOM, pady=20)
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
async_mainloop(self.root)
def on_closing(self):
print("Closing application...")
asyncio.create_task(self.websocket_client.close())
self.root.destroy()
def create_tabs(self):
for tab_name in list(self.tabs.keys()):
self.notebook.forget(self.tabs[tab_name])
del self.tabs[tab_name]
for key, value in self.settings["lights"].items():
tab = ttk.Frame(self.notebook)
self.notebook.add(tab, text=key)
self.create_light_control_widgets(tab, key, value["names"], value["settings"])
self.tabs[key] = tab
def create_light_control_widgets(self, tab, tab_name, ids, initial_settings):
slider_length = 800
slider_width = 50
# Extract initial color, brightness, and delay
initial_colors = initial_settings.get("colors", ["#000000"])
initial_hex_color = initial_colors[0] if initial_colors else "#000000"
initial_brightness = initial_settings.get("brightness", 127)
initial_delay = initial_settings.get("delay", 0)
initial_pattern = initial_settings.get("pattern", "on")
initial_r, initial_g, initial_b = color_utils.hex_to_rgb(initial_hex_color)
# Main frame to hold everything within the tab
main_tab_frame = tk.Frame(tab, bg=bg_color)
main_tab_frame.pack(expand=True, fill="both", padx=10, pady=10)
# Left panel for sliders
slider_panel_frame = tk.Frame(main_tab_frame, bg=bg_color)
slider_panel_frame.pack(side=tk.LEFT, padx=10, pady=10)
# Common slider configuration
slider_config = {
"from_": 255,
"to": 0,
"orient": tk.VERTICAL,
"length": slider_length,
"width": slider_width,
"bg": bg_color,
"fg": fg_color,
"highlightbackground": bg_color,
"activebackground": active_bg_color,
"resolution": 1,
"sliderlength": 70,
}
# Red Slider
red_slider = tk.Scale(slider_panel_frame, label="Red", troughcolor=trough_color_red, **slider_config)
red_slider.set(initial_r)
red_slider.pack(side=tk.LEFT, padx=10)
red_slider.bind("<B1-Motion>", lambda _: self.schedule_update_rgb(tab))
red_slider.bind("<ButtonRelease-1>", lambda _: self.schedule_update_rgb(tab, force_send=True))
# Green Slider
green_slider = tk.Scale(slider_panel_frame, label="Green", troughcolor=trough_color_green, **slider_config)
green_slider.set(initial_g)
green_slider.pack(side=tk.LEFT, padx=10)
green_slider.bind("<B1-Motion>", lambda _: self.schedule_update_rgb(tab))
green_slider.bind("<ButtonRelease-1>", lambda _: self.schedule_update_rgb(tab, force_send=True))
# Blue Slider
blue_slider = tk.Scale(slider_panel_frame, label="Blue", troughcolor=trough_color_blue, **slider_config)
blue_slider.set(initial_b)
blue_slider.pack(side=tk.LEFT, padx=10)
blue_slider.bind("<B1-Motion>", lambda _: self.schedule_update_rgb(tab))
blue_slider.bind("<ButtonRelease-1>", lambda _: self.schedule_update_rgb(tab, force_send=True))
# Brightness Slider
brightness_slider = tk.Scale(
slider_panel_frame, label="Brightness", troughcolor=trough_color_brightness, **slider_config
)
brightness_slider.set(initial_brightness)
brightness_slider.pack(side=tk.LEFT, padx=10)
brightness_slider.bind("<B1-Motion>", lambda _: self.schedule_update_brightness(tab))
brightness_slider.bind("<ButtonRelease-1>", lambda _: self.schedule_update_brightness(tab, force_send=True))
# Delay Slider
delay_slider_config = slider_config.copy()
delay_slider_config.update(
{
"from_": 1000,
"to": 0,
"resolution": 10,
"label": "Delay (ms)",
"troughcolor": trough_color_delay,
}
)
delay_slider = tk.Scale(slider_panel_frame, **delay_slider_config)
delay_slider.set(initial_delay)
delay_slider.pack(side=tk.LEFT, padx=10)
delay_slider.bind("<B1-Motion>", lambda _: self.schedule_update_delay(tab))
delay_slider.bind("<ButtonRelease-1>", lambda _: self.schedule_update_delay(tab, force_send=True))
# Store references to widgets for this tab
tab.widgets = {
"red_slider": red_slider,
"green_slider": green_slider,
"blue_slider": blue_slider,
"brightness_slider": brightness_slider,
"delay_slider": delay_slider,
"selected_color_index": 0, # Default to the first color
}
tab.colors_in_palette = initial_colors.copy() # Store the list of hex colors for this tab
tab.color_swatch_frames = [] # To hold references to the color swatches
# Right panel for IDs, Patterns, and NEW Color Palette
right_panel_frame = tk.Frame(main_tab_frame, bg=bg_color)
right_panel_frame.pack(side=tk.LEFT, padx=20, pady=10, anchor="n", expand=True, fill="both")
# IDs section - MODIFIED TO BE SIDE-BY-SIDE
ids_frame = tk.Frame(right_panel_frame, bg=bg_color)
ids_frame.pack(pady=10, fill=tk.X)
tk.Label(ids_frame, text="Associated Names:", font=("Arial", 20), bg=bg_color, fg=fg_color).pack(pady=10)
# New inner frame for the IDs to be displayed horizontally
ids_inner_frame = tk.Frame(ids_frame, bg=bg_color)
ids_inner_frame.pack(fill=tk.X, expand=True) # Pack this frame to fill available width
for light_id in ids:
tk.Label(ids_inner_frame, text=str(light_id), font=("Arial", 18), bg=bg_color, fg=fg_color).pack(
side=tk.LEFT, padx=5, pady=2
) # Pack labels horizontally
# --- New Frame to hold Patterns and Color Palette side-by-side ---
patterns_and_palette_frame = tk.Frame(right_panel_frame, bg=bg_color)
patterns_and_palette_frame.pack(pady=20, fill=tk.BOTH, expand=True)
# Patterns section
patterns_frame = tk.Frame(patterns_and_palette_frame, bg=bg_color, bd=2, relief=tk.GROOVE)
patterns_frame.pack(side=tk.LEFT, padx=10, pady=5, fill=tk.BOTH, expand=True) # Pack to the left
tk.Label(patterns_frame, text="Patterns:", font=("Arial", 20), bg=bg_color, fg=fg_color).pack(pady=10)
tab.pattern_buttons = {}
patterns = self.settings.get("patterns", [])
for pattern_name in patterns:
button = tk.Button(
patterns_frame,
text=pattern_name,
command=lambda p=pattern_name: self.send_pattern(tab_name, p),
bg=active_bg_color,
fg=fg_color,
font=("Arial", 18),
padx=15,
pady=5,
relief=tk.FLAT,
)
button.pack(pady=5, fill=tk.X)
tab.pattern_buttons[pattern_name] = button
self.highlight_pattern_button(tab, initial_pattern)
# --- Color Palette Editor Section ---
color_palette_editor_frame = tk.Frame(patterns_and_palette_frame, bg=bg_color, bd=2, relief=tk.GROOVE)
color_palette_editor_frame.pack(side=tk.LEFT, padx=10, pady=5, fill=tk.BOTH, expand=True) # Pack to the left
tab.color_palette_editor_frame = color_palette_editor_frame # Store reference for update_ui_for_pattern
tk.Label(color_palette_editor_frame, text="Color Palette:", font=("Arial", 20), bg=bg_color, fg=fg_color).pack(
pady=10
)
# Frame to hold color swatches (will be dynamic)
tab.color_swatches_container = tk.Frame(color_palette_editor_frame, bg=bg_color)
tab.color_swatches_container.pack(pady=5, fill=tk.BOTH, expand=True)
# Buttons for Add/Remove Color
palette_buttons_frame = tk.Frame(color_palette_editor_frame, bg=bg_color)
palette_buttons_frame.pack(pady=10, fill=tk.X)
add_color_button = tk.Button(
palette_buttons_frame,
text="Add Color",
command=lambda t=tab: self.add_color_to_palette(t),
bg=active_bg_color,
fg=fg_color,
font=("Arial", 16),
padx=10,
pady=5,
relief=tk.FLAT,
)
add_color_button.pack(side=tk.LEFT, expand=True, padx=5)
remove_color_button = tk.Button(
palette_buttons_frame,
text="Remove Selected",
command=lambda t=tab: self.remove_selected_color_from_palette(t),
bg=active_bg_color,
fg=fg_color,
font=("Arial", 16),
padx=10,
pady=5,
relief=tk.FLAT,
)
remove_color_button.pack(side=tk.RIGHT, expand=True, padx=5)
# Initial population of the color palette
self.refresh_color_palette_display(tab)
# The initial call to update_ui_for_pattern now only sets slider values and highlights
self.update_ui_for_pattern(tab, initial_pattern)
def refresh_color_palette_display(self, tab):
"""Clears and repopulates the color swatches in the palette display."""
# Clear existing swatches
for frame in tab.color_swatch_frames:
frame.destroy()
tab.color_swatch_frames.clear()
for i, hex_color in enumerate(tab.colors_in_palette):
swatch_frame = tk.Frame(
tab.color_swatches_container, bg=hex_color, width=100, height=50, bd=2, relief=tk.SOLID
)
swatch_frame.pack(pady=3, padx=5, fill=tk.X)
# Bind click to select this color for editing
swatch_frame.bind("<Button-1>", lambda event, idx=i, t=tab: self.select_color_in_palette(t, idx))
# Add a label inside to make it clickable too
swatch_label = tk.Label(
swatch_frame,
text=f"Color {i+1}",
bg=hex_color,
fg=color_utils.get_contrast_text_color(hex_color),
font=("Arial", 14),
width=5,
height=3,
)
swatch_label.pack(expand=True, fill=tk.BOTH)
swatch_label.bind("<Button-1>", lambda event, idx=i, t=tab: self.select_color_in_palette(t, idx))
tab.color_swatch_frames.append(swatch_frame)
# Re-highlight the currently selected color
self._highlight_selected_color_swatch(tab)
def _highlight_selected_color_swatch(self, tab):
"""Applies/removes highlight border to the selected color swatch."""
current_index = tab.widgets["selected_color_index"]
for i, swatch_frame in enumerate(tab.color_swatch_frames):
if i == current_index:
swatch_frame.config(highlightbackground=active_palette_color_border, highlightthickness=3)
else:
swatch_frame.config(highlightbackground=swatch_frame.cget("bg"), highlightthickness=0) # Reset to no highlight
def select_color_in_palette(self, tab, index: int):
"""Selects a color in the palette, updates sliders, and highlights swatch.
This now also triggers an RGB update to the device."""
if not (0 <= index < len(tab.colors_in_palette)):
return
tab.widgets["selected_color_index"] = index
self._highlight_selected_color_swatch(tab)
# Update RGB sliders with the selected color
hex_color = tab.colors_in_palette[index]
r, g, b = color_utils.hex_to_rgb(hex_color)
tab.widgets["red_slider"].set(r)
tab.widgets["green_slider"].set(g)
tab.widgets["blue_slider"].set(b)
print(f"Selected color index {index}: {hex_color}")
# Immediately send the update, as changing the selected color implies
# a desire to change the light's current color, regardless of pattern.
# This will also save the settings.
self.schedule_update_rgb(tab, force_send=True)
def add_color_to_palette(self, tab):
"""Adds a new black color to the palette and selects it, with a limit of 10 colors."""
MAX_COLORS = 8 # Define the maximum number of colors allowed
if len(tab.colors_in_palette) >= MAX_COLORS:
messagebox.showwarning("Color Limit Reached", f"You can add a maximum of {MAX_COLORS} colors to the palette.")
return
# Simplified: just add black. If unique colors were required globally,
# more complex logic would be needed here.
tab.colors_in_palette.append("#000000") # Add black as default
self.refresh_color_palette_display(tab)
# Select the newly added color
self.select_color_in_palette(tab, len(tab.colors_in_palette) - 1)
self.save_current_tab_settings() # Save changes to settings.json
def remove_selected_color_from_palette(self, tab):
"""Removes the currently selected color from the palette."""
current_index = tab.widgets["selected_color_index"]
if len(tab.colors_in_palette) <= 1:
messagebox.showwarning("Cannot Remove", "There must be at least one color in the palette.")
return
if messagebox.askyesno("Confirm Delete", f"Are you sure you want to remove Color {current_index + 1}?"):
del tab.colors_in_palette[current_index]
# Adjust selected index if the removed color was the last one
if current_index >= len(tab.colors_in_palette):
tab.widgets["selected_color_index"] = len(tab.colors_in_palette) - 1
if tab.widgets["selected_color_index"] < 0: # Should not happen with 1-color check
tab.widgets["selected_color_index"] = 0
self.refresh_color_palette_display(tab)
# Update sliders with the new selected color (if any)
if tab.colors_in_palette:
self.select_color_in_palette(tab, tab.widgets["selected_color_index"])
else: # If palette became empty (shouldn't happen with 1-color check)
tab.widgets["red_slider"].set(0)
tab.widgets["green_slider"].set(0)
tab.widgets["blue_slider"].set(0)
self.save_current_tab_settings() # Save changes to settings.json
def update_ui_for_pattern(self, tab, current_pattern: str):
"""
Manages the state of the UI elements based on the selected pattern.
The Color Palette Editor is always visible. RGB sliders update
based on the currently selected color in the palette, or the first
color if the palette is empty or not in transition mode and a new tab/pattern is selected.
"""
# The color_palette_editor_frame is always packed, so no visibility control needed here.
# When the pattern changes, we need to ensure the RGB sliders reflect
# the appropriate color based on the context.
if tab.colors_in_palette:
# If in 'transition' mode, set sliders to the currently selected color in the palette.
if current_pattern == "transition":
self.select_color_in_palette(tab, tab.widgets["selected_color_index"])
else:
# If not in 'transition' mode, but a color is selected, update sliders to that.
# Or, if this is a fresh load/tab change, ensure it's the first color.
# This ensures the sliders consistently show the color that will be sent
# for 'on'/'blink' based on the palette's first entry.
hex_color = tab.colors_in_palette[tab.widgets["selected_color_index"]]
r, g, b = color_utils.hex_to_rgb(hex_color)
tab.widgets["red_slider"].set(r)
tab.widgets["green_slider"].set(g)
tab.widgets["blue_slider"].set(b)
self._highlight_selected_color_swatch(tab) # Re-highlight even if index didn't change
else:
# Handle empty palette scenario (shouldn't happen with default ["#000000"])
tab.widgets["red_slider"].set(0)
tab.widgets["green_slider"].set(0)
tab.widgets["blue_slider"].set(0)
tab.widgets["selected_color_index"] = 0 # Ensure index is valid
self._highlight_selected_color_swatch(tab)
# Brightness and Delay sliders are always visible.
def highlight_pattern_button(self, tab_widget, active_pattern_name):
if hasattr(tab_widget, "pattern_buttons"):
for pattern_name, button in tab_widget.pattern_buttons.items():
if pattern_name == active_pattern_name:
button.config(bg=highlight_pattern_color)
else:
button.config(bg=active_bg_color)
def on_tab_change(self, event):
selected_tab_name = self.notebook.tab(self.notebook.select(), "text")
current_tab_widget = self.notebook.nametowidget(self.notebook.select())
initial_settings = self.settings["lights"][selected_tab_name]["settings"]
# Ensure current_tab_widget has the necessary attributes
if not hasattr(current_tab_widget, "colors_in_palette"):
# This tab might not have been fully initialized yet, or recreated
# In a full reload, create_tabs ensures it is.
return
# Update the local colors_in_palette list for the tab
current_tab_widget.colors_in_palette = initial_settings.get("colors", ["#000000"]).copy()
current_tab_widget.widgets["selected_color_index"] = 0 # Default to first color
# Refresh the color palette display and select the first color
self.refresh_color_palette_display(current_tab_widget)
if current_tab_widget.colors_in_palette:
self.select_color_in_palette(current_tab_widget, 0)
else: # If palette became empty (shouldn't happen with default ["#000000"])
current_tab_widget.widgets["red_slider"].set(0)
current_tab_widget.widgets["green_slider"].set(0)
current_tab_widget.widgets["blue_slider"].set(0)
# Update brightness and delay sliders
current_tab_widget.widgets["brightness_slider"].set(initial_settings.get("brightness", 127))
current_tab_widget.widgets["delay_slider"].set(initial_settings.get("delay", 0))
# Highlight the active pattern button
initial_pattern = initial_settings.get("pattern", "on")
self.highlight_pattern_button(current_tab_widget, initial_pattern)
# Update UI visibility based on the current pattern
self.update_ui_for_pattern(current_tab_widget, initial_pattern)
def reload_config(self):
print("Reloading configuration...")
self.settings = Settings()
self.create_tabs()
# After recreating, ensure the currently selected tab's sliders are updated
# Trigger on_tab_change manually for the currently selected tab
self.on_tab_change(None)
# --- Debouncing functions (no change to core logic, just how they call update_rgb) ---
def schedule_update_rgb(self, tab, force_send=False):
current_time = time.time() * 1000
if force_send:
if self.pending_rgb_update_id:
self.root.after_cancel(self.pending_rgb_update_id)
self.pending_rgb_update_id = None
self.update_rgb(tab)
self.last_rgb_update_time = current_time
elif current_time - self.last_rgb_update_time >= self.rgb_update_interval_ms:
if self.pending_rgb_update_id:
self.root.after_cancel(self.pending_rgb_update_id)
self.pending_rgb_update_id = None
self.update_rgb(tab)
self.last_rgb_update_time = current_time
else:
if self.pending_rgb_update_id:
self.root.after_cancel(self.pending_rgb_update_id)
time_to_wait = int(self.rgb_update_interval_ms - (current_time - self.last_rgb_update_time))
self.pending_rgb_update_id = self.root.after(time_to_wait, lambda: self.update_rgb(tab))
def schedule_update_brightness(self, tab, force_send=False):
current_time = time.time() * 1000
if force_send:
if self.pending_brightness_update_id:
self.root.after_cancel(self.pending_brightness_update_id)
self.pending_brightness_update_id = None
self.update_brightness(tab)
self.last_brightness_update_time = current_time
elif current_time - self.last_brightness_update_time >= self.brightness_update_interval_ms:
if self.pending_brightness_update_id:
self.root.after_cancel(self.pending_brightness_update_id)
self.pending_brightness_update_id = None
self.update_brightness(tab)
self.last_brightness_update_time = current_time
else:
if self.pending_brightness_update_id:
self.root.after_cancel(self.pending_brightness_update_id)
time_to_wait = int(self.brightness_update_interval_ms - (current_time - self.last_brightness_update_time))
self.pending_brightness_update_id = self.root.after(time_to_wait, lambda: self.update_brightness(tab))
def schedule_update_delay(self, tab, force_send=False):
current_time = time.time() * 1000
if force_send:
if self.pending_delay_update_id:
self.root.after_cancel(self.pending_delay_update_id)
self.pending_delay_update_id = None
self.update_delay(tab)
self.last_delay_update_time = current_time
elif current_time - self.last_delay_update_time >= self.delay_update_interval_ms:
if self.pending_delay_update_id:
self.root.after_cancel(self.pending_delay_update_id)
self.pending_delay_update_id = None
self.update_delay(tab)
self.last_delay_update_time = current_time
else:
if self.pending_delay_update_id:
self.root.after_cancel(self.pending_delay_update_id)
time_to_wait = int(self.delay_update_interval_ms - (current_time - self.last_delay_update_time))
self.pending_delay_update_id = self.root.after(time_to_wait, lambda: self.update_delay(tab))
# --- Asynchronous Update Functions ---
@async_handler
async def update_rgb(self, tab):
"""Update the currently selected color in the palette and send to the server."""
try:
red_slider = tab.widgets["red_slider"]
green_slider = tab.widgets["green_slider"]
blue_slider = tab.widgets["blue_slider"]
r = red_slider.get()
g = green_slider.get()
b = blue_slider.get()
hex_color = f"#{r:02x}{g:02x}{b:02x}"
print(f"Updating selected color to: {hex_color}")
selected_color_index = tab.widgets["selected_color_index"]
if 0 <= selected_color_index < len(tab.colors_in_palette):
tab.colors_in_palette[selected_color_index] = hex_color
self.refresh_color_palette_display(tab) # Update swatch immediately
selected_server = self.notebook.tab(self.notebook.select(), "text")
names = self.settings["lights"][selected_server]["names"]
# Determine which colors to send based on the current pattern.
current_pattern = self.settings["lights"][selected_server]["settings"].get("pattern", "on")
colors_to_send = []
if current_pattern == "transition":
colors_to_send = tab.colors_in_palette.copy()
elif current_pattern in ["on", "blink", "theater_chase", "flicker"]: # Add other patterns that use a single color
if tab.colors_in_palette:
# For non-transition patterns, the device typically uses only the first color.
# However, if a user picks a color from the palette, we want THAT color to be the one
# sent and active. So, the selected color from the palette *becomes* the first color
# in the list we send to the device for these modes.
# This ensures the light matches the selected palette color.
colors_to_send = [hex_color] # Send the color currently set by the sliders
else:
colors_to_send = ["#000000"] # Default if palette is empty
else: # For other patterns like "off", "rainbow" where colors might not be primary
# We still want to send the *current* palette state for saving,
# but the device firmware might ignore it for these patterns.
colors_to_send = tab.colors_in_palette.copy()
payload = {
"save": True, # Always save this change to config
"names": names,
"settings": {
"colors": colors_to_send, # This now dynamically changes based on pattern
"brightness": tab.widgets["brightness_slider"].get(),
"delay": tab.widgets["delay_slider"].get(),
"pattern": current_pattern, # Always send the current pattern
},
}
# Update the settings object with the new color list (and potentially other synced values)
self.settings["lights"][selected_server]["settings"]["colors"] = tab.colors_in_palette.copy()
self.settings["lights"][selected_server]["settings"]["brightness"] = tab.widgets["brightness_slider"].get()
self.settings["lights"][selected_server]["settings"]["delay"] = tab.widgets["delay_slider"].get()
self.settings.save()
await self.websocket_client.send_data(payload)
print(f"Sent RGB payload: {payload}")
except Exception as e:
print(f"Error updating RGB: {e}")
@async_handler
async def update_brightness(self, tab):
try:
brightness_slider = tab.widgets["brightness_slider"]
brightness = brightness_slider.get()
print(f"Brightness: {brightness}")
selected_server = self.notebook.tab(self.notebook.select(), "text")
names = self.settings["lights"][selected_server]["names"]
payload = {
"save": True,
"names": names,
"settings": {
"brightness": brightness,
},
}
# Update the settings object with the new brightness
self.settings["lights"][selected_server]["settings"]["brightness"] = brightness
self.settings.save()
await self.websocket_client.send_data(payload)
print(f"Sent brightness payload: {payload}")
except Exception as e:
print(f"Error updating brightness: {e}")
@async_handler
async def update_delay(self, tab):
try:
delay_slider = tab.widgets["delay_slider"]
delay = delay_slider.get()
print(f"Delay: {delay}")
selected_server = self.notebook.tab(self.notebook.select(), "text")
names = self.settings["lights"][selected_server]["names"]
payload = {
"save": True,
"names": names,
"settings": {
"delay": delay,
},
}
# Update the settings object with the new delay
self.settings["lights"][selected_server]["settings"]["delay"] = delay
self.settings.save()
await self.websocket_client.send_data(payload)
print(f"Sent delay payload: {payload}")
except Exception as e:
print(f"Error updating delay: {e}")
@async_handler
async def send_pattern(self, tab_name: str, pattern_name: str):
try:
names = self.settings["lights"][tab_name]["names"]
# Get the actual tab widget to access its `colors_in_palette` and other attributes
current_tab_widget = None
for key, tab_widget in self.tabs.items():
if key == tab_name:
current_tab_widget = tab_widget
break
if not current_tab_widget:
print(f"Error: Could not find tab widget for {tab_name}")
return
current_settings_for_tab = self.settings["lights"][tab_name]["settings"]
payload_settings = {
"pattern": pattern_name,
"brightness": current_settings_for_tab.get("brightness", 127),
"delay": current_settings_for_tab.get("delay", 0),
}
# Determine colors to send based on the *newly selected* pattern
if pattern_name == "transition":
payload_settings["colors"] = current_tab_widget.colors_in_palette.copy()
elif pattern_name in ["on", "blink"]: # Add other patterns that use a single color here
# When switching TO 'on' or 'blink', ensure the color sent is the one
# currently displayed on the sliders (which reflects the selected palette color).
r = current_tab_widget.widgets["red_slider"].get()
g = current_tab_widget.widgets["green_slider"].get()
b = current_tab_widget.widgets["blue_slider"].get()
hex_color_from_sliders = f"#{r:02x}{g:02x}{b:02x}"
payload_settings["colors"] = [hex_color_from_sliders]
else:
# For other patterns, send the full palette, device might ignore or use default
payload_settings["colors"] = current_tab_widget.colors_in_palette.copy()
payload = {
"save": True,
"names": names,
"settings": payload_settings,
}
# Update the settings object with the new pattern and current colors/brightness/delay
self.settings["lights"][tab_name]["settings"]["pattern"] = pattern_name
# Always save the full current palette state in settings.
self.settings["lights"][tab_name]["settings"]["colors"] = current_tab_widget.colors_in_palette.copy()
self.settings.save()
self.highlight_pattern_button(current_tab_widget, pattern_name)
self.update_ui_for_pattern(current_tab_widget, pattern_name) # Update UI based on new pattern
await self.websocket_client.send_data(payload)
print(f"Sent pattern payload: {payload}")
except Exception as e:
print(f"Error sending pattern: {e}")
def save_current_tab_settings(self):
"""Saves the current state of the active tab's settings (colors, brightness, delay, pattern) to config."""
selected_server = self.notebook.tab(self.notebook.select(), "text")
current_tab_widget = self.notebook.nametowidget(self.notebook.select())
if not hasattr(current_tab_widget, "colors_in_palette"):
return # Tab not fully initialized yet
# Update settings for the current tab in the self.settings object
self.settings["lights"][selected_server]["settings"]["colors"] = current_tab_widget.colors_in_palette
self.settings["lights"][selected_server]["settings"]["brightness"] = current_tab_widget.widgets["brightness_slider"].get()
self.settings["lights"][selected_server]["settings"]["delay"] = current_tab_widget.widgets["delay_slider"].get()
# The pattern is updated in send_pattern already, but ensure consistency
# For simplicity, we assume send_pattern is the primary way to change pattern.
self.settings.save()
print(f"Saved settings for {selected_server}")
if __name__ == "__main__":
app = App()

41
presets.json Normal file
View File

@@ -0,0 +1,41 @@
{
"blinker": {
"pattern": "blink",
"colors": [
"#12b533"
],
"brightness": 127,
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"circler": {
"pattern": "circle",
"colors": [
"#9d3434",
"#cb5d5d"
],
"brightness": 127,
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"pulser": {
"pattern": "pulse",
"colors": [
"#9f1d1d",
"#176d2d",
"#50309c"
],
"brightness": 127,
"delay": 300,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}

157
profiles/default.json Normal file
View File

@@ -0,0 +1,157 @@
{
"tab_password": "",
"color_palette": [
"#c12525",
"#246dcc"
],
"lights": {
"test": {
"names": [
"test"
],
"settings": {
"pattern": "pulse",
"brightness": 127,
"colors": [
"#000000"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"patterns": {
"on": {
"colors": [
"#000000"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"rainbow": {
"colors": [
"#000000"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"transition": {
"colors": [
"#000000"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"blink": {
"colors": [
"#12b533"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"circle": {
"colors": [
"#9d3434",
"#cb5d5d"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"pulse": {
"colors": [
"#9f1d1d",
"#176d2d",
"#50309c"
],
"delay": 300,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"test2": {
"names": [
"test"
],
"settings": {
"pattern": "pulse",
"brightness": 127,
"colors": [
"#000000"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"patterns": {
"blink": {
"colors": [
"#12b533"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"circle": {
"colors": [
"#9d3434",
"#cb5d5d"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"pulse": {
"colors": [
"#9f1d1d",
"#176d2d",
"#50309c"
],
"delay": 300,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
}
},
"tab_order": [
"test",
"test2"
]
}

26
profiles/ring.json Normal file
View File

@@ -0,0 +1,26 @@
{
"tab_password": "",
"lights": {
"dsfdfd": {
"names": [
"1"
],
"settings": {
"pattern": "on",
"brightness": 127,
"colors": [
"#000000"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"patterns": {}
}
}
},
"tab_order": [
"dsfdfd"
]
}

6
profiles/test.json Normal file
View File

@@ -0,0 +1,6 @@
{
"lights": {},
"tab_password": "",
"tab_order": [],
"color_palette": []
}

868
profiles/tt.json Normal file
View File

@@ -0,0 +1,868 @@
{
"tab_password": "qwerty1234",
"lights": {
"sign": {
"names": [
"tt-sign",
"1"
],
"settings": {
"colors": [
"#968a00"
],
"brightness": 39,
"pattern": "circle",
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10,
"patterns": {
"pulse": {
"colors": [
"#ff00ff"
],
"delay": 657,
"n1": 100,
"n2": 10,
"n3": 100,
"n4": 10,
"n5": 10,
"n6": 10
},
"n_chase": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10
},
"on": {
"colors": [
"#ff00ff"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10
},
"rainbow": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10
},
"off": {
"colors": [
"#0000ff",
"#ff0000"
],
"delay": 10000,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10
},
"blink": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10
},
"transition": {
"colors": [
"#ff00ff",
"#ffff00"
],
"delay": 10000,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"circle": {
"colors": [
"#0000ff",
"#ff0000"
],
"delay": 10000,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"chase": {
"colors": [
"#000091",
"#00d800"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"dj": {
"names": [
"dj"
],
"settings": {
"colors": [
"#0000ff",
"#ff0000"
],
"brightness": 39,
"pattern": "transition",
"delay": 10000,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10,
"patterns": {
"rainbow": {
"colors": [
"#00006a"
],
"delay": 17,
"n1": 1,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10
},
"on": {
"colors": [
"#ff0062",
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10
},
"blink": {
"colors": [
"#0000d0"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10
},
"pulse": {
"delay": 1002,
"colors": [
"#006600",
"#0000ff"
],
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10
},
"transition": {
"colors": [
"#0000ff",
"#ff0000"
],
"delay": 10000,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10
},
"n_chase": {
"n1": 11,
"n2": 13,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10,
"delay": 639,
"colors": [
"#0000ff"
]
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"circle": {
"colors": [
"#0001bd",
"#00ff00"
],
"delay": 1778,
"n1": 20,
"n2": 40,
"n3": 40,
"n4": 0
},
"chase": {
"colors": [
"#8d00ff",
"#ff0077"
],
"delay": 69,
"n1": 30,
"n2": 30,
"n3": 5,
"n4": 30
}
}
}
},
"middle": {
"names": [
"middle1",
"middle2",
"middle3",
"middle4"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 44,
"pattern": "on",
"delay": 520,
"patterns": {
"flicker": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"on": {
"colors": [
"#ff00ff"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"sides": {
"names": [
"left",
"right"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 13,
"pattern": "on",
"delay": 520,
"patterns": {
"on": {
"colors": [
"#ff00ff"
],
"delay": 988,
"n1": 100,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"pulse": {
"n1": 100,
"n2": 100,
"n3": 100,
"n4": 10,
"delay": 411,
"colors": [
"#ff00ff"
]
}
}
}
},
"outside": {
"names": [
"outside"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 76,
"pattern": "on",
"delay": 520,
"n1": -17,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10,
"patterns": {
"on": {
"colors": [
"#ff00ff"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"transition": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"middle1": {
"names": [
"middle1"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 59,
"pattern": "on",
"delay": 520,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10,
"patterns": {
"flicker": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"on": {
"colors": [
"#ff00ff"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"pulse": {
"delay": 1096,
"colors": [
"#0000ff"
],
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"rainbow": {
"n1": 1,
"n2": 10,
"n3": 10,
"n4": 10,
"delay": 2884,
"colors": [
"#000000"
]
},
"transition": {
"colors": [
"#0000ff",
"#ff0000"
],
"delay": 269,
"n1": 5,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"middle2": {
"names": [
"middle2"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 141,
"pattern": "on",
"delay": 520,
"patterns": {
"flicker": {
"colors": [
"#000078"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"on": {
"colors": [
"#ff00ff"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"pulse": {
"colors": [
"#0000a0",
"#720000"
],
"delay": 4102,
"n1": 100,
"n2": 10,
"n3": 100,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"middle3": {
"names": [
"middle3"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "on",
"delay": 520,
"patterns": {
"flicker": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"on": {
"colors": [
"#00c4a5"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"middle4": {
"names": [
"middle4"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "on",
"delay": 520,
"patterns": {
"flicker": {
"colors": [
"#ff00d6"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"on": {
"colors": [
"#ff00ff"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"front1": {
"names": [
"front1"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 255,
"pattern": "on",
"delay": 520,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10,
"patterns": {
"on": {
"colors": [
"#ff00ff",
"#0000ff"
],
"delay": 2409,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"pulse": {
"colors": [
"#000090"
],
"delay": 1051,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"transition": {
"colors": [
"#ff0000",
"#0000ff"
],
"delay": 2564,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"front2": {
"names": [
"front2"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 255,
"pattern": "off",
"delay": 520,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10,
"patterns": {
"on": {
"colors": [
"#ff00ff"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"rainbow": {
"colors": [
"#00006b"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"transition": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"front3": {
"names": [
"front3"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 29,
"pattern": "on",
"delay": 520,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"n5": 10,
"n6": 10,
"patterns": {
"on": {
"colors": [
"#d200d1"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"rainbow": {
"colors": [
"#00006b"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"transition": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
}
},
"tab_order": [
"sign",
"dj",
"middle",
"sides",
"outside",
"middle1",
"middle2",
"middle3",
"middle4",
"front1",
"front2",
"front3"
],
"color_palette": [
"#c33232",
"#3237c3"
]
}

17
run_web.py Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env python3
"""
Startup script for the Flask web application.
"""
import sys
import os
# Add src directory to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
from flask_app import app
if __name__ == '__main__':
print("Starting Lighting Controller Web App with Flask...")
print("Open http://localhost:5000 in your browser")
app.run(host='0.0.0.0', port=5000, debug=True)

View File

@@ -1,160 +1,54 @@
{
"lights": {
"sign": {
"names": [
"tt-sign"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00"
],
"brightness": 6,
"pattern": "color_transition",
"delay": 30
"tab_password": "",
"current_profile": "default",
"patterns": {
"on": {
"min_delay": 10,
"max_delay": 10000
},
"off": {
"min_delay": 10,
"max_delay": 10000
},
"rainbow": {
"Step Rate": "n1",
"min_delay": 10,
"max_delay": 10000
},
"transition": {
"min_delay": 10,
"max_delay": 10000
},
"chase": {
"Colour 1 Length": "n1",
"Colour 2 Length": "n2",
"Step 1": "n3",
"Step 2": "n4",
"min_delay": 10,
"max_delay": 10000
},
"pulse": {
"Attack": "n1",
"Hold": "n2",
"Decay": "n3",
"min_delay": 10,
"max_delay": 10000
},
"circle": {
"Head Rate": "n1",
"Max Length": "n2",
"Tail Rate": "n3",
"Min Length": "n4",
"min_delay": 10,
"max_delay": 10000
},
"blink": {
"min_delay": 10,
"max_delay": 10000
}
},
"dj": {
"names": [
"dj"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "flicker",
"delay": 520
}
},
"middle": {
"names": [
"middle1",
"middle2",
"middle3",
"middle4"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "flicker",
"delay": 520
}
},
"sides": {
"names": [
"left",
"right"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "on",
"delay": 520
}
},
"outside": {
"names": [
"outside"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "flicker",
"delay": 520
}
},
"middle1": {
"names": [
"middle1"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "flicker",
"delay": 520
}
},
"middle2": {
"names": [
"middle2"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "flicker",
"delay": 520
}
},
"middle3": {
"names": [
"middle3"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "flicker",
"delay": 520
}
},
"middle4": {
"names": [
"middle4"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "flicker",
"delay": 520
}
}
},
"patterns": [
"on",
"off",
"blink",
"rainbow_cycle",
"color_transition",
"theater_chase",
"flicker"
"color_palette": [
"#c12525",
"#246dcc"
]
}

View File

@@ -1,27 +0,0 @@
import json
class Settings(dict):
SETTINGS_FILE = "settings.json"
def __init__(self):
super().__init__()
self.load() # Load settings from file during initialization
def save(self):
try:
j = json.dumps(self, indent=4)
with open(self.SETTINGS_FILE, 'w') as file:
file.write(j)
print("Settings saved successfully.")
except Exception as e:
print(f"Error saving settings: {e}")
def load(self):
try:
with open(self.SETTINGS_FILE, 'r') as file:
loaded_settings = json.load(file)
self.update(loaded_settings)
print("Settings loaded successfully.")
except Exception as e:
print(f"Error loading settings {e}")
self.save()

820
src/flask_app.py Normal file
View File

@@ -0,0 +1,820 @@
"""
Flask web application for the lighting controller.
Provides REST API and serves the web UI.
"""
import asyncio
import json
import os
import math
from flask import Flask, render_template, request, jsonify
from flask_cors import CORS
from networking import WebSocketClient
import color_utils
from settings import Settings
app = Flask(__name__,
template_folder='../templates',
static_folder='../static')
CORS(app)
# Global settings and WebSocket client
settings = Settings()
websocket_client = None
websocket_uri = "ws://192.168.4.1:80/ws"
# Load current profile on startup
def load_current_profile():
"""Load the current profile if one is set."""
current_profile = settings.get("current_profile")
if current_profile:
profile_path = os.path.join("profiles", f"{current_profile}.json")
if os.path.exists(profile_path):
try:
with open(profile_path, 'r') as file:
profile_data = json.load(file)
# Update settings with profile data
profile_data.pop("current_profile", None)
patterns_backup = settings.get("patterns", {})
tab_password_backup = settings.get("tab_password", "")
settings.update(profile_data)
settings["patterns"] = patterns_backup
settings["current_profile"] = current_profile
# Ensure color_palette exists (default to empty array if not in profile)
if "color_palette" not in settings:
settings["color_palette"] = []
print(f"Loaded profile '{current_profile}' on startup.")
except Exception as e:
print(f"Error loading profile '{current_profile}': {e}")
# Load current profile when module is imported
load_current_profile()
# Presets file path
PRESETS_FILE = "presets.json"
def load_presets():
"""Load presets from presets.json file."""
try:
if os.path.exists(PRESETS_FILE):
with open(PRESETS_FILE, 'r') as file:
return json.load(file)
return {}
except Exception as e:
print(f"Error loading presets: {e}")
return {}
def save_presets(presets):
"""Save presets to presets.json file."""
try:
with open(PRESETS_FILE, 'w') as file:
json.dump(presets, file, indent=4)
print(f"Presets saved successfully.")
except Exception as e:
print(f"Error saving presets: {e}")
def delay_to_slider(delay_ms, min_delay=10, max_delay=10000):
"""Convert delay in ms to slider position (0-1000) using logarithmic scale."""
if delay_ms <= min_delay:
return 0
if delay_ms >= max_delay:
return 1000
if min_delay == max_delay:
return 0
return 1000 * math.log(delay_ms / min_delay) / math.log(max_delay / min_delay)
def slider_to_delay(slider_value, min_delay=10, max_delay=10000):
"""Convert slider position (0-1000) to delay in ms using logarithmic scale."""
if slider_value <= 0:
return min_delay
if slider_value >= 1000:
return max_delay
if min_delay == max_delay:
return min_delay
return int(min_delay * ((max_delay / min_delay) ** (slider_value / 1000)))
def get_pattern_settings(tab_name, pattern_name):
"""Get pattern-specific settings."""
light_settings = settings["lights"][tab_name]["settings"]
if "patterns" not in light_settings:
light_settings["patterns"] = {}
if pattern_name not in light_settings["patterns"]:
light_settings["patterns"][pattern_name] = {}
pattern_settings = light_settings["patterns"][pattern_name]
# Fall back to global settings if pattern-specific settings don't exist
global_colors = light_settings.get("colors", ["#000000"])
return {
"colors": pattern_settings.get("colors", global_colors),
"delay": pattern_settings.get("delay", light_settings.get("delay", 100)),
"n1": pattern_settings.get("n1", light_settings.get("n1", 10)),
"n2": pattern_settings.get("n2", light_settings.get("n2", 10)),
"n3": pattern_settings.get("n3", light_settings.get("n3", 10)),
"n4": pattern_settings.get("n4", light_settings.get("n4", 10)),
}
def save_pattern_settings(tab_name, pattern_name, colors=None, delay=None, n_params=None):
"""Save pattern-specific settings."""
light_settings = settings["lights"][tab_name]["settings"]
if "patterns" not in light_settings:
light_settings["patterns"] = {}
if pattern_name not in light_settings["patterns"]:
light_settings["patterns"][pattern_name] = {}
pattern_settings = light_settings["patterns"][pattern_name]
if colors is not None:
pattern_settings["colors"] = colors
if delay is not None:
pattern_settings["delay"] = delay
if n_params is not None:
for i in range(1, 5):
if f"n{i}" in n_params:
pattern_settings[f"n{i}"] = n_params[f"n{i}"]
def save_current_profile():
"""Save current settings to the active profile file."""
current_profile = settings.get("current_profile")
# If no profile is set, create/use a default profile
if not current_profile:
current_profile = "default"
settings["current_profile"] = current_profile
# Save current_profile to settings.json
settings.save()
try:
profiles_dir = "profiles"
os.makedirs(profiles_dir, exist_ok=True)
profile_path = os.path.join(profiles_dir, f"{current_profile}.json")
# Get current tab order
tab_order = settings.get("tab_order", [])
if "lights" in settings:
# Ensure all current tabs are in the order
current_tabs = set(settings["lights"].keys())
order_tabs = set(tab_order)
for tab in current_tabs:
if tab not in order_tabs:
tab_order.append(tab)
settings["tab_order"] = tab_order
# Save to profile file (exclude current_profile from profile)
profile_data = dict(settings)
profile_data.pop("current_profile", None)
profile_data.pop("patterns", None) # Patterns stay in settings.json
# Ensure color_palette is included if it exists
if "color_palette" not in profile_data:
profile_data["color_palette"] = settings.get("color_palette", [])
with open(profile_path, 'w') as file:
json.dump(profile_data, file, indent=4)
print(f"Profile '{current_profile}' saved successfully.")
except Exception as e:
print(f"Error saving profile: {e}")
async def send_to_lighting_controller(payload):
"""Send data to the lighting controller via WebSocket."""
global websocket_client
if websocket_client is None:
websocket_client = WebSocketClient(websocket_uri)
await websocket_client.connect()
if not websocket_client.is_connected:
await websocket_client.connect()
if websocket_client.is_connected:
await websocket_client.send_data(payload)
def run_async(coro):
"""Run async function in sync context."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(coro)
finally:
loop.close()
@app.route('/')
def index():
"""Serve the main web UI."""
return render_template('index.html')
@app.route('/api/state', methods=['GET'])
def get_state():
"""Get the current state of all lights."""
# Ensure a profile is set if we have lights but no profile
if settings.get("lights") and not settings.get("current_profile"):
current_profile = "default"
settings["current_profile"] = current_profile
settings.save()
# Create default profile file if it doesn't exist
profiles_dir = "profiles"
os.makedirs(profiles_dir, exist_ok=True)
profile_path = os.path.join(profiles_dir, f"{current_profile}.json")
if not os.path.exists(profile_path):
profile_data = {
"lights": settings.get("lights", {}),
"tab_order": settings.get("tab_order", []),
"tab_password": settings.get("tab_password", "")
}
with open(profile_path, 'w') as file:
json.dump(profile_data, file, indent=4)
return jsonify({
"lights": settings.get("lights", {}),
"patterns": settings.get("patterns", {}),
"tab_order": settings.get("tab_order", []),
"current_profile": settings.get("current_profile", ""),
"color_palette": settings.get("color_palette", []),
"presets": load_presets()
})
@app.route('/api/pattern', methods=['POST'])
def set_pattern():
"""Set the pattern for a light group."""
data = request.json
tab_name = data.get("tab_name")
pattern_name = data.get("pattern")
if not tab_name or not pattern_name:
return jsonify({"error": "Missing tab_name or pattern"}), 400
if tab_name not in settings.get("lights", {}):
return jsonify({"error": f"Tab '{tab_name}' not found"}), 404
# Save current pattern's settings before switching
old_pattern = settings["lights"][tab_name]["settings"].get("pattern", "on")
# Get current delay (would need to be passed from frontend)
current_delay = data.get("delay", 100)
current_n_params = {
f"n{i}": data.get(f"n{i}", 10) for i in range(1, 5)
}
save_pattern_settings(
tab_name,
old_pattern,
colors=data.get("colors", ["#000000"]),
delay=current_delay,
n_params=current_n_params
)
# Load new pattern's settings
new_pattern_settings = get_pattern_settings(tab_name, pattern_name)
# Update settings
settings["lights"][tab_name]["settings"]["pattern"] = pattern_name
# Prepare payload for lighting controller
names = settings["lights"][tab_name]["names"]
payload = {
"save": True,
"names": names,
"settings": {
"pattern": pattern_name,
"brightness": settings["lights"][tab_name]["settings"].get("brightness", 127),
"delay": new_pattern_settings["delay"],
"colors": new_pattern_settings["colors"],
**{f"n{i}": new_pattern_settings[f"n{i}"] for i in range(1, 5)}
}
}
# Send to lighting controller
run_async(send_to_lighting_controller(payload))
settings.save()
return jsonify({
"success": True,
"pattern": pattern_name,
"settings": new_pattern_settings
})
@app.route('/api/parameters', methods=['POST'])
def set_parameters():
"""Update parameters (RGB, brightness, delay, n params) for a light group."""
data = request.json
tab_name = data.get("tab_name")
if not tab_name:
return jsonify({"error": "Missing tab_name"}), 400
if tab_name not in settings.get("lights", {}):
return jsonify({"error": f"Tab '{tab_name}' not found"}), 404
current_pattern = settings["lights"][tab_name]["settings"].get("pattern", "on")
pattern_config = settings.get("patterns", {}).get(current_pattern, {})
min_delay = pattern_config.get("min_delay", 10)
max_delay = pattern_config.get("max_delay", 10000)
# Build settings payload
payload_settings = {}
# Handle RGB colors
if "red" in data or "green" in data or "blue" in data:
r = data.get("red", 0)
g = data.get("green", 0)
b = data.get("blue", 0)
hex_color = f"#{r:02x}{g:02x}{b:02x}"
# Update color in palette
pattern_settings = get_pattern_settings(tab_name, current_pattern)
colors = pattern_settings["colors"].copy()
selected_index = data.get("color_index", 0)
if 0 <= selected_index < len(colors):
colors[selected_index] = hex_color
else:
# If index is out of range, append the color
colors.append(hex_color)
# Save pattern settings to persist the color change
save_pattern_settings(tab_name, current_pattern, colors=colors)
payload_settings["colors"] = colors
# Handle brightness
if "brightness" in data:
brightness = int(data["brightness"])
settings["lights"][tab_name]["settings"]["brightness"] = brightness
payload_settings["brightness"] = brightness
# Handle delay
if "delay_slider" in data:
slider_value = int(data["delay_slider"])
delay = slider_to_delay(slider_value, min_delay, max_delay)
save_pattern_settings(tab_name, current_pattern, delay=delay)
payload_settings["delay"] = delay
# Handle n parameters
n_params = {}
for i in range(1, 5):
if f"n{i}" in data:
n_params[f"n{i}"] = int(data[f"n{i}"])
if n_params:
save_pattern_settings(tab_name, current_pattern, n_params=n_params)
payload_settings.update(n_params)
# Send to lighting controller
if payload_settings:
names = settings["lights"][tab_name]["names"]
payload = {
"save": True,
"names": names,
"settings": payload_settings
}
run_async(send_to_lighting_controller(payload))
# Save to settings.json (for patterns) and to profile file (for lights data)
settings.save()
save_current_profile()
return jsonify({"success": True})
@app.route('/api/tabs', methods=['GET'])
def get_tabs():
"""Get list of tabs."""
return jsonify({
"tabs": settings.get("tab_order", []),
"lights": settings.get("lights", {})
})
@app.route('/api/tabs', methods=['POST'])
def create_tab():
"""Create a new tab."""
data = request.json
tab_name = data.get("name")
ids = data.get("ids", ["1"])
if not tab_name:
return jsonify({"error": "Missing name"}), 400
if tab_name in settings.get("lights", {}):
return jsonify({"error": f"Tab '{tab_name}' already exists"}), 400
settings.setdefault("lights", {})[tab_name] = {
"names": ids if isinstance(ids, list) else [ids],
"settings": {
"pattern": "on",
"brightness": 127,
"colors": ["#000000"],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"patterns": {}
}
}
if "tab_order" not in settings:
settings["tab_order"] = []
settings["tab_order"].append(tab_name)
settings.save()
save_current_profile()
return jsonify({"success": True, "tab_name": tab_name})
@app.route('/api/tabs/<tab_name>', methods=['PUT'])
def update_tab(tab_name):
"""Update a tab."""
data = request.json
new_name = data.get("name", tab_name)
ids = data.get("ids")
if tab_name not in settings.get("lights", {}):
return jsonify({"error": f"Tab '{tab_name}' not found"}), 404
if new_name != tab_name:
if new_name in settings.get("lights", {}):
return jsonify({"error": f"Tab '{new_name}' already exists"}), 400
# Rename tab
settings["lights"][new_name] = settings["lights"][tab_name]
del settings["lights"][tab_name]
# Update tab order
if "tab_order" in settings and tab_name in settings["tab_order"]:
index = settings["tab_order"].index(tab_name)
settings["tab_order"][index] = new_name
tab_name = new_name
if ids is not None:
settings["lights"][tab_name]["names"] = ids if isinstance(ids, list) else [ids]
settings.save()
save_current_profile()
return jsonify({"success": True, "tab_name": tab_name})
@app.route('/api/tabs/<tab_name>', methods=['DELETE'])
def delete_tab(tab_name):
"""Delete a tab."""
if tab_name not in settings.get("lights", {}):
return jsonify({"error": f"Tab '{tab_name}' not found"}), 404
del settings["lights"][tab_name]
if "tab_order" in settings and tab_name in settings["tab_order"]:
settings["tab_order"].remove(tab_name)
settings.save()
save_current_profile()
return jsonify({"success": True})
@app.route('/api/profiles', methods=['GET'])
def get_profiles():
"""Get list of profiles."""
profiles_dir = "profiles"
profiles = []
if os.path.exists(profiles_dir):
for filename in os.listdir(profiles_dir):
if filename.endswith('.json'):
profiles.append(filename[:-5])
profiles.sort()
return jsonify({
"profiles": profiles,
"current_profile": settings.get("current_profile", ""),
"color_palette": settings.get("color_palette", [])
})
@app.route('/api/profiles', methods=['POST'])
def create_profile():
"""Create a new profile."""
data = request.json
profile_name = data.get("name")
if not profile_name:
return jsonify({"error": "Missing name"}), 400
profiles_dir = "profiles"
os.makedirs(profiles_dir, exist_ok=True)
profile_path = os.path.join(profiles_dir, f"{profile_name}.json")
if os.path.exists(profile_path):
return jsonify({"error": f"Profile '{profile_name}' already exists"}), 400
empty_profile = {
"lights": {},
"tab_password": "",
"tab_order": [],
"color_palette": []
}
with open(profile_path, 'w') as file:
json.dump(empty_profile, file, indent=4)
return jsonify({"success": True, "profile_name": profile_name})
@app.route('/api/profiles/<profile_name>', methods=['DELETE'])
def delete_profile(profile_name):
"""Delete a profile."""
profiles_dir = "profiles"
profile_path = os.path.join(profiles_dir, f"{profile_name}.json")
if not os.path.exists(profile_path):
return jsonify({"error": f"Profile '{profile_name}' not found"}), 404
# Prevent deleting the only existing profile to avoid leaving the app with no profiles
existing_profiles = [
f[:-5] for f in os.listdir(profiles_dir) if f.endswith('.json')
] if os.path.exists(profiles_dir) else []
if len(existing_profiles) <= 1:
return jsonify({"error": "Cannot delete the only existing profile"}), 400
# If deleting the current profile, clear current_profile and related state
if settings.get("current_profile") == profile_name:
settings["current_profile"] = ""
settings["lights"] = {}
settings["tab_order"] = []
settings["color_palette"] = []
# Persist to settings.json
settings_to_save = {
"tab_password": settings.get("tab_password", ""),
"current_profile": "",
"patterns": settings.get("patterns", {})
}
with open("settings.json", 'w') as f:
json.dump(settings_to_save, f, indent=4)
# Remove the profile file
os.remove(profile_path)
return jsonify({"success": True})
@app.route('/api/profiles/<profile_name>', methods=['POST'])
def load_profile(profile_name):
"""Load a profile."""
profile_path = os.path.join("profiles", f"{profile_name}.json")
if not os.path.exists(profile_path):
return jsonify({"error": f"Profile '{profile_name}' not found"}), 404
with open(profile_path, 'r') as file:
profile_data = json.load(file)
# Update settings with profile data
profile_data.pop("current_profile", None)
patterns_backup = settings.get("patterns", {})
tab_password_backup = settings.get("tab_password", "")
settings.update(profile_data)
settings["patterns"] = patterns_backup
settings["current_profile"] = profile_name
# Ensure color_palette exists (default to empty array if not in profile)
if "color_palette" not in settings:
settings["color_palette"] = []
settings_to_save = {
"tab_password": tab_password_backup,
"current_profile": profile_name,
"patterns": patterns_backup
}
with open("settings.json", 'w') as f:
json.dump(settings_to_save, f, indent=4)
return jsonify({"success": True})
@app.route('/api/profiles/<profile_name>/palette', methods=['GET'])
def get_profile_palette(profile_name):
"""Get the color palette for a profile."""
profile_path = os.path.join("profiles", f"{profile_name}.json")
if not os.path.exists(profile_path):
return jsonify({"error": f"Profile '{profile_name}' not found"}), 404
with open(profile_path, 'r') as file:
profile_data = json.load(file)
palette = profile_data.get("color_palette", [])
return jsonify({"color_palette": palette})
@app.route('/api/profiles/<profile_name>/palette', methods=['POST'])
def update_profile_palette(profile_name):
"""Update the color palette for a profile."""
data = request.json
color_palette = data.get("color_palette", [])
profile_path = os.path.join("profiles", f"{profile_name}.json")
if not os.path.exists(profile_path):
return jsonify({"error": f"Profile '{profile_name}' not found"}), 404
with open(profile_path, 'r') as file:
profile_data = json.load(file)
profile_data["color_palette"] = color_palette
with open(profile_path, 'w') as file:
json.dump(profile_data, file, indent=4)
# Update current settings if this is the active profile
if settings.get("current_profile") == profile_name:
settings["color_palette"] = color_palette
return jsonify({"success": True, "color_palette": color_palette})
@app.route('/api/profiles/<profile_name>/save', methods=['POST'])
def save_profile(profile_name):
"""Save current state to a profile."""
# Save current state to the specified profile
save_current_profile()
# If saving to a different profile, switch to it
if profile_name != settings.get("current_profile"):
settings["current_profile"] = profile_name
settings.save()
save_current_profile()
return jsonify({"success": True})
@app.route('/api/presets', methods=['GET'])
def get_presets():
"""Get list of all presets."""
presets = load_presets()
return jsonify({
"presets": presets
})
@app.route('/api/presets', methods=['POST'])
def create_preset():
"""Create a new preset."""
data = request.json
preset_name = data.get("name")
if not preset_name:
return jsonify({"error": "Missing name"}), 400
presets = load_presets()
if preset_name in presets:
return jsonify({"error": f"Preset '{preset_name}' already exists"}), 400
# Validate required fields
required_fields = ["pattern", "colors", "brightness", "delay", "n1", "n2", "n3", "n4"]
for field in required_fields:
if field not in data:
return jsonify({"error": f"Missing required field: {field}"}), 400
preset = {
"pattern": data["pattern"],
"colors": data["colors"],
"brightness": int(data["brightness"]),
"delay": int(data["delay"]),
"n1": int(data["n1"]),
"n2": int(data["n2"]),
"n3": int(data["n3"]),
"n4": int(data["n4"])
}
presets[preset_name] = preset
save_presets(presets)
return jsonify({"success": True, "preset": preset})
@app.route('/api/presets/<preset_name>', methods=['PUT'])
def update_preset(preset_name):
"""Update an existing preset."""
data = request.json
new_name = data.get("name", preset_name)
presets = load_presets()
if preset_name not in presets:
return jsonify({"error": f"Preset '{preset_name}' not found"}), 404
# If renaming, check if new name exists
if new_name != preset_name:
if new_name in presets:
return jsonify({"error": f"Preset '{new_name}' already exists"}), 400
# Rename preset
presets[new_name] = presets[preset_name]
del presets[preset_name]
preset_name = new_name
# Update preset fields
preset = presets[preset_name]
if "pattern" in data:
preset["pattern"] = data["pattern"]
if "colors" in data:
preset["colors"] = data["colors"]
if "brightness" in data:
preset["brightness"] = int(data["brightness"])
if "delay" in data:
preset["delay"] = int(data["delay"])
for i in range(1, 5):
if f"n{i}" in data:
preset[f"n{i}"] = int(data[f"n{i}"])
save_presets(presets)
return jsonify({"success": True, "preset": preset})
@app.route('/api/presets/<preset_name>', methods=['DELETE'])
def delete_preset(preset_name):
"""Delete a preset."""
presets = load_presets()
if preset_name not in presets:
return jsonify({"error": f"Preset '{preset_name}' not found"}), 404
del presets[preset_name]
save_presets(presets)
return jsonify({"success": True})
@app.route('/api/presets/<preset_name>/apply', methods=['POST'])
def apply_preset(preset_name):
"""Apply a preset to a tab."""
data = request.json
tab_name = data.get("tab_name")
if not tab_name:
return jsonify({"error": "Missing tab_name"}), 400
if tab_name not in settings.get("lights", {}):
return jsonify({"error": f"Tab '{tab_name}' not found"}), 404
presets = load_presets()
if preset_name not in presets:
return jsonify({"error": f"Preset '{preset_name}' not found"}), 404
preset = presets[preset_name]
# Apply preset to tab
light_settings = settings["lights"][tab_name]["settings"]
light_settings["pattern"] = preset["pattern"]
light_settings["brightness"] = preset["brightness"]
# Save pattern-specific settings
save_pattern_settings(
tab_name,
preset["pattern"],
colors=preset["colors"],
delay=preset["delay"],
n_params={f"n{i}": preset[f"n{i}"] for i in range(1, 5)}
)
# Prepare payload for lighting controller
names = settings["lights"][tab_name]["names"]
payload = {
"save": True,
"names": names,
"settings": {
"pattern": preset["pattern"],
"brightness": preset["brightness"],
"delay": preset["delay"],
"colors": preset["colors"],
**{f"n{i}": preset[f"n{i}"] for i in range(1, 5)}
}
}
# Send to lighting controller
run_async(send_to_lighting_controller(payload))
settings.save()
save_current_profile()
return jsonify({"success": True})
def init_websocket():
"""Initialize WebSocket connection in background."""
global websocket_client
if websocket_client is None:
websocket_client = WebSocketClient(websocket_uri)
run_async(websocket_client.connect())
if __name__ == '__main__':
# Initialize WebSocket connection
init_websocket()
app.run(host='0.0.0.0', port=5000, debug=True)

2135
src/main.py Normal file

File diff suppressed because it is too large Load Diff

1054
src/main_textual.py Normal file

File diff suppressed because it is too large Load Diff

2135
src/main_tkinter.py.bak Normal file

File diff suppressed because it is too large Load Diff

665
src/microdot_app.py Normal file
View File

@@ -0,0 +1,665 @@
"""
Microdot web application for the lighting controller.
Provides REST API and serves the web UI.
"""
import asyncio
import json
import os
import math
from microdot import Microdot, Request, Response
from networking import WebSocketClient
import color_utils
from settings import Settings
app = Microdot()
# CORS middleware
@app.after_request()
def cors_handler(req, res):
"""Add CORS headers to all responses."""
res.headers['Access-Control-Allow-Origin'] = '*'
res.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
res.headers['Access-Control-Allow-Headers'] = 'Content-Type'
return res
@app.route('/<path:path>', methods=['OPTIONS'])
def options_handler(req, path):
"""Handle OPTIONS requests for CORS."""
return Response('', status_code=204, headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
})
# Global settings and WebSocket client
settings = Settings()
websocket_client = None
websocket_uri = "ws://192.168.4.1:80/ws"
# Load current profile on startup
def load_current_profile():
"""Load the current profile if one is set."""
current_profile = settings.get("current_profile")
if current_profile:
profile_path = os.path.join("profiles", f"{current_profile}.json")
if os.path.exists(profile_path):
try:
with open(profile_path, 'r') as file:
profile_data = json.load(file)
# Update settings with profile data
profile_data.pop("current_profile", None)
patterns_backup = settings.get("patterns", {})
tab_password_backup = settings.get("tab_password", "")
settings.update(profile_data)
settings["patterns"] = patterns_backup
settings["current_profile"] = current_profile
# Ensure color_palette exists (default to empty array if not in profile)
if "color_palette" not in settings:
settings["color_palette"] = []
print(f"Loaded profile '{current_profile}' on startup.")
except Exception as e:
print(f"Error loading profile '{current_profile}': {e}")
# Load current profile when module is imported
load_current_profile()
def delay_to_slider(delay_ms, min_delay=10, max_delay=10000):
"""Convert delay in ms to slider position (0-1000) using logarithmic scale."""
if delay_ms <= min_delay:
return 0
if delay_ms >= max_delay:
return 1000
if min_delay == max_delay:
return 0
return 1000 * math.log(delay_ms / min_delay) / math.log(max_delay / min_delay)
def slider_to_delay(slider_value, min_delay=10, max_delay=10000):
"""Convert slider position (0-1000) to delay in ms using logarithmic scale."""
if slider_value <= 0:
return min_delay
if slider_value >= 1000:
return max_delay
if min_delay == max_delay:
return min_delay
return int(min_delay * ((max_delay / min_delay) ** (slider_value / 1000)))
def get_pattern_settings(tab_name, pattern_name):
"""Get pattern-specific settings."""
light_settings = settings["lights"][tab_name]["settings"]
if "patterns" not in light_settings:
light_settings["patterns"] = {}
if pattern_name not in light_settings["patterns"]:
light_settings["patterns"][pattern_name] = {}
pattern_settings = light_settings["patterns"][pattern_name]
# Fall back to global settings if pattern-specific settings don't exist
global_colors = light_settings.get("colors", ["#000000"])
return {
"colors": pattern_settings.get("colors", global_colors),
"delay": pattern_settings.get("delay", light_settings.get("delay", 100)),
"n1": pattern_settings.get("n1", light_settings.get("n1", 10)),
"n2": pattern_settings.get("n2", light_settings.get("n2", 10)),
"n3": pattern_settings.get("n3", light_settings.get("n3", 10)),
"n4": pattern_settings.get("n4", light_settings.get("n4", 10)),
}
def save_pattern_settings(tab_name, pattern_name, colors=None, delay=None, n_params=None):
"""Save pattern-specific settings."""
light_settings = settings["lights"][tab_name]["settings"]
if "patterns" not in light_settings:
light_settings["patterns"] = {}
if pattern_name not in light_settings["patterns"]:
light_settings["patterns"][pattern_name] = {}
pattern_settings = light_settings["patterns"][pattern_name]
if colors is not None:
pattern_settings["colors"] = colors
if delay is not None:
pattern_settings["delay"] = delay
if n_params is not None:
for i in range(1, 5):
if f"n{i}" in n_params:
pattern_settings[f"n{i}"] = n_params[f"n{i}"]
def save_current_profile():
"""Save current settings to the active profile file."""
current_profile = settings.get("current_profile")
# If no profile is set, create/use a default profile
if not current_profile:
current_profile = "default"
settings["current_profile"] = current_profile
# Save current_profile to settings.json
settings.save()
try:
profiles_dir = "profiles"
os.makedirs(profiles_dir, exist_ok=True)
profile_path = os.path.join(profiles_dir, f"{current_profile}.json")
# Get current tab order
tab_order = settings.get("tab_order", [])
if "lights" in settings:
# Ensure all current tabs are in the order
current_tabs = set(settings["lights"].keys())
order_tabs = set(tab_order)
for tab in current_tabs:
if tab not in order_tabs:
tab_order.append(tab)
settings["tab_order"] = tab_order
# Save to profile file (exclude current_profile from profile)
profile_data = dict(settings)
profile_data.pop("current_profile", None)
profile_data.pop("patterns", None) # Patterns stay in settings.json
# Ensure color_palette is included if it exists
if "color_palette" not in profile_data:
profile_data["color_palette"] = settings.get("color_palette", [])
with open(profile_path, 'w') as file:
json.dump(profile_data, file, indent=4)
print(f"Profile '{current_profile}' saved successfully.")
except Exception as e:
print(f"Error saving profile: {e}")
async def send_to_lighting_controller(payload):
"""Send data to the lighting controller via WebSocket."""
global websocket_client
if websocket_client is None:
websocket_client = WebSocketClient(websocket_uri)
await websocket_client.connect()
if not websocket_client.is_connected:
await websocket_client.connect()
if websocket_client.is_connected:
await websocket_client.send_data(payload)
def run_async(coro):
"""Run async function in sync context."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(coro)
finally:
loop.close()
@app.route('/')
def index(req):
"""Serve the main web UI."""
return Response.send_file('templates/index.html', base_path='.')
@app.route('/api/state', methods=['GET'])
def get_state(req):
"""Get the current state of all lights."""
# Ensure a profile is set if we have lights but no profile
if settings.get("lights") and not settings.get("current_profile"):
current_profile = "default"
settings["current_profile"] = current_profile
settings.save()
# Create default profile file if it doesn't exist
profiles_dir = "profiles"
os.makedirs(profiles_dir, exist_ok=True)
profile_path = os.path.join(profiles_dir, f"{current_profile}.json")
if not os.path.exists(profile_path):
profile_data = {
"lights": settings.get("lights", {}),
"tab_order": settings.get("tab_order", []),
"tab_password": settings.get("tab_password", "")
}
with open(profile_path, 'w') as file:
json.dump(profile_data, file, indent=4)
return {
"lights": settings.get("lights", {}),
"patterns": settings.get("patterns", {}),
"tab_order": settings.get("tab_order", []),
"current_profile": settings.get("current_profile", ""),
"color_palette": settings.get("color_palette", [])
}
@app.route('/api/pattern', methods=['POST'])
def set_pattern(req):
"""Set the pattern for a light group."""
data = req.json
tab_name = data.get("tab_name")
pattern_name = data.get("pattern")
if not tab_name or not pattern_name:
return {"error": "Missing tab_name or pattern"}, 400
if tab_name not in settings.get("lights", {}):
return {"error": f"Tab '{tab_name}' not found"}, 404
# Save current pattern's settings before switching
old_pattern = settings["lights"][tab_name]["settings"].get("pattern", "on")
# Get current delay (would need to be passed from frontend)
current_delay = data.get("delay", 100)
current_n_params = {
f"n{i}": data.get(f"n{i}", 10) for i in range(1, 5)
}
save_pattern_settings(
tab_name,
old_pattern,
colors=data.get("colors", ["#000000"]),
delay=current_delay,
n_params=current_n_params
)
# Load new pattern's settings
new_pattern_settings = get_pattern_settings(tab_name, pattern_name)
# Update settings
settings["lights"][tab_name]["settings"]["pattern"] = pattern_name
# Prepare payload for lighting controller
names = settings["lights"][tab_name]["names"]
payload = {
"save": True,
"names": names,
"settings": {
"pattern": pattern_name,
"brightness": settings["lights"][tab_name]["settings"].get("brightness", 127),
"delay": new_pattern_settings["delay"],
"colors": new_pattern_settings["colors"],
**{f"n{i}": new_pattern_settings[f"n{i}"] for i in range(1, 5)}
}
}
# Send to lighting controller
run_async(send_to_lighting_controller(payload))
settings.save()
save_current_profile()
return {
"success": True,
"pattern": pattern_name,
"settings": new_pattern_settings
}
@app.route('/api/parameters', methods=['POST'])
def set_parameters(req):
"""Update parameters (RGB, brightness, delay, n params) for a light group."""
data = req.json
tab_name = data.get("tab_name")
if not tab_name:
return {"error": "Missing tab_name"}, 400
if tab_name not in settings.get("lights", {}):
return {"error": f"Tab '{tab_name}' not found"}, 404
current_pattern = settings["lights"][tab_name]["settings"].get("pattern", "on")
pattern_config = settings.get("patterns", {}).get(current_pattern, {})
min_delay = pattern_config.get("min_delay", 10)
max_delay = pattern_config.get("max_delay", 10000)
# Build settings payload
payload_settings = {}
# Handle RGB colors
if "red" in data or "green" in data or "blue" in data:
r = data.get("red", 0)
g = data.get("green", 0)
b = data.get("blue", 0)
hex_color = f"#{r:02x}{g:02x}{b:02x}"
# Update color in palette
pattern_settings = get_pattern_settings(tab_name, current_pattern)
colors = pattern_settings["colors"].copy()
selected_index = data.get("color_index", 0)
if 0 <= selected_index < len(colors):
colors[selected_index] = hex_color
else:
# If index is out of range, append the color
colors.append(hex_color)
# Save pattern settings to persist the color change
save_pattern_settings(tab_name, current_pattern, colors=colors)
payload_settings["colors"] = colors
# Handle brightness
if "brightness" in data:
brightness = int(data["brightness"])
settings["lights"][tab_name]["settings"]["brightness"] = brightness
payload_settings["brightness"] = brightness
# Handle delay
if "delay_slider" in data:
slider_value = int(data["delay_slider"])
delay = slider_to_delay(slider_value, min_delay, max_delay)
save_pattern_settings(tab_name, current_pattern, delay=delay)
payload_settings["delay"] = delay
# Handle n parameters
n_params = {}
for i in range(1, 5):
if f"n{i}" in data:
n_params[f"n{i}"] = int(data[f"n{i}"])
if n_params:
save_pattern_settings(tab_name, current_pattern, n_params=n_params)
payload_settings.update(n_params)
# Send to lighting controller
if payload_settings:
names = settings["lights"][tab_name]["names"]
payload = {
"save": True,
"names": names,
"settings": payload_settings
}
run_async(send_to_lighting_controller(payload))
# Save to settings.json (for patterns) and to profile file (for lights data)
settings.save()
save_current_profile()
return {"success": True}
@app.route('/api/tabs', methods=['GET'])
def get_tabs(req):
"""Get list of tabs."""
return {
"tabs": settings.get("tab_order", []),
"lights": settings.get("lights", {})
}
@app.route('/api/tabs', methods=['POST'])
def create_tab(req):
"""Create a new tab."""
data = req.json
tab_name = data.get("name")
ids = data.get("ids", ["1"])
if not tab_name:
return {"error": "Missing name"}, 400
if tab_name in settings.get("lights", {}):
return {"error": f"Tab '{tab_name}' already exists"}, 400
settings.setdefault("lights", {})[tab_name] = {
"names": ids if isinstance(ids, list) else [ids],
"settings": {
"pattern": "on",
"brightness": 127,
"colors": ["#000000"],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"patterns": {}
}
}
if "tab_order" not in settings:
settings["tab_order"] = []
settings["tab_order"].append(tab_name)
settings.save()
save_current_profile()
return {"success": True, "tab_name": tab_name}
@app.route('/api/tabs/<tab_name>', methods=['PUT'])
def update_tab(req, tab_name):
"""Update a tab."""
data = req.json
new_name = data.get("name", tab_name)
ids = data.get("ids")
if tab_name not in settings.get("lights", {}):
return {"error": f"Tab '{tab_name}' not found"}, 404
if new_name != tab_name:
if new_name in settings.get("lights", {}):
return {"error": f"Tab '{new_name}' already exists"}, 400
# Rename tab
settings["lights"][new_name] = settings["lights"][tab_name]
del settings["lights"][tab_name]
# Update tab order
if "tab_order" in settings and tab_name in settings["tab_order"]:
index = settings["tab_order"].index(tab_name)
settings["tab_order"][index] = new_name
tab_name = new_name
if ids is not None:
settings["lights"][tab_name]["names"] = ids if isinstance(ids, list) else [ids]
settings.save()
save_current_profile()
return {"success": True, "tab_name": tab_name}
@app.route('/api/tabs/<tab_name>', methods=['DELETE'])
def delete_tab(req, tab_name):
"""Delete a tab."""
if tab_name not in settings.get("lights", {}):
return {"error": f"Tab '{tab_name}' not found"}, 404
del settings["lights"][tab_name]
if "tab_order" in settings and tab_name in settings["tab_order"]:
settings["tab_order"].remove(tab_name)
settings.save()
save_current_profile()
return {"success": True}
@app.route('/api/profiles', methods=['GET'])
def get_profiles(req):
"""Get list of profiles."""
profiles_dir = "profiles"
profiles = []
if os.path.exists(profiles_dir):
for filename in os.listdir(profiles_dir):
if filename.endswith('.json'):
profiles.append(filename[:-5])
profiles.sort()
return {
"profiles": profiles,
"current_profile": settings.get("current_profile", ""),
"color_palette": settings.get("color_palette", [])
}
@app.route('/api/profiles', methods=['POST'])
def create_profile(req):
"""Create a new profile."""
data = req.json
profile_name = data.get("name")
if not profile_name:
return {"error": "Missing name"}, 400
profiles_dir = "profiles"
os.makedirs(profiles_dir, exist_ok=True)
profile_path = os.path.join(profiles_dir, f"{profile_name}.json")
if os.path.exists(profile_path):
return {"error": f"Profile '{profile_name}' already exists"}, 400
empty_profile = {
"lights": {},
"tab_password": "",
"tab_order": [],
"color_palette": []
}
with open(profile_path, 'w') as file:
json.dump(empty_profile, file, indent=4)
return {"success": True, "profile_name": profile_name}
@app.route('/api/profiles/<profile_name>', methods=['DELETE'])
def delete_profile(req, profile_name):
"""Delete a profile."""
profiles_dir = "profiles"
profile_path = os.path.join(profiles_dir, f"{profile_name}.json")
if not os.path.exists(profile_path):
return {"error": f"Profile '{profile_name}' not found"}, 404
# Prevent deleting the only existing profile to avoid leaving the app with no profiles
existing_profiles = [
f[:-5] for f in os.listdir(profiles_dir) if f.endswith('.json')
] if os.path.exists(profiles_dir) else []
if len(existing_profiles) <= 1:
return {"error": "Cannot delete the only existing profile"}, 400
# If deleting the current profile, clear current_profile and related state
if settings.get("current_profile") == profile_name:
settings["current_profile"] = ""
settings["lights"] = {}
settings["tab_order"] = []
settings["color_palette"] = []
# Persist to settings.json
settings_to_save = {
"tab_password": settings.get("tab_password", ""),
"current_profile": "",
"patterns": settings.get("patterns", {})
}
with open("settings.json", 'w') as f:
json.dump(settings_to_save, f, indent=4)
# Remove the profile file
os.remove(profile_path)
return {"success": True}
@app.route('/api/profiles/<profile_name>', methods=['POST'])
def load_profile(req, profile_name):
"""Load a profile."""
profile_path = os.path.join("profiles", f"{profile_name}.json")
if not os.path.exists(profile_path):
return {"error": f"Profile '{profile_name}' not found"}, 404
with open(profile_path, 'r') as file:
profile_data = json.load(file)
# Update settings with profile data
profile_data.pop("current_profile", None)
patterns_backup = settings.get("patterns", {})
tab_password_backup = settings.get("tab_password", "")
settings.update(profile_data)
settings["patterns"] = patterns_backup
settings["current_profile"] = profile_name
# Ensure color_palette exists (default to empty array if not in profile)
if "color_palette" not in settings:
settings["color_palette"] = []
settings_to_save = {
"tab_password": tab_password_backup,
"current_profile": profile_name,
"patterns": patterns_backup
}
with open("settings.json", 'w') as f:
json.dump(settings_to_save, f, indent=4)
return {"success": True}
@app.route('/api/profiles/<profile_name>/palette', methods=['GET'])
def get_profile_palette(req, profile_name):
"""Get the color palette for a profile."""
profile_path = os.path.join("profiles", f"{profile_name}.json")
if not os.path.exists(profile_path):
return {"error": f"Profile '{profile_name}' not found"}, 404
with open(profile_path, 'r') as file:
profile_data = json.load(file)
palette = profile_data.get("color_palette", [])
return {"color_palette": palette}
@app.route('/api/profiles/<profile_name>/palette', methods=['POST'])
def update_profile_palette(req, profile_name):
"""Update the color palette for a profile."""
data = req.json
color_palette = data.get("color_palette", [])
profile_path = os.path.join("profiles", f"{profile_name}.json")
if not os.path.exists(profile_path):
return {"error": f"Profile '{profile_name}' not found"}, 404
with open(profile_path, 'r') as file:
profile_data = json.load(file)
profile_data["color_palette"] = color_palette
with open(profile_path, 'w') as file:
json.dump(profile_data, file, indent=4)
# Update current settings if this is the active profile
if settings.get("current_profile") == profile_name:
settings["color_palette"] = color_palette
return {"success": True, "color_palette": color_palette}
@app.route('/api/profiles/<profile_name>/save', methods=['POST'])
def save_profile(req, profile_name):
"""Save current state to a profile."""
# Save current state to the specified profile
save_current_profile()
# If saving to a different profile, switch to it
if profile_name != settings.get("current_profile"):
settings["current_profile"] = profile_name
settings.save()
save_current_profile()
return {"success": True}
# Serve static files
@app.route('/static/<path:path>')
def serve_static(req, path):
"""Serve static files."""
return Response.send_file(f'static/{path}', base_path='.')
def init_websocket():
"""Initialize WebSocket connection in background."""
global websocket_client
if websocket_client is None:
websocket_client = WebSocketClient(websocket_uri)
run_async(websocket_client.connect())
if __name__ == '__main__':
# Initialize WebSocket connection
init_websocket()
print("Starting Lighting Controller Web App with Microdot...")
print("Open http://localhost:5000 in your browser")
app.run(host='0.0.0.0', port=5000, debug=True)

105
src/midi.py Normal file
View File

@@ -0,0 +1,105 @@
import mido
import asyncio
import time
import networking # <--- This will now correctly import your module
async def midi_to_websocket_listener(midi_port_index: int, websocket_uri: str):
"""
Listens to a specific MIDI port and sends data to a WebSocket server
when Note 32 (and 33) is pressed.
"""
delay = 100 # Default delay value
# 1. Get MIDI port name
port_names = mido.get_input_names()
if not port_names:
print("No MIDI input ports found. Please connect your device.")
return
if not (0 <= midi_port_index < len(port_names)):
print(f"Error: MIDI port index {midi_port_index} out of range. Available ports: {port_names}")
print("Available ports:")
for i, name in enumerate(port_names):
print(f" {i}: {name}")
return
midi_port_name = port_names[midi_port_index]
print(f"Selected MIDI input port: {midi_port_name}")
# 2. Initialize WebSocket client (using your actual networking.py)
ws_client = networking.WebSocketClient(websocket_uri)
try:
# 3. Connect WebSocket
await ws_client.connect()
print(f"WebSocket client connected to {ws_client.uri}")
# 4. Open MIDI port and start listening loop
with mido.open_input(midi_port_name) as port:
print(f"MIDI port '{midi_port_name}' opened. Press Ctrl+C to stop.")
while True:
msg = port.receive(block=False) # Non-blocking read
if msg:
match msg.type:
case 'note_on':
print(f" Note ON: Note={msg.note}, Velocity={msg.velocity}, Channel={msg.channel}")
match msg.note:
case 32:
await ws_client.send_data({
"names": ["1"],
"settings": {
"pattern": "pulse",
"delay": delay,
"colors": ["#00ff00"],
"brightness": 100,
"num_leds": 200,
}
})
case 33:
await ws_client.send_data({
"names": ["2"],
"settings": {
"pattern": "chase",
"speed": 10,
"color": "#00FFFF",
}
})
case 'control_change':
match msg.control:
case 36:
delay = msg.value * 4
print(f"Delay set to {delay} ms")
await asyncio.sleep(0.001) # Important: Yield control to asyncio event loop
except mido.PortsError as e:
print(f"Error opening MIDI port '{midi_port_name}': {e}")
except asyncio.CancelledError:
print(f"MIDI listener cancelled.")
except Exception as e:
print(f"An unexpected error occurred: {e}")
finally:
# 5. Disconnect WebSocket and clean up
# This assumes your WebSocketClient has a ._connected attribute or similar way to check state.
# If your client's disconnect method is safe to call even if not connected, you can simplify.
await ws_client.close()
print("MIDI listener stopped and cleaned up.")
async def main():
# --- Configuration ---
MIDI_PORT_INDEX = 1 # <--- IMPORTANT: Change this to the correct index for your device
WEBSOCKET_SERVER_URI = "ws://192.168.4.1:80/ws"
# --- End Configuration ---
try:
await midi_to_websocket_listener(MIDI_PORT_INDEX, WEBSOCKET_SERVER_URI)
except KeyboardInterrupt:
print("\nProgram interrupted by user.")
finally:
print("Main program finished.")
if __name__ == "__main__":
asyncio.run(main())

67
src/settings.py Normal file
View File

@@ -0,0 +1,67 @@
import json
class Settings(dict):
SETTINGS_FILE = "settings.json"
def __init__(self):
super().__init__()
self.load() # Load settings from file during initialization
def save(self):
try:
# Create a copy without lights and tab_order (these belong in profiles, not settings.json)
# But keep patterns, tab_password, and current_profile
settings_to_save = {k: v for k, v in self.items() if k not in ["lights", "tab_order"]}
# Ensure patterns are always included if they exist
if "patterns" in self:
settings_to_save["patterns"] = self["patterns"]
j = json.dumps(settings_to_save, indent=4)
with open(self.SETTINGS_FILE, 'w') as file:
file.write(j)
print("Settings saved successfully.")
except Exception as e:
print(f"Error saving settings: {e}")
def load(self):
try:
with open(self.SETTINGS_FILE, 'r') as file:
loaded_settings = json.load(file)
self.update(loaded_settings)
# Ensure patterns exist (they should always be in settings.json)
if "patterns" not in self:
# Initialize with default patterns if missing
self["patterns"] = {
"on": {"min_delay": 10, "max_delay": 10000},
"off": {"min_delay": 10, "max_delay": 10000},
"rainbow": {"Step Rate": "n1", "min_delay": 10, "max_delay": 10000},
"transition": {"min_delay": 10, "max_delay": 10000},
"chase": {
"Colour 1 Length": "n1",
"Colour 2 Length": "n2",
"Step 1": "n3",
"Step 2": "n4",
"min_delay": 10,
"max_delay": 10000
},
"pulse": {
"Attack": "n1",
"Hold": "n2",
"Decay": "n3",
"min_delay": 10,
"max_delay": 10000
},
"circle": {
"Head Rate": "n1",
"Max Length": "n2",
"Tail Rate": "n3",
"Min Length": "n4",
"min_delay": 10,
"max_delay": 10000
},
"blink": {"min_delay": 10, "max_delay": 10000}
}
self.save() # Save to persist the default patterns
print("Settings loaded successfully.")
except Exception as e:
print(f"Error loading settings {e}")
self.save()

124
src/sound.py Normal file
View File

@@ -0,0 +1,124 @@
#!/usr/bin/python
import pyaudio
import aubio
import numpy as np
from time import sleep
import websocket # pip install websocket-client
import json
seconds = 10 # how long this script should run (if not using infinite loop)
bufferSize = 512
windowSizeMultiple = 2 # or 4 for higher accuracy, but more computational cost
audioInputDeviceIndex = 7 # use 'arecord -l' to check available audio devices
audioInputChannels = 1
pa = pyaudio.PyAudio()
print("Available audio input devices:")
info = pa.get_host_api_info_by_index(0)
num_devices = info.get('deviceCount')
found_device = False
for i in range(0, num_devices):
device_info = pa.get_device_info_by_host_api_device_index(0, i)
if (device_info.get('maxInputChannels')) > 0:
print(f" Input Device id {i} - {device_info.get('name')}")
if i == audioInputDeviceIndex:
found_device = True
if not found_device:
print(f"Warning: Audio input device index {audioInputDeviceIndex} not found or has no input channels.")
# Consider exiting or picking a default if necessary
try:
audioInputDevice = pa.get_device_info_by_index(audioInputDeviceIndex)
audioInputSampleRate = int(audioInputDevice['defaultSampleRate'])
except Exception as e:
print(f"Error getting audio device info for index {audioInputDeviceIndex}: {e}")
pa.terminate()
exit()
# create the aubio tempo detection:
hopSize = bufferSize
winSize = hopSize * windowSizeMultiple
tempoDetection = aubio.tempo(method='default', buf_size=winSize, hop_size=hopSize, samplerate=audioInputSampleRate)
# --- WebSocket Setup ---
websocket_url = "ws://192.168.4.1:80/ws"
ws = None
try:
ws = websocket.create_connection(websocket_url)
print(f"Successfully connected to WebSocket at {websocket_url}")
except Exception as e:
print(f"Failed to connect to WebSocket: {e}. Data will not be sent over WebSocket.")
# --- End WebSocket Setup ---
# this function gets called by the input stream, as soon as enough samples are collected from the audio input:
def readAudioFrames(in_data, frame_count, time_info, status):
global ws # Allow modification of the global ws variable
signal = np.frombuffer(in_data, dtype=np.float32)
beat = tempoDetection(signal)
if beat:
bpm = tempoDetection.get_bpm()
print(f"beat! (running with {bpm:.2f} bpm)") # Use f-string for cleaner formatting, removed extra bells
data_to_send = {
"names": ["1"],
"settings": {
"pattern": "pulse",
"delay": 10,
"colors": ["#00ff00"],
"brightness": 10,
"num_leds": 200,
},
}
if ws: # Only send if the websocket connection is established
try:
ws.send(json.dumps(data_to_send))
# print("Sent data over WebSocket") # Optional: for debugging
except websocket.WebSocketConnectionClosedException:
print("WebSocket connection closed, attempting to reconnect...")
ws = None # Mark as closed, connection will need to be re-established if desired
except Exception as e:
print(f"Error sending over WebSocket: {e}")
return (in_data, pyaudio.paContinue)
# create and start the input stream
try:
inputStream = pa.open(format=pyaudio.paFloat32,
input=True,
channels=audioInputChannels,
input_device_index=audioInputDeviceIndex,
frames_per_buffer=bufferSize,
rate=audioInputSampleRate,
stream_callback=readAudioFrames)
inputStream.start_stream()
print("\nAudio stream started. Detecting beats. Press Ctrl+C to stop.")
# Loop to keep the script running, allowing graceful shutdown
while inputStream.is_active():
sleep(0.1) # Small delay to prevent busy-waiting
except KeyboardInterrupt:
print("\nKeyboardInterrupt: Stopping script gracefully.")
except Exception as e:
print(f"An error occurred with the audio stream: {e}")
finally:
# Ensure streams and resources are closed
if 'inputStream' in locals() and inputStream.is_active():
inputStream.stop_stream()
if 'inputStream' in locals() and not inputStream.is_stopped():
inputStream.close()
pa.terminate()
if ws:
print("Closing WebSocket connection.")
ws.close()
print("Script finished.")

1650
static/app.js Normal file

File diff suppressed because it is too large Load Diff

548
static/style.css Normal file
View File

@@ -0,0 +1,548 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: #2e2e2e;
color: white;
height: 100vh;
overflow: hidden;
}
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
}
header {
background-color: #1a1a1a;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid #4a4a4a;
}
header h1 {
font-size: 1.5rem;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #4a4a4a;
color: white;
}
.btn-primary:hover {
background-color: #5a5a5a;
}
.btn-secondary {
background-color: #3a3a3a;
color: white;
}
.btn-secondary:hover {
background-color: #4a4a4a;
}
.btn-danger {
background-color: #d32f2f;
color: white;
}
.btn-danger:hover {
background-color: #c62828;
}
.btn-small {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tabs-container {
background-color: #1a1a1a;
border-bottom: 2px solid #4a4a4a;
padding: 0.5rem 1rem;
}
.tabs-list {
display: flex;
gap: 0.5rem;
overflow-x: auto;
}
.tab-button {
padding: 0.5rem 1rem;
background-color: #3a3a3a;
color: white;
border: none;
border-radius: 4px 4px 0 0;
cursor: pointer;
font-size: 0.9rem;
white-space: nowrap;
transition: background-color 0.2s;
}
.tab-button:hover {
background-color: #4a4a4a;
}
.tab-button.active {
background-color: #6a5acd;
color: white;
}
.tab-content {
flex: 1;
display: flex;
overflow: hidden;
padding: 1rem;
gap: 1rem;
}
.left-panel {
flex: 0 0 50%;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
border-right: 2px solid #4a4a4a;
padding-right: 1rem;
}
.right-panel {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
padding-left: 1rem;
}
.ids-display {
padding: 0.5rem;
background-color: #3a3a3a;
border-radius: 4px;
font-size: 0.9rem;
}
.left-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.left-panel-toggle {
padding: 0.25rem 0.5rem;
min-width: 32px;
}
.left-panel-body {
display: flex;
flex-direction: column;
gap: 1rem;
}
.left-panel.collapsed {
flex: 0 0 48px;
padding-right: 0.5rem;
}
.left-panel.collapsed .left-panel-body {
display: none;
}
.left-panel.collapsed .left-panel-toggle {
transform: rotate(180deg);
}
.controls-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.control-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.control-group label {
min-width: 100px;
font-weight: 500;
}
.slider {
flex: 1;
height: 8px;
background-color: #3a3a3a;
border-radius: 4px;
outline: none;
-webkit-appearance: none;
margin: 0 0.5rem;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
background-color: #6a5acd;
border-radius: 50%;
cursor: pointer;
transition: background-color 0.2s;
}
.slider::-webkit-slider-thumb:hover {
background-color: #7a6add;
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
background-color: #6a5acd;
border-radius: 50%;
cursor: pointer;
border: none;
transition: background-color 0.2s;
}
.slider::-moz-range-thumb:hover {
background-color: #7a6add;
}
/* Red slider */
#red-slider {
accent-color: #ff0000;
}
#red-slider::-webkit-slider-thumb {
background-color: #ff0000;
}
#red-slider::-moz-range-thumb {
background-color: #ff0000;
}
/* Green slider */
#green-slider {
accent-color: #00ff00;
}
#green-slider::-webkit-slider-thumb {
background-color: #00ff00;
}
#green-slider::-moz-range-thumb {
background-color: #00ff00;
}
/* Blue slider */
#blue-slider {
accent-color: #0000ff;
}
#blue-slider::-webkit-slider-thumb {
background-color: #0000ff;
}
#blue-slider::-moz-range-thumb {
background-color: #0000ff;
}
/* Brightness slider */
#brightness-slider {
accent-color: #ffff00;
}
#brightness-slider::-webkit-slider-thumb {
background-color: #ffff00;
}
#brightness-slider::-moz-range-thumb {
background-color: #ffff00;
}
.slider-value {
min-width: 50px;
text-align: right;
font-weight: 500;
font-size: 0.9rem;
}
.n-params-section {
margin-top: 1rem;
}
.n-params-section h3 {
margin-bottom: 0.5rem;
font-size: 1rem;
}
.n-params-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.n-param-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.n-param-group label {
min-width: 40px;
font-weight: 500;
}
.n-input {
flex: 1;
padding: 0.5rem;
background-color: #3a3a3a;
color: white;
border: 1px solid #4a4a4a;
border-radius: 4px;
font-size: 1rem;
}
.n-input:focus {
outline: none;
border-color: #6a5acd;
}
.patterns-section,
.presets-section,
.color-palette-section {
background-color: #1a1a1a;
border: 2px solid #4a4a4a;
border-radius: 4px;
padding: 1rem;
}
.patterns-section h3,
.presets-section h3,
.color-palette-section h3 {
margin-bottom: 1rem;
font-size: 1.1rem;
}
.patterns-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.presets-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.pattern-button {
padding: 0.75rem;
background-color: #3a3a3a;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
text-align: left;
transition: background-color 0.2s;
}
.pattern-button:hover {
background-color: #4a4a4a;
}
.pattern-button.active {
background-color: #6a5acd;
color: white;
}
.pattern-button.default-preset {
border: 2px solid #6a5acd;
}
.color-palette {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
max-height: 300px;
overflow-y: auto;
}
.color-swatch {
display: flex;
align-items: center;
padding: 0.5rem;
background-color: #3a3a3a;
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.2s;
gap: 0.5rem;
}
.color-swatch:hover {
border-color: #6a5acd;
}
.color-swatch.selected {
border-color: #FFD700;
border-width: 3px;
}
.color-swatch-preview {
width: 40px;
height: 40px;
border-radius: 4px;
border: 1px solid #4a4a4a;
flex-shrink: 0;
}
.color-swatch-label {
flex: 1;
font-size: 0.9rem;
min-width: 80px;
}
.color-picker-input {
width: 60px;
height: 40px;
border: 1px solid #4a4a4a;
border-radius: 4px;
cursor: pointer;
background: none;
padding: 0;
flex-shrink: 0;
}
.color-picker-input::-webkit-color-swatch-wrapper {
padding: 0;
}
.color-picker-input::-webkit-color-swatch {
border: none;
border-radius: 4px;
}
.color-picker-input::-moz-color-swatch {
border: none;
border-radius: 4px;
}
.palette-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background-color: #2e2e2e;
padding: 2rem;
border-radius: 8px;
min-width: 400px;
max-width: 500px;
}
.modal-content h2 {
margin-bottom: 1rem;
font-size: 1.3rem;
}
.modal-content label {
display: block;
margin-top: 1rem;
margin-bottom: 0.5rem;
font-weight: 500;
}
.modal-content input {
width: 100%;
padding: 0.5rem;
background-color: #3a3a3a;
color: white;
border: 1px solid #4a4a4a;
border-radius: 4px;
font-size: 1rem;
}
.modal-content input:focus {
outline: none;
border-color: #6a5acd;
}
.modal-actions {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
justify-content: flex-end;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1a1a1a;
}
::-webkit-scrollbar-thumb {
background: #4a4a4a;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #5a5a5a;
}

279
templates/index.html Normal file
View File

@@ -0,0 +1,279 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lighting Controller</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="app-container">
<header>
<h1>Lighting Controller</h1>
<div class="header-actions">
<button id="add-tab-btn" class="btn btn-primary">+ Add Tab</button>
<button id="edit-tab-btn" class="btn btn-secondary">Edit Tab</button>
<button id="delete-tab-btn" class="btn btn-danger">Delete Tab</button>
<button id="color-palette-btn" class="btn btn-secondary">Color Palette</button>
<button id="presets-btn" class="btn btn-secondary">Presets</button>
<button id="profiles-btn" class="btn btn-secondary">Profiles</button>
</div>
</header>
<div class="main-content">
<div class="tabs-container">
<div id="tabs-list" class="tabs-list"></div>
</div>
<div id="tab-content" class="tab-content">
<div class="left-panel">
<div class="left-panel-header">
<div class="ids-display">
<label>IDs: </label>
<span id="current-ids"></span>
</div>
<button id="toggle-left-panel" class="btn btn-small left-panel-toggle" title="Collapse/expand controls"></button>
</div>
<div class="left-panel-body">
<div class="color-palette-section">
<h3>Color Palette</h3>
<div id="color-palette" class="color-palette"></div>
<div class="palette-actions">
<button id="add-color-btn" class="btn btn-small">Add Color</button>
<button id="remove-color-btn" class="btn btn-small">Remove Selected</button>
</div>
</div>
<div class="controls-section">
<div class="control-group">
<label for="brightness-slider">Brightness:</label>
<input type="range" id="brightness-slider" min="0" max="255" value="127" class="slider">
<span id="brightness-value" class="slider-value">127</span>
</div>
<div class="control-group">
<label for="delay-slider">Delay:</label>
<input type="range" id="delay-slider" min="0" max="1000" value="0" class="slider">
<span id="delay-value" class="slider-value">100 ms</span>
</div>
</div>
<div class="n-params-section">
<h3>N Parameters</h3>
<div class="n-params-grid">
<div class="n-param-group">
<label for="n1-input">n1:</label>
<input type="number" id="n1-input" min="0" max="255" value="10" class="n-input">
</div>
<div class="n-param-group">
<label for="n2-input">n2:</label>
<input type="number" id="n2-input" min="0" max="255" value="10" class="n-input">
</div>
<div class="n-param-group">
<label for="n3-input">n3:</label>
<input type="number" id="n3-input" min="0" max="255" value="10" class="n-input">
</div>
<div class="n-param-group">
<label for="n4-input">n4:</label>
<input type="number" id="n4-input" min="0" max="255" value="10" class="n-input">
</div>
</div>
</div>
</div>
</div>
<div class="right-panel">
<div class="presets-section">
<h3>Presets</h3>
<div id="presets-list-tab" class="presets-list"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Modals -->
<div id="add-tab-modal" class="modal">
<div class="modal-content">
<h2>Add New Tab</h2>
<label>Tab Name:</label>
<input type="text" id="new-tab-name" placeholder="Enter tab name">
<label>Device IDs (comma-separated):</label>
<input type="text" id="new-tab-ids" placeholder="1,2,3" value="1">
<div class="modal-actions">
<button id="add-tab-confirm" class="btn btn-primary">Add</button>
<button id="add-tab-cancel" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
<div id="edit-tab-modal" class="modal">
<div class="modal-content">
<h2>Edit Tab</h2>
<label>Tab Name:</label>
<input type="text" id="edit-tab-name" placeholder="Enter tab name">
<label>Device IDs (comma-separated):</label>
<input type="text" id="edit-tab-ids" placeholder="1,2,3">
<div class="modal-actions">
<button id="edit-tab-confirm" class="btn btn-primary">Update</button>
<button id="edit-tab-cancel" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
<div id="profiles-modal" class="modal">
<div class="modal-content" style="min-width: 500px;">
<h2>Profiles</h2>
<div id="profiles-list-container" style="margin: 1rem 0; max-height: 400px; overflow-y: auto;">
<div id="profiles-list"></div>
</div>
<div style="margin-top: 1rem;">
<label>New Profile Name:</label>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<input type="text" id="new-profile-name" placeholder="Enter profile name" style="flex: 1;">
<button id="create-profile-btn" class="btn btn-primary">Create</button>
</div>
</div>
<div style="margin-top: 1rem;">
<label>Current Profile:</label>
<div id="current-profile-display" style="padding: 0.5rem; background-color: #3a3a3a; border-radius: 4px; margin-top: 0.5rem;">
<span id="current-profile-name">None</span>
</div>
</div>
<div style="margin-top: 1.5rem;">
<label>Profile Color Palette:</label>
<div id="profile-palette-container" style="margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.5rem; background-color: #3a3a3a; border-radius: 4px; min-height: 60px;">
<!-- Palette colors will be rendered here -->
</div>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<input type="color" id="new-palette-color" value="#000000" style="width: 60px; height: 40px; border: 1px solid #4a4a4a; border-radius: 4px; cursor: pointer;">
<button id="add-palette-color-btn" class="btn btn-small">Add to Palette</button>
</div>
</div>
<div class="modal-actions">
<button id="profiles-close-btn" class="btn btn-secondary">Close</button>
</div>
</div>
</div>
<div id="color-palette-modal" class="modal">
<div class="modal-content" style="min-width: 500px;">
<h2>Color Palette</h2>
<div style="margin-top: 1rem;">
<label>Current Profile:</label>
<div id="palette-current-profile-display" style="padding: 0.5rem; background-color: #3a3a3a; border-radius: 4px; margin-top: 0.5rem;">
<span id="palette-current-profile-name">None</span>
</div>
</div>
<div style="margin-top: 1.5rem;">
<label>Profile Color Palette:</label>
<div id="palette-container" style="margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.5rem; background-color: #3a3a3a; border-radius: 4px; min-height: 60px;">
<!-- Palette colors will be rendered here -->
</div>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<input type="color" id="palette-new-color" value="#000000" style="width: 60px; height: 40px; border: 1px solid #4a4a4a; border-radius: 4px; cursor: pointer;">
<button id="palette-add-color-btn" class="btn btn-small">Add to Palette</button>
</div>
</div>
<div class="modal-actions">
<button id="color-palette-close-btn" class="btn btn-secondary">Close</button>
</div>
</div>
</div>
<div id="quick-palette-modal" class="modal">
<div class="modal-content" style="min-width: 500px; max-width: 600px;">
<h2>Select Color from Palette</h2>
<div id="quick-palette-container" style="margin-top: 1rem; display: flex; flex-wrap: wrap; gap: 0.75rem; padding: 1rem; background-color: #3a3a3a; border-radius: 4px; min-height: 200px; max-height: 500px; overflow-y: auto;">
<!-- Palette colors will be rendered here -->
</div>
<div class="modal-actions" style="margin-top: 1rem;">
<button id="quick-palette-use-picker-btn" class="btn btn-secondary">Use Color Picker</button>
<button id="quick-palette-close-btn" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
<div id="presets-modal" class="modal">
<div class="modal-content" style="min-width: 600px; max-width: 800px;">
<h2>Presets</h2>
<div id="presets-list-container" style="margin: 1rem 0; max-height: 400px; overflow-y: auto;">
<div id="presets-list"></div>
</div>
<div class="modal-actions">
<button id="create-preset-btn" class="btn btn-primary">Create Preset</button>
<button id="presets-close-btn" class="btn btn-secondary">Close</button>
</div>
</div>
</div>
<div id="preset-editor-modal" class="modal">
<div class="modal-content" style="min-width: 600px; max-width: 800px; max-height: 90vh; overflow-y: auto;">
<h2 id="preset-editor-title">Create Preset</h2>
<div style="display: flex; flex-direction: column; gap: 1rem;">
<div>
<label>Preset Name:</label>
<input type="text" id="preset-name-input" placeholder="Enter preset name" style="width: 100%; padding: 0.5rem; margin-top: 0.5rem;">
</div>
<div>
<label>Pattern:</label>
<select id="preset-pattern-select" style="width: 100%; padding: 0.5rem; margin-top: 0.5rem;">
<!-- Patterns will be populated dynamically -->
</select>
</div>
<div>
<label>Brightness:</label>
<div style="display: flex; align-items: center; gap: 1rem; margin-top: 0.5rem;">
<input type="range" id="preset-brightness-slider" min="0" max="255" value="127" class="slider" style="flex: 1;">
<span id="preset-brightness-value" class="slider-value">127</span>
</div>
</div>
<div>
<label>Delay (ms):</label>
<input type="number" id="preset-delay-input" min="10" max="10000" value="100" style="width: 100%; padding: 0.5rem; margin-top: 0.5rem;">
</div>
<div>
<label>N Parameters:</label>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; margin-top: 0.5rem;">
<div>
<label for="preset-n1-input">n1:</label>
<input type="number" id="preset-n1-input" min="0" max="255" value="10" style="width: 100%; padding: 0.5rem;">
</div>
<div>
<label for="preset-n2-input">n2:</label>
<input type="number" id="preset-n2-input" min="0" max="255" value="10" style="width: 100%; padding: 0.5rem;">
</div>
<div>
<label for="preset-n3-input">n3:</label>
<input type="number" id="preset-n3-input" min="0" max="255" value="10" style="width: 100%; padding: 0.5rem;">
</div>
<div>
<label for="preset-n4-input">n4:</label>
<input type="number" id="preset-n4-input" min="0" max="255" value="10" style="width: 100%; padding: 0.5rem;">
</div>
</div>
</div>
<div>
<label>Colors:</label>
<div id="preset-colors-container" style="margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.5rem; background-color: #3a3a3a; border-radius: 4px; min-height: 60px;">
<!-- Colors will be rendered here -->
</div>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<input type="color" id="preset-new-color" value="#000000" style="width: 60px; height: 40px; border: 1px solid #4a4a4a; border-radius: 4px; cursor: pointer;">
<button id="preset-add-color-btn" class="btn btn-small">Add Color</button>
<button id="preset-remove-color-btn" class="btn btn-small">Remove Selected</button>
</div>
</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
<button id="preset-editor-from-current-btn" class="btn btn-secondary" style="flex: 1;">Create from Current</button>
<button id="preset-editor-save-btn" class="btn btn-primary" style="flex: 1;">Save Preset</button>
<button id="preset-editor-cancel-btn" class="btn btn-secondary" style="flex: 1;">Cancel</button>
</div>
</div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>

4
tmp_explanation.txt Normal file
View File

@@ -0,0 +1,4 @@
This is just a placeholder to satisfy the tool requirement; actual code changes are in other files.