Commit graph

72 commits

Author SHA1 Message Date
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