Commit graph

38 commits

Author SHA1 Message Date
cjkas
fdf1a47ff8 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 14:40:22 +02:00
cjkas
325da6bd9f 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 14:35:26 +02:00
cjkas
72b37e5f8d 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 14:34:39 +02:00
cjkas
c4d20908a5 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 14:04:29 +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
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
a176017960 refactor: move the RX pulse decoder into PulseDecoder
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>
2026-10-13 21:21:05 +02:00
cjkas
c75b715b3b refactor: share step targets between remote and group steps
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>
2026-10-13 21:17:02 +02:00
cjkas
5f26340d06 fix: move only the tilt of a tilt-only shade for sun and wind
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>
2026-10-13 21:14:20 +02:00
cjkas
8276c1c3cd refactor: move sun and wind automation into ShadeSensors
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>
2026-10-13 21:12:34 +02:00
cjkas
c8d774be7d chore: remove unused ESP-IDF, SVD and template files
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>
2026-10-13 20:54:53 +02:00
cjkas
4544eb6469 refactor: time steps with ElapsedTimer::lap()
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>
2026-10-13 15:39:14 +02:00
cjkas
293e013aa2 test: cover the roam threshold and who gets memStatus
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>
2026-10-13 15:37:12 +02:00
cjkas
3a2702af6c test: name the ticket tests as GLOSSARY.md does
Two test names still said seq after the rename to ticket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:35:15 +02:00
cjkas
50ff878498 fix: hold an unpair while radio TX is suspended
/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>
2026-10-13 15:33:49 +02:00
cjkas
bb6c929bf8 refactor: name radio jobs as GLOSSARY.md does
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>
2026-10-13 15:13:03 +02:00
cjkas
29d642b6f4 refactor: drop unused radio API
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>
2026-10-13 15:11:35 +02:00
cjkas
bea2f78cb6 refactor: share the shade step maths in ShadeMotion.cpp
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>
2026-10-13 15:09:15 +02:00
cjkas
f0e8b1d896 fix: refuse a radio job when its outcomes might not fit in the event ring
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>
2026-10-13 15:05:06 +02:00
cjkas
b768c5f987 fix: count the set-My settle delay from the end of the job ahead
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>
2026-10-13 15:03:17 +02:00
cjkas
d85b3d30c8 fix: turn a group's sun flag on, not off, for MQTT sunFlag 1
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>
2026-10-12 20:55:21 +02:00
cjkas
4135f21ee7 refactor: let one RadioScheduler own the radio job queue
The order radio jobs go out in was spread across callers, file statics and
the radio task: each remote tracked its last back job so a stop could decide
whether it may jump the queue, and the task kept a separate count of the back
jobs it had taken.  RadioScheduler now owns the waiting jobs, their tickets,
the stop-goes-first rule, delayed jobs, flush and suspend, and the outcome of
every job.  It is plain C++, with the time passed in and the caller locking,
so all of it is covered by native tests.  Transceiver keeps the FreeRTOS task,
the locks and the CC1101.

sendCommandAfter returns the job's ticket instead of leaving it in lastTxSeq.

Two stops for the same remote now go out in the order they were sent; queued
to the front they used to come out reversed, putting the rolling codes out of
order.

Adds GLOSSARY.md for the radio terms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:36:32 +02:00
cjkas
1962778912 test: cover the radio queue rules, shade TX state, rolling codes and web command hold
Move the pieces of the radio TX path that don't need FreeRTOS or NVS out of
Somfy.cpp and Web.cpp so they build in the native test env: the TX job and
event types and job encoding (TxBuffer), the queue rules (TxQueuePolicy.h),
a shade's pending radio jobs (ShadeTxState), the next rolling code
(RollingCode.h) and the web command queue types (WebCommand.h).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:26:33 +02:00
cjkas
6a3769406f fix: skip formatting request timings that won't be logged
timeRequest built both 112-byte descriptions (with a String copy of the URL)
on async_tcp for every request, and logElapsed threw them away for anything
under 100ms.  Check worthLogging() first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:36:09 +02:00
cjkas
9988a2bec8 fix: don't wait on a log lock holder that can't run
The log lock is a binary semaphore, so when the radio task preempted loop()
mid-log every log from async_tcp waited the full 20ms for a holder that
couldn't let go until the radio job ended.  Waiting now stops as soon as
the holder is preempted on another core.  The rule lives in
LogLockPolicy.h with unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:35:42 +02:00
cjkas
e47381b19e fix: only let wifiStrength broadcasts move the RSSI baseline
The emit to a newly connected client also stored the RSSI and time, which
restarted the 10s hold-off and hid a real change from the clients already
connected.  The baseline moves into RssiBroadcast, which ignores
per-client emits, with unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:34:45 +02:00
cjkas
0ba5260d1d refactor: pin which radio job holds a shade's simulated movement
Review flagged the outer My/Favorite losing to the nested Up/Down it queues.
Every such nested send is from a simMy shade, where the Up/Down is what
starts the motor, so the behaviour stays.  The hold moves into TxHold in
ShadeMotion with a comment explaining the order and unit tests pinning it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:33:19 +02:00
cjkas
7e9687c023 fix: keep a repeater frame until the radio queue takes it
The frame was popped before queueTx(), so whenever the radio queue was busy
it was lost.  Peek it instead, only remove it once it is queued (or TX is
suspended), and don't try while the queue has no room.  The repeater buffer
moves to TxBuffer.h/.cpp so it can be unit tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:31:39 +02:00
cjkas
e1c76bc2af fix: compare the coredump build SHA without reading past the array
app_elf_sha256 has no terminator (IDF 4.4 fills all 17 bytes), so strcmp
never matched the running build and %s printed whatever followed it.  Copy
it into a terminated buffer first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:29:59 +02:00
cjkas
a8bb377957 fix: track the JSON length instead of recounting it
Every append found the end of the buffer with strlen(), up to four
times per append and four appends per field, so building a response
cost the square of its size. /discovery fills a 4 KB buffer and spent
about 195 ms of its ~210 ms there (shades 126 ms, groups 69 ms), all on
the async_tcp task that serves every socket and web request.

Keep the length in the formatter and write every append at the end
through one helper. The web response and socket event subclasses and
ControllerChunker use the tracked length too. Output is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:04:13 +02:00
cjkas
05c3d309a6 fix: send wifiStrength only for real signal changes
At -70 dBm the RSSI moves a dBm or two constantly, so the >1 dBm check
every 1.5s sent wifiStrength to every client about 270 times an hour.
That is extra traffic on an already lossy link and a state write in
Home Assistant for each one.

Broadcast on a channel change, or for a change of at least 3 dBm no
more than every 10s. A newly connected client still gets the current
value straight away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:35:42 +02:00
cjkas
1d1f685fce test: cover shade travel and position/command flipping
Tests travelPosition (part-way positions both ways, separate up and
down times, stopping at the ends, zero travel time, steady progress
and staying within 0-100), displayPosition with and without flipping,
and flipCommand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:38:23 +02:00
cjkas
8881289b1d fix: never write past a JSON buffer or send a broken socket event
When something didn't fit, the formatters skipped it silently, closing
brackets included, and clients got malformed JSON.  Two writes could
also go past the buffer:

- AsyncJsonResp flushed when full and then wrote the value without
  checking again, so a value bigger than the whole buffer overflowed.
  It is now streamed straight out instead.
- JsonSockEvent::closeEvent added the closing ] when only one byte was
  left, putting the terminator one byte past the buffer.

The formatters now set an overflowed() flag and log an error, and a
socket event that overflowed is logged and not sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:31:48 +02:00
cjkas
48676b05c3 test: cover the JSON formatter
Tests BufferedJsonFormatter output: nesting and commas, number and bool
values, skipped null strings and escaping.  A canary after the buffer
catches writes past its end.

Three fail: control characters other than \b\f\n\r\t aren't escaped,
calcEscapedLength skips the first character, and so a value starting
with an escaped character can be written one byte past the buffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:30:14 +02:00
cjkas
3774aba45c test: cover the frame codec and command names
Builds SomfyFrame.cpp in the native env, with a minimal String and
no-op ESP_LOGx in the stubs, and tests:

- a fixed 56-bit frame, byte for byte, so what goes on air is pinned
- encode/decode round trips for 56-bit, 80-bit (repeats, Favorite,
  Stop, steps, Toggle), RTW and RTV frames
- rejection of bad checksums, addresses, rolling codes, keys and
  80-bit parity
- repeat/synonym tracking in copy()
- parsing and naming of commands

The command-name round trip fails: names the firmware reports, such as
"Step Up" and "My+Up", parse back to the wrong command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:20:39 +02:00
cjkas
52c67b12f8 test: add native Unity env with Utils tests
Adds an [env:native] for host-side unit tests (pio test -e native) with
a minimal Arduino.h stub, and covers the Utils helpers: _trim,
toBoolean, SETCHARPROP, sortArray and the Timestamp conversions.

The firmware envs now extend a shared [esp32] section instead of [env]
so the native env doesn't inherit the Arduino framework, the ESP32
libraries or the build scripts.

The half-hour calcTZOffset cases fail: +05:30 comes back as 2300.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:54:39 +02:00
cjkas
a64b648f7a init 2026-03-10 20:23:48 +01:00