Commit graph

31 commits

Author SHA1 Message Date
cjkas
9df2638f03 cleanup: drop the step cases onWaitingFrame never reaches
onWaitingFrame only acts on a frame that isn't processed yet, and the
step rule in onFrame marks a step frame processed as soon as it arrives;
a repeat or synonym keeps the mark. So the "fixed 1% step of a held step
button" never ran. A new test pins what a held step button does: one
step by the frame's step size. SOMFY_SPLIT_PLAN.md is corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 23:25:16 +02:00
cjkas
67da75fcf4
Merge pull request #55 from cjkas/build/same-fs-partition
build: give the debug build the release file system size
2026-10-17 23:11:59 +02:00
cjkas
e3c3472967 build: give the debug build the release file system size
esp32_huge_app.csv had a 0xD0000 file system where the release table has
0xE0000 at the same offset. LittleFS can grow but not shrink, so flashing
the debug build onto a device formatted by a release build failed the
lfs_fs_grow assert on every boot. The debug table now uses 0xE0000 and
moves the coredump into the 64 KB that was unused at the end of flash,
so both builds share one data layout. Devices formatted at 0xD0000 (debug,
esp32c6) grow on their next mount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:55:31 +02:00
cjkas
a34982a4c7 docs: mark the settingPos and getNext*Id items done
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:49:58 +02:00
cjkas
a954c5047f docs: close the shade behaviour reconcile (Q24, Q25)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:49:17 +02:00
cjkas
38d4ce5058 fix: keep a tilt-only shade's lift at 100 whatever sends the command
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>
2026-10-16 22:47:53 +02:00
cjkas
7220f38fcd fix: wait out a remote's long press across the millis() wrap
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>
2026-10-16 22:41:48 +02:00
cjkas
4eff50645d fix: update every group's flags after a group's sun flag command
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>
2026-10-16 22:40:32 +02:00
cjkas
2cf3c4b542 fix: step a remote's step by the frame's own step size
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>
2026-10-16 22:39:58 +02:00
cjkas
9e519d4d5f fix: end the positioning when the lift reaches its target
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>
2026-10-16 22:39:02 +02:00
cjkas
4f8eac03ac refactor: move the shade's behaviour into ShadeBehaviour
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>
2026-10-16 21:21:38 +02:00
cjkas
fa7aaf0d49 refactor: hold shade settings in ShadeSettings and RemoteSettings
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>
2026-10-16 20:31:39 +02:00
cjkas
8a13e0c3a9 docs: settle the shade behaviour design (rounds 1-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:14:59 +02:00
cjkas
220af1fafb docs: plan deepening the shade behaviour
Records the design state for candidate #1 of the architecture review so a
new session can pick it up: the drifted Up/Down/My copies in SomfyShade.cpp,
the echo path through SomfyRemote::sendCommandAfter, what steps 1-5 already
did, round 1 of the design questions with the recommended answers, and the
topics waiting for round 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:03:24 +02:00
cjkas
a0ad301779 refactor: split the transports into the modules GLOSSARY.md names
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>
2026-10-16 13:47:56 +02:00
cjkas
4c5bc01fe5 refactor: send shade and group changes once per loop() pass
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>
2026-10-16 10:40:44 +02:00
cjkas
e70e499d75 refactor: apply shade model edits on loop() through ModelEditor
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>
2026-10-16 10:26:45 +02:00
cjkas
77703a9855 refactor: serve the read routes from a ModelSnapshot that loop() fills
/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>
2026-10-16 10:05:22 +02:00
cjkas
395cb2d1c7 refactor: carry out shade requests from one module behind a LoopQueue
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>
2026-10-16 09:42:58 +02:00
cjkas
843e7b1e46 docs: add agent testing instructions and the RMT transmit idea
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:11:17 +02:00
cjkas
d9287a9e1f docs: record group reads emitting events from the web server task
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:10:26 +02:00
cjkas
8d4c621d5a docs: plan domain modules behind a LoopQueue
ADR-0002 makes loop() the only writer of the shade model. The plan
puts ShadeRequests, ModelEditor and ModelSnapshot behind one LoopQueue
that the HTTP routes and MQTT commands feed, with the outputs fed by
per-field dirty bits. GLOSSARY.md gains the terms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:38:43 +02:00
cjkas
e624a4177d refactor: move the transports into entryPoints and outputs
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>
2026-10-15 19:41:49 +02:00
cjkas
4a33b0d7dd fix: set rolling codes only on the main loop
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>
2026-10-14 16:01:55 +02:00
cjkas
1128431e8c fix: default a set-favorite request's tilt to the favorite tilt
/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>
2026-10-14 15:57:49 +02:00
cjkas
c71b5d8a85 docs: record phase 2 of the Somfy.cpp split
GLOSSARY.md gains the shade terms the new modules use (favorite, step,
sensor frame, sun flag, extend and retract) and the pulse decoder. The
split plan marks phase 2 done, with what changed in behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:22:04 +02:00
cjkas
c0cdf93747 docs: point to the files Somfy.cpp was split into
Add the Somfy.cpp split plan with phases 0 and 1 marked done, what the
split taught about flash size in the debug build, and the phase 2 work
still open. KNOWN_ISSUES.md now names the new files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:48:24 +02:00
cjkas
019af34cd9 docs: record /controller item skipping and the rest of the web-task writers
List every web handler that still changes the shade model, add the
oversized /controller items that are left out without telling the client,
the /linkRemote rolling-code race, and the /setMyPosition tilt default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:34:50 +02:00
cjkas
082d9776ed docs: bring KNOWN_ISSUES.md up to date
- A websocket event too large for its buffer has been logged and not
  sent since 8881289, not sent truncated.  Retitle the entry and point at
  MAX_SOCK_RESPONSE by name rather than a stale line number.
- List the web server task handlers that still change the shade model
  loop() owns: /setPaired, /setRollingCode and restore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:14:24 +02:00
cjkas
c1b47f898c fix: give every NVS user its own Preferences handle
loop() and the async web server task shared one global Preferences.  A
begin() from one task failed while the other had a namespace open, and the
clear() or remove() after it then hit the other task's namespace: saving the
radio settings while a command committed its rolling code could erase every
rolling code.

Each function now opens NVS with a local Preferences.  NVS serializes access
internally and the destructor closes the handle, so no lock is added (see
docs/adr/0001).  No new tests: nothing here builds natively, and deleting the
global makes any missed use a compile error.

The read-then-commit races on rolling codes across tasks remain and are
tracked in docs/KNOWN_ISSUES.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:51:05 +02:00
cjkas
6365f16850 Memory-bounded streaming builder for /controller. Produces the JSON payload one small unit at a time into a fixed 3KB buffer, so a chunked HTTP response can drain it as the TCP send window allows — no growing cbuf in the async response stream. 2026-04-21 14:56:20 +02:00