Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/mesh-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
23 changes: 23 additions & 0 deletions src/mesh/MeshManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ class MeshManager {
using PresenceCb = std::function<void(const uint8_t* mac, const char* name, bool isNew)>;
using SetGroupCb =
std::function<void(const uint8_t* targetMac, uint8_t lightIndex, uint8_t groupId)>;
// 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<void(const LightOverrideMsg*)>;
using GroupSyncCb = std::function<void(const GroupConfig&)>;
using PhaseSyncCb = std::function<void(uint8_t groupId, float phase)>;
// Returns the current animation phase for the given light index.
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -529,6 +540,7 @@ class MeshManager {
LightConfigCb _onLightConfig;
PresenceCb _onPresence;
SetGroupCb _onSetGroup;
LightOverrideCb _onLightOverride;
GroupSyncCb _onGroupSync;
PhaseSyncCb _onPhaseSync;
GetPhaseCb _getPhase;
Expand Down Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions src/mesh/MeshTypes.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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.
Expand Down