Commit graph

726 commits

Author SHA1 Message Date
cjkas
ea39872d4a refactor: move config parsing into SomfyJson.cpp
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>
2026-10-13 20:37:36 +02:00
cjkas
2771b91571 refactor: move socket events into SomfyEvents.cpp
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>
2026-10-13 20:35:44 +02:00
cjkas
297c4d5dcd refactor: move MQTT publishing into SomfyMqtt.cpp
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>
2026-10-13 20:33:23 +02:00
cjkas
0a329cafb8 refactor: move the radio into Transceiver.cpp
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>
2026-10-13 20:31:16 +02:00
cjkas
4cb676a7a7 build: limit inlining in every file of the debug build
At -Og GCC only caps inlining growth in units over large-unit-insns
(10000 instructions). Smaller files inline ArduinoJson's helpers into
every caller: moving the JSON parsing out of Somfy.cpp grew the image by
19 KB, past the 1.5 MB app partition. Treating every file as large keeps
inlining at the level the big files already had and frees 13 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:27:49 +02:00
cjkas
bbfb23e972 chore: delete code that never compiles from Somfy.cpp
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>
2026-10-13 20:06:48 +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
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
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
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
e15793be99 refactor: keep each socket client's slot data together
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>
2026-10-13 15:13:43 +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
0b7c57a9ab refactor: share the favorite store, tilt repeats and request timing log
- 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>
2026-10-13 15:10:34 +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
ea520731a9 fix: unpair without changing the shade on the web server task
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>
2026-10-13 15:07:20 +02:00
cjkas
45776f64ce fix: report tilt and set-My commands the radio refused
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>
2026-10-13 15:06:25 +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
125a758d1c
Merge pull request #35 from cjkas/fix/radio-tx-task
Move radio TX to a dedicated task so the HA WebSocket stays connected
2026-10-12 21:00:30 +02:00
cjkas
4dd4a9872e fix: ignore a received StepUp for a shade with no up time
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>
2026-10-12 20:58:10 +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
b51292889f fix: return the shade from /repeatCommand as an object
The shade branch opened an array and wrote the shade's named fields straight
into it, so the response was invalid JSON.  The web UI reads it as one shade,
as the group branch already returns it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:55:20 +02:00
cjkas
bec6bed54d fix: move a tilt-only shade's tilt target on a received StepDown
A StepDown frame for a tilt-only shade computed the new tilt position but set
it as the lift target, so the simulated tilt never moved.  StepUp and the
internal StepDown already set the tilt target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:55:20 +02:00
cjkas
53dfe619d0 fix: don't read a missing linked shade's flags in group state events
SomfyGroup::emitState skipped a linked shade id that no longer resolves when
listing the shades, but still read its flags through the null pointer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:55:20 +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
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
c4472ba8e8 fix: set the radio up for a new config before anything transmits on it
setConfig() swapped the config in and released the radio lock, and apply()
only ran later from save(), so the radio task could send queued jobs with
the new TX pin and settings on a CC1101 that wasn't set up for them.
setConfig() now swaps, saves and applies under one lock and save() goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:33:59 +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
a212d562cb fix: send frequency scan events from loop() only
beginFrequencyScan, endFrequencyScan and apply() run on async_tcp from the
web handlers and emitted into the socket event buffer loop() also writes,
so an event could be mixed up or dropped.  They now only flag the change
and Transceiver::loop() sends it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:32:12 +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
2ee7af51ab refactor: share shade remote matching in SomfyShade::findRemote
markTxPending copied the shade and linked-remote address matching from
SomfyShade::processFrame.  Both now use findRemote() so they can't drift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:01:31 +02:00
cjkas
b4d480caa0 fix: send memStatus once per network broadcast
emitSockets() sent memStatus and then emitSockets(255) sent it again, so
room 0 got two identical events in one pass after every /discovery.
Only the per-client path sends it from emitSockets(num).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:00:52 +02:00
cjkas
08d2cd7a0c fix: read the rolling code from NVS only until it has been written
Splitting getNextRollingCode into peek and commit opened the ShadeCodes
namespace twice per command.  Once a code has been written for an
address lastRollingCode matches NVS, so peek uses it and only commit
opens NVS.  Changing the address drops back to reading NVS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:00:51 +02:00
cjkas
a49fa5d953 fix: don't hold position updates back while the radio is full
processQueue stopped draining web commands whenever the radio queue was
near full, including set_positions, which never transmits.  Look at the
command at the head and only hold it back if it needs the radio, keeping
the commands in order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:59:48 +02:00
cjkas
ab0eba2035 fix: leave out /controller items that overflow instead of breaking the JSON
The formatter drops a value that doesn't fit but keeps appending the
commas and braces after it, so an oversized shade or group streamed
invalid JSON.  Check overflowed() after each unit, log the item and skip
it, and keep producing until a unit or the end so a skip never ends the
stream early.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:59:39 +02:00
cjkas
03eb3b3d99 fix: let a priority stop overtake a delayed radio job
The radio task took a delayed job (the My after a shade reaches its
target) off the queue and slept until it was due, so a stop queued at the
front waited for the sleep plus the whole delayed job.  Wait on a task
notification instead and send front jobs while the delayed one is held.

A held job still counts as queued for isQueued(), so a stop for the same
remote goes behind it and the rolling codes stay in order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:59:10 +02:00
cjkas
1cc55e07f0 fix: keep the core dump in flash after reporting it
Erasing the dump on the first boot after a crash lost it on headless
devices where nobody saw the serial summary.  Leave it until the next
crash replaces it and use the reset reason to tell a fresh crash (WARN)
from one reported on an earlier boot (INFO).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:58:12 +02:00
cjkas
77d3e18cce fix: log only timings of 100ms or more by default
Every HTTP request logged two timing lines and every socket connect five,
all from the async_tcp task, at several ms each on a 115200 baud console.
Raise the default TIMING_LOG_MIN_MS to 100 and drop the unused ScopeTimer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:57:47 +02:00
cjkas
6cec411fa8 fix: stop the log lock lifting loop() above the radio task
The log lock was a mutex, so when async_tcp waited on it loop() inherited
async_tcp's priority and could preempt the radio task mid-frame.  Use a
binary semaphore, which has no priority inheritance, track the holder so
a nested log doesn't wait on itself, and cap the wait at 20ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:57:33 +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
55b1db4508 fix: stop warning about late pings
Home Assistant's aiohttp client only pings after 20s without a message
from the device, so with memStatus every 30s pings arrive every 30-60s
and every one was logged as late. The gap depends on our own traffic,
not the link, so log pings at debug level and keep the time of the
last one for the disconnect line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:23:11 +02:00
cjkas
b6ed24f5bb fix: hook the log lock into log_printfv
The Arduino core already links with --wrap=log_printf for the ESP
Insights hook in libesp_diagnostics, so defining __wrap_log_printf
failed to link with a duplicate definition. That hook prints through
log_printfv, so wrap that instead and take the lock there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:23:11 +02:00