fromJSON read linkedShades into a local array and dropped it, so /addGroup
and /saveGroup never changed a group's members; /linkToGroup and
/unlinkFromGroup do, with ModelEditor's checks. The loop also wrote past
the end of that stack array when a request listed more than 32 shades.
No client sends the list: the web UI's group form has no members field.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New ids stopped two short of the limits, at 30 shades, 14 groups and 14
rooms instead of 32, 16 and 16, while the web UI showed the full limits.
The original loop ran i < MAX - 1, an off-by-one: nothing indexes by id
or treats the last two ids as special. ModelSnapshot's next-id answers
and SomfyShadeController's allocation now run from 1 to the maximum.
Found during LOOP_QUEUE_PLAN.md's review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A restore without "shades" skipped the shade and group records but not
the room records, which come first, so it read the repeaters and the
settings from room data. The skip is now shadeModelBytes(), in a
ConfigHeader.h the native tests can reach, which counts rooms, shades
and groups. Found during LOOP_QUEUE_PLAN.md's review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web and group Up/Down put a tilt-only shade's lift back at 100, but a
remote's Up/Down/My, a group's My and the web's My while moving left a
lift that something had moved, and the simulation then ran it. Every
Up/Down/My path now pins the lift at 100 and acts on the tilt only.
Reconcile Q22 in SHADE_BEHAVIOUR_PLAN.md, which also records Q23 (the
integrated tilt turn-back) as deferred.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 500 ms wait that tells a short press from a long one compared
now > await, so a press just before the wrap acted at once. It now
compares the wrap-safe difference. Reconcile Q21 in
SHADE_BEHAVIOUR_PLAN.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A SunFlag or Flag frame makes every group work its flags out again, but
the same command from a group only updated that group, so other groups
holding the shade kept stale sun flags. The group rule now reports the
sensor flags as the frame rule does. Reconcile Q20 in
SHADE_BEHAVIOUR_PLAN.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The step rule read lastFrame.stepSize, which copy() never updates, so a
remote's step moved by whatever step size this shade last sent. It now
reads the frame's step size, 1 when the frame has none. Reconcile Q19 in
SHADE_BEHAVIOUR_PLAN.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Arriving at a lift target never cleared settingPos (the tilt arrival
clears settingTiltPos), so the next step or group move also sent a stop
when it arrived. A stop to a motor that has already stopped is a My,
which sends it to its favorite. Reconcile Q18 in SHADE_BEHAVIOUR_PLAN.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR B of SHADE_BEHAVIOUR_PLAN.md. What a shade does with frames, commands,
group commands and radio events moves unchanged from SomfyShade into
ShadeBehaviour, which builds natively and is pinned by test_shadebehaviour.
ShadeBehaviour reads ShadeSettings and RemoteSettings, gets the time on
every input, and holds the motion state that other files now read through
state(). It sends through MotorLink and reports through ShadeEvents
(changes, commands, save wanted, sensor flags) as things happen.
SomfyShade wraps it: its MotorLink is SomfyRemote::sendCommandAfter, which
now leaves the sender out of the echo and radio hold because the module
hears its own frame. lastFrame stays shared with the send path.
ConfigFile writes through restore() and clearTilt(), SomfyShadeOps through
setPosition(). The pinning tests label where the copies of a rule disagree
"today:", for the reconcile phase.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Code moves unchanged; only files and names change.
- entryPoints/WebUiRoutes.cpp: registerWebUiRoutes(), the port 80 routes
and the UI's files, with the helpers only they use.
- entryPoints/ApiRoutes.cpp: registerApiRoutes(), the port 8081 routes.
- entryPoints/Web.cpp keeps the servers' lifecycle, auth and the handlers
both route sets call; entryPoints/WebRoutes.h is what the three share.
- entryPoints/MQTT.* -> MqttConnection.*, class MQTTClass -> MqttConnection.
- outputs/SomfyMqtt.cpp -> outputs/MqttPublisher.cpp.
- outputs/Sockets.* -> outputs/SocketEmitter.*.
The subscribe and unsubscribe lists (the latter repeated
groups/+/direction/set) become MqttCommands' MQTT_COMMAND_TOPICS, and a
native test checks the parser takes every topic in it. GLOSSARY.md gains
MqttConnection and Change fan-out.
328 native tests. Flash: esp32devdbg +3,112 bytes (the routes are a second
ArduinoJson file), esp32dev -2,336 bytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The p_* setters and emitState() no longer publish or emit straight away:
they set SHADE_CHANGED_* / GROUP_CHANGED_* bits on the shade or group.
At the end of every loop() pass SomfyShadeController::sendChanges()
publishes the MQTT topics whose bits are set and sends one shadeState or
groupState, then clears the bits. emitState(evt) with an event name and
emitState(num) for one client still go out at once.
publishShadeTopics() (ShadeChanges.cpp, tested natively) writes the topics
from a shade snapshot; publishState() on connect uses it too. That fixes
MQTT position and target, a constant 50 since c94819e, and direction, a
constant 0 on connect. position and tiltPosition are now retained when
they change, as they were on connect, and a group whose flags change sends
one groupState instead of two. somfy_flags_t moves to ShadeTypes.h.
8 new native tests, 327 in all. Flash: esp32devdbg +196 bytes, esp32dev
+228 bytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 19 routes that changed rooms, shades, groups and repeaters on the web
server task (add, save, delete, link and unlink, setPaired, the sort
orders) now copy their request into a model_edit_t, queue it on the
LoopQueue and reply {status:accepted,requestId:N}. loop() applies it
(SomfyJson.cpp, next to fromJSON) and broadcasts requestResult {requestId,
ok, id, code, desc}. One edit at a time; a second gets 503, a body over
4 KB gets 413.
ModelEditor.h/.cpp holds the rules, tested natively: the lowest free id,
the next sort order, a free remote address, adding to and removing from
the group-member and repeater lists, and the GPIO pin-conflict check. They
replace the getNext*Id/getMax*Order copies, the remote-address search,
the list code and the inline pin checks. The pin check now uses the
protocol being saved and checks every pin against Ethernet, not only up.
/getNextRoom, /getNextShade, /getNextGroup, /groupOptions and a new
/repeaters (the UI already called it) are ModelSnapshot reads.
The web UI's editJSON() sends an edit, waits for its requestResult, then
reads the item back from /shade, /group, /room or /repeaters, so the
callbacks that used the old reply bodies are unchanged. No result within
5 s offers a reload; reads and edits told Busy are retried. It now also
refreshes its lists on shadeAdded/shadeRemoved/groupAdded/groupRemoved
from other clients, and procRoomRemoved keeps the right rooms.
Also fixed: a shade that fails validation is no longer added; linked
repeaters and sort orders are saved; linking reports a full list.
27 new native tests, 319 in all. Flash: esp32devdbg -11,484 bytes,
esp32dev -7,180 bytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/controller, /discovery, /rooms, /shades, /groups, /room, /shade and /group
now take a SnapshotSlots slot, queue a fill on the LoopQueue and send a
chunked response whose body waits (RESPONSE_TRY_AGAIN) until loop() has
copied the shade model into the slot. SnapshotWriter then streams the
document from that copy a unit at a time, so each document describes one
moment. At most two reads run at once; a third gets 503. A slot's 5.6 KB
snapshot is allocated only while a read holds it, and its atomic state
decides whether loop() or the read frees it when the read goes away first.
The JSON of each item is unchanged and now lives in ModelSnapshot.cpp,
where the native tests check it. Replies still written on the web server
task copy their one item with SomfySnapshot.cpp, so serializing a group
no longer calls updateFlags(), which emitted groupState and published MQTT
from async_tcp.
Also: every read route is chunked and leaves out an item too big for its
3 KB unit, like /controller did; /discovery drops its timing log; the
SOMFY_MAX_* limits move to ShadeTypes.h.
21 new native tests, 292 in all. Flash: esp32devdbg +440 bytes, esp32dev
+2,064 bytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HTTP routes and MQTT commands now both build a shade request and submit it
to the LoopQueue, which loop() drains into ShadeRequests. ShadeRequests
finds the shade or group, turns client positions into stored ones, checks
ranges and fills in default repeats, and reaches the shades through the
ShadeOps seam: SomfyShadeOps on the device, a recording fake in the native
tests. MqttCommands turns a .../set topic into a shade request.
Behaviour that changes:
- MQTT tiltTarget, mypos, myTiltPos and tiltPosition, and /setPositions,
now honour flipPosition like the other routes.
- MQTT mypos keeps the favorite tilt (was set to 0) and myTiltPos keeps
the favorite lift.
- repeat 0 on /shadeCommand means no repeats, as it did for groups; a
JSON repeat over 127 is capped instead of wrapping to the default.
- Position corrections go through the setters, so MQTT publishes them.
- MQTT commands wait while the radio is full instead of being refused.
- The queue is drained every loop() pass and no longer from inside a
GitHub firmware download.
WebCommand.h is replaced by LoopQueue.h and ShadeRequests.h. 45 new native
tests, 271 in all. Flash: esp32devdbg +976 bytes, esp32dev +1,380 bytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The HTTP servers and MQTT command handling go to src/entryPoints/, the
socket emitter and the MQTT and socket output of shades, groups and rooms
to src/outputs/. GLOSSARY.md names what they become once split:
WebUiRoutes, ApiRoutes and MqttCommands in, MqttPublisher and
SocketEmitter out.
Only files and include paths change. esp32devdbg flash goes from
1,546,405 to 1,546,257 bytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sending a command reads a remote's next rolling code, queues the frame and
commits the code, all on loop(). /setRollingCode, a shades restore and
/updateShadeConfig wrote the same codes from the web server task, so a set
could be undone by a command's commit, and a restore could save an older
code to NVS that survived the reboot. All three now go through the web
command queue to loop(). They don't use the radio, so they are never held
back or refused while it is busy.
/restore now answers after queueing the restore. A broken options string
used to send an error after the success answer had already gone out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/setMyPosition filled in a missing tilt with the lift favorite, so a
request without a tilt set the favorite tilt to the lift's. It also took
the stored position as a display one, which flipped it twice on a shade
with flipPosition. requestedFavoriteTilt() now gives the favorite tilt as
displayed. Home Assistant never calls /setMyPosition and the web UI always
sends a tilt, so only other API callers see the change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The decoding of the time between received edges into frame bits moves out
of Transceiver::handleReceive unchanged into decodePulse(), with the RTS
timings and the receive buffer type. It stays in IRAM on the ESP32 and is
now covered by native tests that feed it the edges sendFrame() produces
for 56 and 80-bit first frames and repeats, a glitch and a broken pulse.
handleReceive keeps the noise guard, the edge timing and the RX queue.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A received step and a group step each worked out where a step takes the
shade, the group copy with its own inline maths. stepTargets() in
ShadeMotion now does it for both: a tilt-only shade steps its tilt, an
integrated tilt turns its slats to the end first and then steps the lift,
and every other shade steps its lift.
Only edge cases change. A group step on a tilt-only shade already at the
end now goes through like a remote step, and a step that can't be sized
(no travel time) leaves the targets alone in both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The three places that act on the sun and wind each set different targets
for a tilt-only shade: the sun timer moved its lift, the no-sun and wind
timers moved tilt and lift, a group's sun flag moved its lift and only a
remote's sun flag moved its tilt. They now share sensorTargets(): a
tilt-only shade moves its tilt to the favorite tilt (or fully) and back to
0, and every other shade its lift, as Up and Down already do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sun and wind timers were spread over processFrame, checkMovement and
processInternalCommand. ShadeSensors now holds them and decides when the
weather extends or retracts a shade, when turning the sun flag on moves
it, and when wind blocks a remote Down. Each caller still sets the targets
itself, so no shade moves differently yet.
The timers are now 32-bit like millis(). As 64-bit values holding a 32-bit
millis() they fired at once after the rollover every 49.7 days. The debug
logs of each timer go: they were compiled out at CORE_DEBUG_LEVEL 3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
group_types, shade_types and tilt_types move out of Somfy.h unchanged into
a header without the shade model, so the shade maths in ShadeMotion and
the sun and wind rules can use tilt_types in the native tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop files the Arduino PlatformIO build never reads: the ESP32 SVD,
the ESP-IDF CMakeLists/idf_component.yml, the PlatformIO template
READMEs, and the already-ignored debug.cfg. Fix the esp32.vsd typo in
.gitignore so the SVD stays ignored.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only SomfyShade's behaviour is left: receiving frames, commands, moving to
targets and the favorite. Drop the includes and externs it no longer uses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Loading and committing the shade list, adding, finding and deleting rooms,
shades and groups, repeaters, and handing frames and radio events to the
shades move out of Somfy.cpp unchanged, with SomfyRoom's clear and save.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Linking shades, the group flags and direction, sending group commands and
saving move out of Somfy.cpp unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sending commands, repeats and sensor frames, rolling codes and the remote
flags move out of Somfy.cpp unchanged, with startsMovement(), which only
sendCommandAfter uses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
setGPIOs, triggerGPIOs and usesPin, which drive the relay outputs of dry
contact and GPIO shades, move out of Somfy.cpp unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
validateJSON and fromJSON for shades, rooms, groups and the radio move out
of Somfy.cpp unchanged. Keeping every ArduinoJson parser in one file
shares its inlined helpers, which saves 2.8 KB of flash.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The emitState and emitCommand events for rooms, shades, groups and the
controller move out of Somfy.cpp unchanged. Also drop the log tag from
SomfyMqtt.cpp, which never logs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The publish, unpublish and Home Assistant discovery code for rooms, shades
and groups moves out of Somfy.cpp unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CC1101 setup, receive decoding, radio task and frequency scan move out
of Somfy.cpp, and their types out of Somfy.h into Transceiver.h, which
Somfy.h includes. The code is unchanged apart from the bit length the
radio was set up for: bit_length becomes radioBitLength, shared with the
remotes that send without one.
The radio's fromJSON stays in Somfy.cpp for now so the JSON parsing can
move into one file: ArduinoJson costs flash in every file that uses it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
USE_NVS is never defined, so the NVS-era shade storage (loadLegacy,
SomfyShade::load and the NVS branches) never built. Also drop the
commented-out toJSON methods, snprintf emitters and unused CC1101 options,
useNVS() and sort_asc(), which nothing calls.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
loop(), socket client init and /discovery each read a step's time and
restarted the timer by hand. lap() does both. Also put the request timing
comment back on timeRequest() and say why /discovery logs its steps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Move the roam check into worthRoaming() and the memStatus routing into
heapEmitTarget() in Utils so the native tests reach them.
worthRoaming() roams for a gain of at least ROAM_MIN_GAIN_DB, as the
comment and the 6 dB commit said. The old check needed one dB more.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/unpairShade answers paired:false as soon as the unpair is queued. If TX
was suspended before loop() reached it, the Prog was dropped and the shade
stayed paired, with nothing to tell the client. The unpair now waits in the
queue until TX resumes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
clientMap[], connectedAt[] and lastPing[] were parallel arrays indexed by
the same slot. They are now one ws_slot_t per slot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The repeater-frame flag was called raw and the ticket was seq, jobSeq,
stopSeq or mySeq depending on the file. They are now repeater and ticket
(stopTicket, myTicket, lastTicket) throughout, matching the glossary.
Renames only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Transceiver::flushTx() had no caller; suspendTx() is the only flush.
RadioScheduler::hasRoom() was only ever asked about a normal job, so it no
longer takes a priority flag. The tests check the reserved stop slots
through submit() instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- setMyPosition stored a new favorite the same way in each of its three
tilt branches; storeMyPosition() now does it. The plain-lift branch
now emits its state even when the radio refuses the favorite, which is
harmless since sendSetMy() has already put the old one back.
- tiltRepeats() replaces the repeated tiltmotor ? TILT_REPEATS : repeats.
- timeRequest's two timing log blocks share logRequestTime().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
processFrame worked out a StepUp/StepDown target with the same formula in
eight places, each with its own divide-by-zero guard. 4dd4a98 fixed one
that guarded on downTime but divided by upTime. stepPosition() takes the
travel time once, for both the guard and the maths, so they can't drift
apart again, and it is now covered by native tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
queueUnpairShade cleared shade->paired on the async_tcp task while loop()
owns the shade. A second unpair queued before the first ran recorded
wasPaired=false and, if its Prog was refused, put back the wrong value.
The shade is now only changed by loop() once the Prog is queued, and the
response reports paired=false through a serializeShade overload instead.
No native test: Web.cpp can't be built by the native env.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
moveToTiltTarget, sendTiltCommand and setMyPosition returned nothing, so
the web command queue counted tilt_command and set_my_position as sent
and never logged a refusal. They now return false when the radio refused
a command they sent.
No native test: these live in Somfy.cpp, which the native env can't build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A full event ring threw an outcome away, breaking the rule that every job
we submit ends with exactly one of finished, aborted or dropped. A lost
outcome left a favorite uncommitted and a held shade waiting out its 30s
timeout. submit() and hasRoom() now keep room for two outcomes of every
job not yet ended, so an event can't be lost; if loop() stops collecting
them, new jobs are refused instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The delay was counted from when the job was queued. The stop at the
target is queued just before the favorite and is on air for about 245ms,
so the 200ms settle delay had run out before the favorite's turn and only
the 100ms beat between jobs was left. A delayed job now waits its delay
after whichever came later: being queued, or the job ahead of it going off
air.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lift StepUp checked downTime for 0 but divided by upTime, so a shade with
an up time of 0 had its target sent to 0% instead of the step being ignored.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
groups/+/sunFlag/set sent Flag for 1 and SunFlag for 0, the opposite of the
shade topic. SunFlag turns the flag on. Both topics now take the command
from sunFlagCommand() so they can't drift apart again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>