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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>