The shade behaviour module (SHADE_BEHAVIOUR_PLAN.md, PR B) reads its settings through read-only structs, so they get one home each: RemoteSettings (flipCommands, bitLength, repeats) under SomfyRemote, shared with groups, and ShadeSettings (types, travel times, step size, flipPosition, simMy, sunSensor) under SomfyShade. Both are base structs, so shade->upTime and group->repeats keep working. A shade's flags byte is split by owner: simMy and sunSensor are settings, light is a field, and the sun flag, sunny, windy, demo mode and lighted bits are stateFlags. getFlags()/setFlags() put the byte together and take it apart for the config file, JSON, MQTT, events and group flags, so nothing saved or sent changes. Groups keep their own flags byte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 KiB
Plan: deepen the shade behaviour
Status: design settled (rounds 1–3, see "Decisions"), apart from the reconcile order, which waits for PR B's pinning tests. PR A (refactor/shade-settings) is built; PR B is next.
Written: 2026-10-16, on top of refactor/split-transports (PR #44, the top of the LoopQueue stack #40–#44, which is not yet merged or run on a device). Line numbers refer to that branch.
Start here: read GLOSSARY.md, ADR-0002 and LOOP_QUEUE_PLAN.md first. Their decisions are not reopened here.
Why
This is candidate #1 of the architecture review, the one ranked first. The LoopQueue plan (Q7 there) deliberately did it second, so that the bugs in front of the shade could be fixed first. Steps 1–5 rebuilt everything around the shade. The shade's own behaviour is still about 1,000 lines in src/SomfyShade.cpp.
The same rule exists in several drifted copies
What a shade does for Up, Down or My is written out separately in each place it can be triggered from:
| Function | Triggered by |
|---|---|
processFrame |
a frame received from a remote, and the shade's own echoed frame (see below) |
processWaitingFrame |
a remote button held (long press, tilt) |
processInternalCommand |
a group command reaching its member shades |
sendCommand, sendTiltCommand, moveToTarget, moveToTiltTarget, setMyPosition, moveToMyPosition |
commands from the web, MQTT and Home Assistant, through ShadeRequests and SomfyShadeOps |
checkMovement |
every loop() pass: simulated travel, arriving at the target, sending the stop, storing the favorite |
processTxEvent |
the radio reporting a job started, finished, aborted or dropped |
Known drift:
- Up on a tilt-only shade.
sendCommandandprocessInternalCommandset target 100, position 100 and tilt target 0.processFramesets only the tilt target. - My while a tilt-only shade moves.
processInternalCommandsets the lift target to 100;processFrameleaves the lift alone. - The four "arrived at target" blocks in
checkMovementaren't symmetric. The tilt blocks clearsettingTiltPos; the lift blocks never clearsettingPos, which may send an extra stop on a later arrival. This is on the "still open" list in LOOP_QUEUE_PLAN.md, not confirmed on a device. Their stop conditions also differ by hand-written literals. setMyPositionhas three near-copy branches (tilt-only, tilt, none).
None of it is tested
The native tests can't build it. It reads millis(), sends through the radio, and uses the global somfy. Only the helpers it calls are tested: travelPosition, stepTargets, ShadeSensors, TxHold/ShadeTxState and requestedFavoriteTilt (ShadeMotion, ShadeSensors). The fixes just before this work (set-favorite tilt, rolling codes on the main loop) were in this code.
What the shade code depends on today
- Time:
millis()throughoutcheckMovement,processFrameand the senders. - Radio: every send goes through
SomfyRemote::sendCommandAfter(src/SomfyRemote.cpp, around lines 61–108). It:- takes the next rolling code from NVS;
- encodes the frame and submits it with
somfy.sendFrame(), getting a ticket, or 0 when the radio refuses; - commits the rolling code;
- sets
lastFrame; - calls
somfy.processFrame(lastFrame, internal = true); - calls
somfy.markTxPending()for commands that start movement.
- The echo (step 5 above).
somfy.processFrame()runsprocessFrameon every shade, the sender included. So part of what a command does to the sending shade happens insendCommand, and part in its ownprocessFrame(internal)on the echoed frame. The same echo moves any other shade that has the sender's address as a linked remote. GPIO-driven shades (GP_Remote/GP_Relay) skip the radio but still echo. - Groups. A group command sends the group's frame, then calls
processInternalCommandon each member shade (src/SomfyGroup.cpp). - The global
somfy:isDirty/commit()for saving positions and the favorite,updateGroupFlags()after sensor and sun-flag changes,transceiver.configfor bit length and protocol, andgroupsinisInGroup(). - GPIO:
triggerGPIOs,setGPIOsandusesPinfor dry contacts and GPIO motors. - Outputs: only
changedbits since step 4. The change fan-out publishes them (ShadeChanges.h,SomfyShadeController::sendChanges()).
What makes it easier now
- Callers: ShadeRequests reaches the shade only through the
ShadeOpsseam (src/ShadeRequests.h). The real implementation isSomfyShadeOps, and a recording fake exists intest/test_shaderequests. Once the behaviour module exists, onlySomfyShadeOpschanges. - Outputs: the shade only marks what changed (step 4).
- Concurrency: only
loop()touches shades (ADR-0002), so the module is single-threaded code. - Snapshots:
shade_snapshot_tandsnapshotShade()already describe a shade as clients see it, which is useful for test assertions.
The goal
One module holding the shade's behaviour behind a small interface:
- Inputs: a received frame, a command, a group command, a radio event, and a tick, each with the current time.
- Sending: goes out through a seam that returns a ticket or a refusal. On the device that is the radio path; in tests, a fake that records what was sent.
- Inside: one rule per command and tilt type.
- Tested natively, by scenarios such as: an integrated-tilt shade at 0%; a remote sends Down; at 1.5 s it is still tilting; at 4 s it is lifting at about 25%; reaching the 40% target sends a stop; the radio drops the stop and the shade runs on to 100%.
Design questions
The decisions are the user's. Each question comes with a recommended answer, and they are asked in rounds, as in the LoopQueue plan. Settled answers move to "Decisions".
Found while preparing round 2
- The echo does part of the sender's work, and it varies by command. For a frame marked internal,
processFrameleaves the targets alone on Up and Down (the sender sets them itself after the send). It does set them on My (when idle), Stop, Toggle, Favorite, StepUp and StepDown. So for Stop, Step, Toggle and Favorite sent from the web, the shade's only reaction is in its echo. A linked shade gets the same internal frame, so it follows My/Stop/Step/Toggle/Favorite but not Up/Down. The echo also resetsmoveStart/startPosand copies the frame intolastFrameon every shade it reaches. - The order inside a send is: encode → submit → commit rolling code →
lastFrame→ echo to every shade →markTxPendingon the sender, its linked shades and the members of a group with that address. The sender's caller (moveToTarget,sendCommand, …) sets its targets after all of that. lastFrameserves two purposes. It is the templatesendCommandAfterbuilds the next frame from, and the long-press stateprocessWaitingFramereads (await,repeats,processed,cmd,stepSize).emitCommandis still a direct output. It builds a socket event with ArduinoJson and publishes three MQTT topics on the spot, about 40 times in the shade code and once per member inSomfyGroup::sendCommand.emitState()only marks the shade changed.isInGroup()isn't used by the behaviour code. Only the delete check in SomfyJson and the snapshot call it, so it stays inSomfyShade.flagsmixes settings and state. SimMy, SunSensor and Light are settings; SunFlag, Sunny, Windy and DemoMode are sensor state the behaviour changes. ConfigFile saves the whole byte.- Outside writers of motion state: ConfigFile at boot (positions, targets, favorite) and
SomfyShadeOpssetting a position without moving (p_target/p_currentPos). The other files only read it.
Found while preparing round 3
- The sender's own echo needs the frame as sent, not just the command. The Flag and SunFlag rules ignore a frame whose rolling code is 0x8000 or above (a guard against bogus sensors), and they apply it to the shade's own echo too.
flipCommandsflips the command on the way out and flips it back when the echo comes in. sendSensorCommanddoesn't move. ShadeRequests calls it throughSomfyShadeOps, and it stays inSomfyRemote. It keeps echoing to every shade, the sender included, so the shade hears it throughonFrame.- GPIO:
GP_Remotepulses its pins inside the send;GP_Relaysends nothing andsetGPIOs()drives the relays fromloop()by reading the direction. Both stay outside the module.
Still open
- Reconcile: what is decided first, and in what order. Asked once PR B's pinning tests have listed every "today: …" disagreement. Known candidates:
settingPoson arrival (may send extra stops), Up/Down/My on tilt-only shades, the self-echo (Q5), and merging group commands into the frame rule (Q8).
Constraints
- AGENTS.md: every change keeps
pio test -e nativegreen and adds or updates tests. - Flash: check every commit.
esp32dev(release, with OTA) has about 180 KB free. The debug build uses one 3 MB slot once #39 is in its history. Every file that uses ArduinoJson costs flash, so keep the new module free of it. ShadeMotion,ShadeSensors,ShadeTxStateandstepTargetsare deep and tested. Reuse them; don't re-derive them.- Vocabulary: module, interface, seam, adapter, depth (the
/codebase-designskill) and the domain terms in GLOSSARY.md.
Decisions
Round 1 (2026-10-16), all as recommended:
- Q1. What moves in.
processFrame,processWaitingFrame,processInternalCommand,sendCommand,sendTiltCommand,moveToTarget,moveToTiltTarget,setMyPosition,moveToMyPosition,checkMovementandprocessTxEventmove into the module. So do their helpers (sendStop,sendSetMy,storeMyPosition,runToLimit,applySensorMove,applyStep,tiltRepeats) and their state: positions, targets, directions,lastMovement, start times and positions, thesetting*flags,awaitMy, the favorite,ShadeSensorsandShadeTxState. Thep_*setters and change marking also move in.SomfyShadekeeps identity, settings, persistence, GPIO, JSON, MQTT topics and rolling codes. - Q2. Pin, move, reconcile. Phases 1 and 2 land together and change no behaviour. Disagreements are pinned as "today: …" tests. Reconcile makes one decision per commit, each checked on real motors.
- Q3. Time is passed in. Every input carries
nowasuint32_t, with wrap-safe elapsed times as inTxHold. - Q4. One object per shade. It holds a
const ShadeSettings&. The settings fields move into that struct, whichSomfyShadeowns, so there is one copy and nothing to drift.
Round 2 (2026-10-16), all as recommended. The principle: the module never touches anything outside itself; SomfyShade wraps it and does the outside work.
- Q5. The echo. Today a sent frame is handed back to every shade as if a remote had sent it, and some commands (Stop, Step, Toggle, Favorite, My when idle) do their work only there. The module calls its own frame rule directly after a successful send, at the same point as today. Other shades (linked remotes) still hear the frame through the controller. Whether the sender should hear itself at all is a reconcile decision.
- Q6. Held-button state. The module keeps its own record of the held button (
cmd,repeats,await,processed,stepSize,remoteAddress).SomfyRemote::lastFramestays the send template, andSomfyShadekeeps copying frames into it as today. - Q7. Command events. They leave through an output seam,
onCommand(cmd, source, sourceAddress), which calls today'semitCommandon the device and records in tests. The timing is unchanged, and ArduinoJson stays out of the module. - Q8. Group commands. They stay a separate input,
onGroupCommand(cmd, repeat, now), with today's rule.markTxPendingbecomesonTxQueued(ticket, now). Merging group commands into the frame rule is a reconcile decision. - Q9. The global
somfy. The module reports "save wanted" and "sensor flags changed";SomfyShadesetssomfy.isDirtyand callsupdateGroupFlags().bitLengthgoes intoShadeSettings, still filled in lazily bySomfyShade.isInGroup()stays inSomfyShade. - Q10. The
flagsbyte. SunFlag, Sunny, Windy and DemoMode go into the module; SimMy and SunSensor intoShadeSettings; Light stays inSomfyShade. The byte is put back together for ConfigFile, JSON and MQTT, so nothing saved or sent changes. - Q11. Reading the motion state. A read-only
state(). ConfigFile at boot writes throughrestore(...)andSomfyShadeOpsthroughsetPosition(pos, tilt). The 12 reading files switch over in the move phase.
Round 3 (2026-10-16), all as recommended:
- Q12. The radio seam. The module sends with
send(cmd, repeats, stepSize, delayMs, priority)and gets back{sent, ticket, frame}. On the device,SomfyRemotedoes whatsendCommandAfterdoes today (rolling code, encode, submit or pulse GPIO, commit the code,lastFrame), but it echoes and marks the radio hold only for the other shades and group members. The module runs its own echo on the returned frame and marks its own hold. In tests a fake records each send, hands out tickets 1, 2, 3, … and can be told to refuse. - Q13. Names. These go into GLOSSARY.md with PR B, when the code exists:
- ShadeBehaviour: the module (
src/ShadeBehaviour.h/.cpp,test/test_shadebehaviour). - ShadeSettings: the read-only settings.
- ShadeBehaviour::State: the motion state
state()returns. - MotorLink: the send seam, radio or GPIO.
- ShadeEvents: the
onCommand(...)output.
- ShadeBehaviour: the module (
- Q14. Order and PRs, stacked on #44:
- PR A:
ShadeSettingsand theflagssplit, with tests that the saved byte and JSON don't change. - PR B: the move:
ShadeBehaviour,MotorLink,ShadeEvents,restore/setPosition, the 12 readers and the pinning scenario tests. No behaviour change. - Reconcile: one PR per decision. These start only after #40–#44, A and B have run on a device.
- PR A:
Before PR A (2026-10-16), all as recommended. Q4 assumed every setting lives on SomfyShade. But flipCommands, bitLength, repeats and the flags byte are on the SomfyRemote base, which groups share and the send path uses.
- Q15. Two settings structs, held as bases.
RemoteSettings(flipCommands,bitLength,repeats) is a base ofSomfyRemote, so shades and groups each have one.ShadeSettings(types, travel times, step size,flipPosition,simMy,sunSensor) is a second base ofSomfyShade. Existing code likeshade->upTimekeeps compiling. The module will get two read-only references. That is still one copy of each, so nothing can drift. - Q16. The
flagsbyte.- SimMy and SunSensor are
bools inShadeSettings, and Light is aboolonSomfyShade. - SunFlag, Sunny, Windy, DemoMode and the unused Lighted stay on
SomfyShadeasstateFlagsuntil PR B moves them into the module. - A shade's byte is
getFlags()(put together) andsetFlags()(split), used by ConfigFile, JSON, MQTT, events and group flags. - Groups keep their own
flagsbyte (the OR of their shades), andSomfyRemote::getFlags()is virtual forsendSensorCommand. test_shadesettingschecks that all 256 byte values come back unchanged.
- SimMy and SunSensor are