diff --git a/docs/mesh-compatibility.md b/docs/mesh-compatibility.md index a7a4c2af..f8bbf4d3 100644 --- a/docs/mesh-compatibility.md +++ b/docs/mesh-compatibility.md @@ -86,6 +86,7 @@ All current `MsgType` payloads were audited against `MeshManager::_onRecv`. | `PlayAudio` | `PlayAudioMsg` fixed-size | `len >= sizeof(PlayAudioMsg)` | Raw fixed struct. One-shot playback trigger — see `PlayAudioMsg`'s comment for the start-sync/participation contract. | | `StopAudio` | `StopAudioMsg` fixed-size | `len >= sizeof(StopAudioMsg)` | Raw fixed struct. | | `GenericEvent` | `GenericEventMsg` fixed-size | `len >= sizeof(GenericEventMsg)` | Raw fixed struct. `eventType` is opaque to the mesh layer; no versioning. | +| `LightOverride` | `LightOverrideMsg` fixed-size | `len >= sizeof(LightOverrideMsg)` | Raw nested `LightConfig` layout; no versioning. Target filtering happens after parse, mirroring `SetGroup`. | | `Hello` | `HelloMsg` fixed-size | exact `sizeof(HelloMsg)` | **The one deliberate cross-firmware exception** — see below. | ## The `Hello` exception diff --git a/src/main.cpp b/src/main.cpp index 795ee86f..6d711938 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -716,6 +716,24 @@ void setup() { } }); + // Another device commanded a single light on this device: store (or clear) + // its override and render it — same arbitration as the local REST surface, + // so with multiple senders receive order decides (issue #458). Non-targets + // ignore the message entirely. + mesh.setOnLightOverride([](const LightOverrideMsg* msg) { + uint8_t own[6]; + WiFi.macAddress(own); + if (memcmp(msg->targetMac, own, 6) != 0) return; + uint8_t idx = msg->lightIndex; + if (idx >= MAX_LIGHTS || !Config::get().lights[idx].exists) return; + if (msg->clear) + LightOverrides::clear(idx); + else + LightOverrides::set(idx, msg->config, msg->durationMs); + applyEffectiveLight(idx); + publishTelemetry(); + }); + // Another device told this device (or a peer) to change a light's group mesh.setOnSetGroup([](const uint8_t* targetMac, uint8_t lightIndex, uint8_t groupId) { uint8_t own[6]; diff --git a/src/mesh/MeshManager.h b/src/mesh/MeshManager.h index 2a4a3f92..7a5de72e 100644 --- a/src/mesh/MeshManager.h +++ b/src/mesh/MeshManager.h @@ -30,6 +30,9 @@ class MeshManager { using PresenceCb = std::function; using SetGroupCb = std::function; + // Direct per-light override command (issue #458) — the receiver applies it + // through the same arbitration as local REST set/clear, see main.cpp. + using LightOverrideCb = std::function; using GroupSyncCb = std::function; using PhaseSyncCb = std::function; // Returns the current animation phase for the given light index. @@ -96,6 +99,7 @@ class MeshManager { void setOnLightConfig(LightConfigCb cb) { _onLightConfig = cb; } void setOnPresence(PresenceCb cb) { _onPresence = cb; } void setOnSetGroup(SetGroupCb cb) { _onSetGroup = cb; } + void setOnLightOverride(LightOverrideCb cb) { _onLightOverride = cb; } void setOnGroupSync(GroupSyncCb cb) { _onGroupSync = cb; } void setOnPhaseSync(PhaseSyncCb cb) { _onPhaseSync = cb; } void setGetPhase(GetPhaseCb cb) { _getPhase = cb; } @@ -229,6 +233,13 @@ class MeshManager { _send(&msg, sizeof(msg)); } + // Sender builds the full LightOverrideMsg (snapshot construction is the + // sender's job — see the rework plan's command-arbitration design). + void broadcastLightOverride(const LightOverrideMsg& msg) { + if (!_ready) return; + _send(&msg, sizeof(msg)); + } + void broadcastSetSoundGroup(const uint8_t* targetMac, uint8_t audioGroupId) { if (!_ready) return; SetSoundGroupMsg msg; @@ -529,6 +540,7 @@ class MeshManager { LightConfigCb _onLightConfig; PresenceCb _onPresence; SetGroupCb _onSetGroup; + LightOverrideCb _onLightOverride; GroupSyncCb _onGroupSync; PhaseSyncCb _onPhaseSync; GetPhaseCb _getPhase; @@ -1227,6 +1239,17 @@ class MeshManager { _instance->_onGenericEvent(mac, m->eventType, m->payload); break; } + case MsgType::LightOverride: { + if (len < (int)sizeof(LightOverrideMsg)) return; + auto* m = (LightOverrideMsg*)data; + Logger::i( + "[mesh] light-override rx: target %02x:%02x:%02x:%02x:%02x:%02x light " + "%u %s", + m->targetMac[0], m->targetMac[1], m->targetMac[2], m->targetMac[3], + m->targetMac[4], m->targetMac[5], m->lightIndex, m->clear ? "clear" : "set"); + if (_instance->_onLightOverride) _instance->_onLightOverride(m); + break; + } default: Logger::w("[mesh] unknown msg type %u len %d", (uint8_t)type, len); break; diff --git a/src/mesh/MeshTypes.h b/src/mesh/MeshTypes.h index 973a7624..9905ccf7 100644 --- a/src/mesh/MeshTypes.h +++ b/src/mesh/MeshTypes.h @@ -46,6 +46,7 @@ enum class MsgType : uint8_t { SetVolume = 34, GenericEvent = 35, Hello = 36, + LightOverride = 37, }; enum class FwState : uint8_t { Idle = 0, Checking = 1, Downloading = 2, Error = 3, Done = 4 }; @@ -440,6 +441,26 @@ struct GenericEventMsg { uint16_t payload; }; +// ── Per-light override (issue #458, LightWitch M1) ──────────────────────────── +// Broadcast to command a single light on a specific device: the target stores +// the full LightConfig snapshot as that light's override (see LightOverrides.h) +// and renders it instead of its group state, arbitrated by recency — the next +// real group change takes the light back. Addressing mirrors SetGroupMsg +// (targetMac + lightIndex, target filtering after parse); non-targets update +// nothing. clear=1 drops the target's override without supplying a snapshot, +// so any device (or, later, a graph node) can return a light to its group. +// durationMs mirrors the REST surface: 0 = stays until displaced, otherwise +// the receiver expires it on its own. With multiple senders, receive order +// decides — last one wins, same as every other direct command. +struct LightOverrideMsg { + MsgType type = MsgType::LightOverride; + uint8_t targetMac[6]; + uint8_t lightIndex; + uint8_t clear; // 1 = drop the override; config/durationMs are then meaningless + uint32_t durationMs; + LightConfig config; +}; + // ── Onboarding discovery (frozen, cross-firmware) ───────────────────────────── // Every other message in this file follows the same-firmware-only policy in // docs/mesh-compatibility.md: exact struct layout, no version tolerance.