Skip to content

Commit d12a496

Browse files
fhirschmannclaude
andcommitted
feat: state-driven status control-LEDs (keylock/repeat/bluetooth)
The optional control LEDs (NUM_CONTROL_LEDS, soldered to the end of the existing NeoPixel data line) could only show a fixed color. Each control LED now gets a per-slot function, configurable in the web LED settings and persisted in NVS: - Static : legacy fixed color from controlColors - KeyLock : red while physical controls are locked - Repeat : green=track, blue=playlist, purple=both - Bluetooth: violet=waiting, blue=connected (only in BT modes) - Off : always off Colors are written at full scale; the global FastLED master brightness keeps them in sync with night mode, so legacy static control LEDs behave exactly as before. Defaults to Static for every slot (backward compatible). A master switch mutes all control LEDs (e.g. at night): bindable command 121 (CMD_TOGGLE_CONTROL_LEDS) and the MQTT command/state-topic control_leds (ON/OFF) incl. Home Assistant discovery switch. Web interface: per-slot function dropdown next to the color picker (color only enabled for Static) plus a "Control LEDs enabled" toggle; i18n for de/en/fr. README updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3e2f2fb commit d12a496

12 files changed

Lines changed: 229 additions & 17 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ neon logo that doubles as the SVG favicon ([`7be5254`](../../commit/7be5254)):
131131
| Home Assistant **MQTT discovery**: on connect the device auto-registers all entities under one HA device — track/status/firmware/software/WiFi (+ battery) sensors, volume & LED-brightness numbers, lock & ambient-light switches, equalizer select, and transport/update/shutdown buttons | [`db73db1`](../../commit/db73db1) |
132132
| Consolidated **Settings** tab (UniFi-style): WiFi, MQTT, FTP and Bluetooth settings merged with the general settings into one top tab with centered horizontal sub-tabs (General / Buttons / WiFi / MQTT / FTP / Bluetooth), the button-assignment config moved into its own **Buttons** sub-tab, plus an FTP start/stop button in the control tab; also fixes the sleep-timer dropdown staying open on outside-click and the desktop control-tab volume slider rendering at zero width | [`8b01876`](../../commit/8b01876) |
133133
| Battery-backed **RTC** (DS3231): optional `RTC_ENABLE` adds a real-time clock on the external I²C bus so the time stays correct without WiFi/NTP. The RTC stores UTC and seeds the system clock at boot (before WiFi is up); NTP remains the master and disciplines the RTC on every sync and hourly. Current time (and DS3231 die-temperature) shown in the info dialog, published via the MQTT state-topic `rtc` (+ Home Assistant discovery), and the RTC can be re-disciplined from system time via the bindable command **155** (button/RFID modifier) | [`bf90f66`](../../commit/bf90f66) |
134+
| **Status control-LEDs**: the optional control LEDs (`NUM_CONTROL_LEDS`, soldered to the end of the same NeoPixel data line — no extra GPIO) can now mirror a runtime state instead of just showing a static color. Each control LED gets a per-slot function in the LED settings — **Static** (legacy fixed color), **Key lock** (red while controls are locked), **Repeat mode** (green = track, blue = playlist, purple = both), **Bluetooth** (violet = waiting, blue = connected) or **Off**. A master switch mutes all control LEDs (e.g. at night), toggleable via the bindable command **121** (button/RFID modifier) and the MQTT command/state-topic `control_leds` (`ON`/`OFF`, + Home Assistant discovery switch) | [`6712ae1`](../../commit/6712ae1) |
134135

135136
#### Feature highlights
136137

‎html/locales/de.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@
4848
"numindicator": "Anzahl Anzeige-LEDs",
4949
"numcontrol": "Anzahl Kontroll-LEDs",
5050
"controlled": "Kontroll-LED",
51+
"controlledsenabled": "Kontroll-LEDs aktiv",
52+
"controlfunc": {
53+
"static": "Statische Farbe",
54+
"keylock": "Tastensperre",
55+
"repeat": "Wiederholmodus",
56+
"bluetooth": "Bluetooth",
57+
"off": "Aus"
58+
},
5159
"numidledots": "Anzahl LEDs im Leerlauf",
5260
"idlecolor": "Farbe der Leerlauf-LEDs",
5361
"idleanimation": "Leerlauf-Animation",
@@ -278,6 +286,7 @@
278286
"110": "Wiederhole Playlist (endlos)",
279287
"111": "Wiederhole Titel (endlos)",
280288
"120": "Dimme LEDs (Nachtmodus)",
289+
"121": "Kontroll-LEDs an/aus",
281290
"130": "Aktiviere/deaktiviere WLAN",
282291
"140": "Bluetooth-Lautsprecher aktivieren/deaktivieren",
283292
"141": "Bluetooth-Kopfhörer aktivieren/deaktivieren",

‎html/locales/en.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@
4848
"numindicator": "Number of indicator LEDs",
4949
"numcontrol": "Number of control LEDs",
5050
"controlled": "Control-LED",
51+
"controlledsenabled": "Control LEDs enabled",
52+
"controlfunc": {
53+
"static": "Static color",
54+
"keylock": "Key lock",
55+
"repeat": "Repeat mode",
56+
"bluetooth": "Bluetooth",
57+
"off": "Off"
58+
},
5159
"numidledots": "Number of LEDs in idle state",
5260
"idlecolor": "Color of the idle dots",
5361
"idleanimation": "Idle animation",
@@ -279,6 +287,7 @@
279287
"110": "Loop playlist",
280288
"111": "Loop track",
281289
"120": "Dimm LEDs (nightmode)",
290+
"121": "Toggle control LEDs",
282291
"130": "Toggle WiFi",
283292
"140": "Toggle Bluetooth Speaker",
284293
"141": "Toggle Bluetooth Headphones",

‎html/locales/fr.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@
4848
"numindicator": "Nombre d'indicateurs LED",
4949
"numcontrol": "Nombre de LED de contrôle",
5050
"controlled": "LED de contrôle",
51+
"controlledsenabled": "LED de contrôle activées",
52+
"controlfunc": {
53+
"static": "Couleur statique",
54+
"keylock": "Verrouillage des touches",
55+
"repeat": "Mode répétition",
56+
"bluetooth": "Bluetooth",
57+
"off": "Éteint"
58+
},
5159
"numidledots": "Nombre de LED en état de veille",
5260
"idlecolor": "Couleur des LED en veille",
5361
"idleanimation": "Animation de veille",
@@ -271,6 +279,7 @@
271279
"110": "Boucler la liste de lecture",
272280
"111": "Boucler la piste",
273281
"120": "Diminuer la luminosité des LED (mode nuit)",
282+
"121": "Activer/désactiver les LED de contrôle",
274283
"130": "Basculer le WiFi",
275284
"140": "Basculer le haut-parleur Bluetooth",
276285
"141": "Basculer le casque Bluetooth",

‎html/management.html‎

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2371,11 +2371,23 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
23712371
min="0" max="128"><br>
23722372
<label for="led_numControl" data-i18n="settingsex.led.numcontrol"></label>: <br>
23732373
<input type="number" class="form-control" id="led_numControl" name="led_numcontrol"
2374-
min="0" max="128" onChange="listControlColors(Number(this.value), null)"><br>
2374+
min="0" max="128" onChange="listControlColors(Number(this.value), null, null)"><br>
23752375
<div id="controlColorList" class="mb-2 d-none"></div>
23762376
<div id="controlColorTemp" class="pb-2 d-none">
23772377
<span data-i18n="settingsex.led.controlled"></span>&nbsp;<span
2378-
class="ledNumber"></span>:&nbsp;<input type="color" value="#ffffff">
2378+
class="ledNumber"></span>:&nbsp;<select class="form-control form-select controlFunc d-inline-block w-auto">
2379+
<option value="0" data-i18n="settingsex.led.controlfunc.static"></option>
2380+
<option value="1" data-i18n="settingsex.led.controlfunc.keylock"></option>
2381+
<option value="2" data-i18n="settingsex.led.controlfunc.repeat"></option>
2382+
<option value="3" data-i18n="settingsex.led.controlfunc.bluetooth"></option>
2383+
<option value="4" data-i18n="settingsex.led.controlfunc.off"></option>
2384+
</select>&nbsp;<input type="color" class="controlColorInput" value="#ffffff">
2385+
</div>
2386+
<div id="controlLedsEnabledWrap" class="pb-2 d-none">
2387+
<input type="checkbox" id="led_controlLedsEnabled"
2388+
name="led_controlLedsEnabled" value="true">&nbsp;
2389+
<label for="led_controlLedsEnabled"
2390+
data-i18n="settingsex.led.controlledsenabled"></label><br>
23792391
</div>
23802392
<label for="led_idleDots" data-i18n="settingsex.led.numidledots"></label>: <br>
23812393
<input type="number" class="form-control" id="led_idleDots" name="led_idleDots" min="0"
@@ -4195,22 +4207,37 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
41954207
return "#" + ('000000' + ((number) >>> 0).toString(16)).slice(-6);
41964208
}
41974209

4198-
// list the LED control colors
4199-
function listControlColors(numControlColor, data) {
4200-
console.log(data);
4210+
// the control LED color picker is only relevant for the 'Static' function (value 0)
4211+
function updateControlColorState(select) {
4212+
const colorInput = $(select).closest("div").find(".controlColorInput");
4213+
colorInput.prop("disabled", Number(select.value) !== 0);
4214+
}
4215+
4216+
// list the LED control LEDs with their function dropdown and (static) color
4217+
function listControlColors(numControlColor, colorData, funcData) {
42014218
const list = $("#controlColorList");
42024219
list.toggleClass("d-none", numControlColor <= 0).empty();
4220+
$("#controlLedsEnabledWrap").toggleClass("d-none", numControlColor <= 0);
42034221
if (numControlColor > 0) {
42044222
const t = $("#controlColorTemp");
42054223
for (let i = 0; i < numControlColor; i++) {
4206-
const ledN = t.clone().removeClass("d-none");
4207-
const input = $("input", ledN).attr("id", `controlColor${i}`);
4224+
const ledN = t.clone().removeClass("d-none").attr("id", `controlLed${i}`);
4225+
const colorInput = $(".controlColorInput", ledN).attr("id", `controlColor${i}`);
4226+
const funcSelect = $(".controlFunc", ledN).attr("id", `controlFunc${i}`);
42084227
$(".ledNumber", ledN).text(i + 1);
4209-
if (data) {
4210-
input.val(getHexColor(data[i]));
4228+
if (colorData && colorData[i] != null) {
4229+
colorInput.val(getHexColor(colorData[i]));
42114230
}
4231+
if (funcData && funcData[i] != null) {
4232+
funcSelect.val(funcData[i]);
4233+
}
4234+
funcSelect.on("change", function () { updateControlColorState(this); });
42124235
list.append(ledN);
42134236
}
4237+
if (localize) {
4238+
localize("#controlColorList");
4239+
}
4240+
$(".controlFunc", list).each(function () { updateControlColorState(this); });
42144241
}
42154242
}
42164243

@@ -4348,7 +4375,8 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
43484375
$('#atmoBrightness').bootstrapSlider('setValue', ledSettings.atmoBrightness);
43494376
document.getElementById('led_numIndicator').value = ledSettings.numIndicator;
43504377
document.getElementById('led_numControl').value = ledSettings.numControl;
4351-
listControlColors(ledSettings.numControl, ledSettings.controlColors);
4378+
document.getElementById('led_controlLedsEnabled').checked = ledSettings.controlLedsEnabled !== false;
4379+
listControlColors(ledSettings.numControl, ledSettings.controlColors, ledSettings.controlFuncs);
43524380
document.getElementById('led_idleDots').value = ledSettings.numIdleDots;
43534381
document.getElementById('led_idleColor').value = getHexColor(ledSettings.idleColor);
43544382
document.getElementById('led_idleAnimation').value = ledSettings.idleAnimation != null ? ledSettings.idleAnimation : 0;
@@ -4780,7 +4808,9 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
47804808
atmoBrightness: Number($('#atmoBrightness').val()),
47814809
numIndicator: Number(document.getElementById("led_numIndicator").value),
47824810
numControl: Number(document.getElementById("led_numControl").value),
4811+
controlLedsEnabled: document.getElementById("led_controlLedsEnabled").checked,
47834812
controlColors: [],
4813+
controlFuncs: [],
47844814
numIdleDots: Number(document.getElementById("led_idleDots").value),
47854815
idleColor: hexColorToInt(document.getElementById("led_idleColor").value),
47864816
idleAnimation: Number(document.getElementById("led_idleAnimation").value),
@@ -4845,12 +4875,14 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
48454875
reverse: $('#rotaryReverse').prop('checked')
48464876
}
48474877
};
4848-
// fill LED control colors
4878+
// fill LED control colors and functions
48494879
for (var i = 0; i < myObj.led.numControl; i++) {
48504880
var element = document.getElementById('controlColor' + i);
48514881
if (element) {
48524882
myObj.led.controlColors.push(hexColorToInt(element.value));
48534883
}
4884+
var funcElement = document.getElementById('controlFunc' + i);
4885+
myObj.led.controlFuncs.push(funcElement ? Number(funcElement.value) : 0);
48544886
}
48554887
console.log(myObj);
48564888
var myJSON = JSON.stringify(myObj);
@@ -5980,7 +6012,7 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
59806012
const cmdElem = document.createElement('select');
59816013
const cmdNothing = addOption(cmdElem, 0);
59826014
cmdNothing.setAttribute('data-i18n', "settingsex.buttons.noaction");
5983-
const cmds = [170, 171, 172, 173, 174, 184, 185, 175, 176, 177, 178, 179, 180, 181, 182, 183, 186, 199, 130, 140, 141, 142, 143, 144, 150, 151, 152, 153, 154, 120, 100, 101, 102, 103, 104, 105, 106, 107, 110, 111, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250];
6015+
const cmds = [170, 171, 172, 173, 174, 184, 185, 175, 176, 177, 178, 179, 180, 181, 182, 183, 186, 199, 130, 140, 141, 142, 143, 144, 150, 151, 152, 153, 154, 120, 121, 100, 101, 102, 103, 104, 105, 106, 107, 110, 111, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250];
59846016
for (const cmd of cmds) {
59856017
const opt = addOption(cmdElem, cmd);
59866018
if ([140, 141, 142, 143, 144].includes(cmd)) {
@@ -5994,7 +6026,7 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
59946026

59956027
/* The <select /> that houses all commands for the modification selectors */
59966028
const modElem = document.createElement('select');
5997-
const mods = [100, 179, 101, 102, 103, 104, 105, 106, 110, 111, 120, 130, 140, 141, 142, 143, 144, 150, 151, 152, 153, 154, 0, 170, 171, 172, 173, 174, 184, 185, 180, 181, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250];
6029+
const mods = [100, 179, 101, 102, 103, 104, 105, 106, 110, 111, 120, 121, 130, 140, 141, 142, 143, 144, 150, 151, 152, 153, 154, 0, 170, 171, 172, 173, 174, 184, 185, 180, 181, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250];
59986030
for (const mod of mods) {
59996031
const opt = addOption(modElem, mod);
60006032
if ([140, 141, 142, 143, 144].includes(mod)) {

‎src/Cmd.cpp‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,15 @@ void Cmd_Action(const uint16_t mod) {
200200
break;
201201
}
202202

203+
case CMD_TOGGLE_CONTROL_LEDS: {
204+
Led_ToggleControlLeds();
205+
#ifdef MQTT_ENABLE
206+
publishMqtt(topicControlLeds, Led_GetControlLeds() ? "ON" : "OFF", false);
207+
#endif
208+
System_IndicateOk();
209+
break;
210+
}
211+
203212
case CMD_TOGGLE_EQUALIZER: {
204213
AudioPlayer_CycleEqualizerProfile();
205214
System_IndicateOk();

‎src/Led.cpp‎

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,19 @@ bool Led_LoadSettings(LedSettings &settings) {
158158
gPrefsSettings.getBytes("controlColors", settings.controlLedColors.data(), keySize);
159159
}
160160
}
161+
162+
// load control LED functions from NVS (defaults to Static for every slot to keep legacy behaviour)
163+
settings.controlLedFunctions = CONTROL_LEDS_FUNCTIONS;
164+
settings.controlLedFunctions.resize(settings.numControlLeds, static_cast<uint8_t>(LedControlFunction::Static));
165+
if ((settings.numControlLeds > 0) && gPrefsSettings.isKey("controlFuncs")) {
166+
size_t keySize = gPrefsSettings.getBytesLength("controlFuncs");
167+
if (keySize == (settings.numControlLeds * sizeof(uint8_t))) {
168+
gPrefsSettings.getBytes("controlFuncs", settings.controlLedFunctions.data(), keySize);
169+
}
170+
}
171+
172+
// load the control LED master switch from NVS (defaults to enabled)
173+
settings.controlLedsEnabled = gPrefsSettings.getBool("ctrlLedsOn", true);
161174
return true;
162175
}
163176
#endif
@@ -325,6 +338,29 @@ void Led_ToggleAmbientLight() {
325338
#endif
326339
}
327340

341+
void Led_SetControlLeds(bool enabled) {
342+
#ifdef NEOPIXEL_ENABLE
343+
if (gLedSettings.controlLedsEnabled != enabled) {
344+
gLedSettings.controlLedsEnabled = enabled;
345+
gPrefsSettings.putBool("ctrlLedsOn", enabled);
346+
}
347+
#endif
348+
}
349+
350+
bool Led_GetControlLeds() {
351+
#ifdef NEOPIXEL_ENABLE
352+
return gLedSettings.controlLedsEnabled;
353+
#else
354+
return false;
355+
#endif
356+
}
357+
358+
void Led_ToggleControlLeds() {
359+
#ifdef NEOPIXEL_ENABLE
360+
Led_SetControlLeds(!gLedSettings.controlLedsEnabled);
361+
#endif
362+
}
363+
328364
// Calculates physical address for a virtual LED address. This handles reversing the rotation direction of the ring and shifting the starting LED
329365
#ifdef NEOPIXEL_ENABLE
330366
uint8_t Led_Address(uint8_t number) {
@@ -345,11 +381,54 @@ uint8_t Led_Address(uint8_t number) {
345381
#endif
346382

347383
#ifdef NEOPIXEL_ENABLE
384+
// Resolves the color a control LED should show for its assigned function. The
385+
// returned color is at full scale; the global FastLED master brightness handles
386+
// dimming (incl. night mode), keeping legacy static control LEDs unchanged.
387+
CRGB Led_GetControlColor(LedControlFunction function, uint32_t staticColor) {
388+
switch (function) {
389+
case LedControlFunction::KeyLock:
390+
return System_AreControlsLocked() ? CRGB(CRGB::Red) : CRGB(CRGB::Black);
391+
case LedControlFunction::Repeat:
392+
switch (AudioPlayer_GetRepeatMode()) {
393+
case TRACK:
394+
return CRGB(CRGB::Green);
395+
case PLAYLIST:
396+
return CRGB(CRGB::Blue);
397+
case TRACK_N_PLAYLIST:
398+
return CRGB(CRGB::Purple);
399+
default:
400+
return CRGB(CRGB::Black);
401+
}
402+
case LedControlFunction::Bluetooth: {
403+
const uint8_t opMode = System_GetOperationMode();
404+
if ((opMode == OPMODE_BLUETOOTH_SINK) || (opMode == OPMODE_BLUETOOTH_SOURCE)) {
405+
return Bluetooth_Device_Connected() ? CRGB(CRGB::Blue) : CRGB(CRGB::BlueViolet);
406+
}
407+
return CRGB(CRGB::Black);
408+
}
409+
case LedControlFunction::Off:
410+
return CRGB(CRGB::Black);
411+
case LedControlFunction::Static:
412+
default:
413+
return CRGB(staticColor);
414+
}
415+
}
416+
348417
void Led_DrawControls(CRGB *leds) {
349-
if (gLedSettings.numControlLeds > 0) {
350-
for (uint8_t controlLed = 0; controlLed < gLedSettings.numControlLeds; controlLed++) {
351-
leds[gLedSettings.numIndicatorLeds + controlLed] = gLedSettings.controlLedColors[controlLed];
418+
if (gLedSettings.numControlLeds == 0) {
419+
return;
420+
}
421+
for (uint8_t controlLed = 0; controlLed < gLedSettings.numControlLeds; controlLed++) {
422+
CRGB color = CRGB::Black;
423+
if (gLedSettings.controlLedsEnabled) {
424+
LedControlFunction function = LedControlFunction::Static;
425+
if (controlLed < gLedSettings.controlLedFunctions.size()) {
426+
function = static_cast<LedControlFunction>(gLedSettings.controlLedFunctions[controlLed]);
427+
}
428+
const uint32_t staticColor = (controlLed < gLedSettings.controlLedColors.size()) ? gLedSettings.controlLedColors[controlLed] : 0u;
429+
color = Led_GetControlColor(function, staticColor);
352430
}
431+
leds[gLedSettings.numIndicatorLeds + controlLed] = color;
353432
}
354433
}
355434
#endif

‎src/Led.h‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,23 @@ enum class LedPlaylistProgressStates : uint8_t {
5555
Done
5656
};
5757

58+
// Function assigned to a single control LED. The control LEDs sit at the end of
59+
// the same data line as the indicator LEDs and can either show a fixed color
60+
// (Static, the legacy behaviour) or mirror a runtime state.
61+
enum class LedControlFunction : uint8_t {
62+
Static = 0, // fixed color taken from controlLedColors (legacy behaviour)
63+
KeyLock, // lit (red) while physical controls are locked
64+
Repeat, // reflects the repeat mode (track/playlist/both)
65+
Bluetooth, // reflects the bluetooth mode and connection state
66+
Off // always off
67+
};
68+
5869
struct LedSettings {
5970
uint8_t numIndicatorLeds = NUM_INDICATOR_LEDS;
6071
uint8_t numControlLeds = NUM_CONTROL_LEDS;
6172
std::vector<uint32_t> controlLedColors;
73+
std::vector<uint8_t> controlLedFunctions; // per control LED: LedControlFunction
74+
bool controlLedsEnabled = true; // master switch to mute all control LEDs (e.g. at night)
6275
uint8_t numIdleDots = NUM_LEDS_IDLE_DOTS;
6376
uint32_t idleColor = IDLE_COLOR;
6477
uint8_t idleAnimation = 0; // 0 = standard idle dots, 1 = cyberpunk "Data Drop"
@@ -99,3 +112,6 @@ void Led_SetNightmode(bool enabled);
99112
void Led_ToggleNightmode();
100113
void Led_SetAmbientLight(bool enabled);
101114
void Led_ToggleAmbientLight();
115+
void Led_SetControlLeds(bool enabled);
116+
void Led_ToggleControlLeds();
117+
bool Led_GetControlLeds();

0 commit comments

Comments
 (0)