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>
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>
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>
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>
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>
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>
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>
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>
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>
checkMovement worked out the position four times, nearly copy-pasted,
for lift down, lift up, tilt down and tilt up. All four now call
travelPosition(), which keeps the same arithmetic, including how the
elapsed time wraps with millis(). Tilt up with no tilt time still
stops the tilt direction in the caller.
transformPosition and transformCommand keep their behaviour through
the new displayPosition() and flipCommand() helpers. The native env
builds ShadeMotion.cpp so all of this can be unit tested on the PC.
No behaviour change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The command and protocol enums, somfy_frame_t and its encode/decode
methods, and both translateSomfyCommand overloads move verbatim out of
Somfy.cpp/Somfy.h, which now include SomfyFrame.h. The codec doesn't
depend on the radio, storage or network, so on its own it can be unit
tested on the PC.
decodeFrame(somfy_rx_t*) stays in Somfy.cpp since it reads the RSSI
from the CC1101. No behaviour change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Web commands are answered once queued, but the main loop handed all of
them to the radio at once and anything past its free slots was dropped
silently. Unpair also cleared paired even when the Prog wasn't sent.
- processQueue leaves commands queued until the radio has room.
- Commands that transmit get a 503 straight away during a frequency
scan instead of being dropped later.
- Unpair restores paired when the Prog can't be sent.
- Failed web commands are logged.
- Note that /setPositions and /setSensor answer with the state before
the command; the socket events carry the result.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Restoring a backup wrote the transceiver config in place from the async
task while the radio task could be transmitting with it.
Read the record into a copy and swap it in through the new
Transceiver::setConfig, which Transceiver::fromJSON now uses as well.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The favorite was persisted as soon as the long My press was queued. A
flush or radio reconfiguration could drop or cut it short and the app
kept a favorite the motor never stored.
The radio task now reports finished/aborted for every job. Setting a
favorite goes through sendSetMy, which commits it on finished and puts
the previous one back otherwise. The new favorite is set before the
My is sent so the simulation no longer heads for the old one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The radio task bit-bangs a whole job on loop()'s core at a higher
priority. A job with 127+ repeats ran for 18-36s without loop() ever
running and the 15s task watchdog rebooted the device.
Limit a job to 50 repeats (about 7s) in encodeTxJob, which every send
path goes through, and advance the 80-bit repeat counter by the number
of frames actually queued.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A priority stop always went to the front of the radio queue, ahead of
an earlier frame of the same remote that was still waiting. The older
rolling code then went out last and the motor rejected it.
Split "may use the reserved slots" (priority) from "goes to the front"
(front) and only send a stop to the front when its remote has nothing
queued at the back. The radio task and flushes track which back jobs
have been taken so Transceiver::isQueued() can tell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
markTxPending ran inside sendCommandAfter before callers set the new
target, so isAtTarget() kept idle shades from ever being held, while
shades already moving were frozen although their motor kept running.
Hold a shade only when it was idle before the command and release the
hold when the command didn't set it moving. The single mark in
sendCommandAfter now covers group members too, so the second pass in
SomfyGroup::sendCommand is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- endFrequencyScan always resumes TX; applying radio settings ends a scan.
- Frequency scans time out after 5 minutes.
- Reboot when an OTA upload is abandoned mid-transfer.
- /sendRemoteCommand returns 503 when the radio can't take the frame.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Hold a shade's simulated movement until its command is on air.
- Send stops at the target as priority jobs.
- If a stop can't be sent or is dropped, simulate the run to the limit.
- Roll back targets when a movement command is dropped.
- Only store a favorite position when SETMY was queued.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Send functions return whether the radio took the command.
- Shade, group and tilt state only change when the command was queued.
- Rolling code is committed only after the frame is queued.
- repeatFrame advances the repeat counter only on success.
- Remove canTransmit/canQueueTx; queueTx is the single authority.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Serialize queueTx and flushTx so a queued job can't be lost to a flush.
- Settings changes preempt only the job in flight; the queue survives.
- Flush drains the queue and reports each job as dropped.
- Track suspend reasons (scan, OTA) separately.
- Report started/dropped for every job on a single event queue.
- Add priority jobs that go to the front and may use reserved slots.
- Remove the MAX_TX_REPEATS clamp.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Abort in-flight transmissions on flush so config save, frequency scan
and OTA never wait on the radio lock for a whole job
- Suspend TX during frequency scan and OTA
- Refuse commands the radio queue cannot take before consuming a rolling
code or moving simulated shade state; shift movement start when a job
goes on air late
- Apply radio config changes under the radio lock
- Re-check receiver idle before repeater transmissions
- Return 503 from web handlers when the command queue is full
- Restore 200 ms settle delay before the set-My command
- Report RSSI 0 instead of a stale value when the radio is busy
- Encode each frame once and centralise the repeat clamp
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Web command handlers ran on the async_tcp task and blocked up to 3s
waiting for loop() to bit-bang radio frames. async_tcp also services the
WebSocket on port 8080, so pongs stalled and Home Assistant
(ping_timeout=15) disconnected when several covers were commanded.
- Add a radio_tx FreeRTOS task with a job queue; sendFrame/repeatFrame
and repeater frames enqueue instead of transmitting inline.
- Serialize CC1101 access with a recursive mutex (TX task, apply, end,
frequency scan, RSSI read).
- Make Web::queueCommand fire-and-forget; drop the done semaphore.
- Route /setMyPosition and /unpairShade through the command queue.
- Clamp repeats to MAX_TX_REPEATS (40).
- Pin AsyncTCP to core 0 on dual-core targets.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HA 2026.9.2 added strict device registry validation rejecting discovery
messages where via_device equals identifiers[0] ('A device can not be
its own via device'). ESPSomfy sets via_device = identifiers[0], causing
all cover entities to go unavailable on every ESP32 reboot.
via_device is for proxy/gateway relationships. ESPSomfy shades ARE the
ESP32 device — there is no proxy. Removing the field is semantically
correct and has no functional impact.
Fixes#32
The RTW decoder formula encKey - 133 was off by one versus the
RTW encoder (which maps command N to encKey N + 132). This caused
every non-My RTW command to decode as the wrong button (e.g. Up
decoded as My, Down as MyUp). Also adds rawCmd diagnostic field to
received frames for protocol debugging.