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>
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>
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>
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>
calcEscapedLength looped from strlen(raw) down to 1, counting the
terminator instead of the first character. A value starting with a
character that needs escaping was counted one byte short, so the fit
check could let it be written one byte past the buffer. Control
characters other than \b\f\n\r\t were copied raw, which isn't valid
JSON.
Both now go through one escapeChar helper, which also escapes the
remaining control characters as \u00XX.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
JsonFormatter and BufferedJsonFormatter move verbatim out of
WResp.cpp/WResp.h, which now include JsonFormatter.h. They don't need
the web server, so on their own they can be unit tested on the PC; the
native env builds the new file. AsyncJsonResp and JsonSockEvent stay
in WResp. No behaviour change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
shadeCommand events (and the Home Assistant events built from them)
name commands "Step Up", "Sun Flag", "My+Up" and so on, but parsing
those names fell through to the prefix shortcuts: "Step Up" became
SunFlag, "Sun Flag" became StepUp and "My+Up" became My. Match the
reported names exactly before the shortcuts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
calcTZOffset added the leftover seconds instead of minutes to the hhmm
offset, so +05:30 came out as 2300 and ISO timestamps showed +2300.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a "Unit tests" job running pio test -e native on ubuntu-latest.
The firmware build matrix waits for it, so a failing test also stops
release uploads.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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.