Commit graph

540 commits

Author SHA1 Message Date
cjkas
377a1217a6
Merge pull request #36 from cjkas/fix/live-bugs
Fix NVS, radio and shade bugs; split Somfy.cpp into tested modules
2026-10-14 15:52:04 +02:00
cjkas
c71b5d8a85 docs: record phase 2 of the Somfy.cpp split
GLOSSARY.md gains the shade terms the new modules use (favorite, step,
sensor frame, sun flag, extend and retract) and the pulse decoder. The
split plan marks phase 2 done, with what changed in behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:22:04 +02:00
cjkas
a176017960 refactor: move the RX pulse decoder into PulseDecoder
The decoding of the time between received edges into frame bits moves out
of Transceiver::handleReceive unchanged into decodePulse(), with the RTS
timings and the receive buffer type. It stays in IRAM on the ESP32 and is
now covered by native tests that feed it the edges sendFrame() produces
for 56 and 80-bit first frames and repeats, a glitch and a broken pulse.

handleReceive keeps the noise guard, the edge timing and the RX queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:21:05 +02:00
cjkas
c75b715b3b refactor: share step targets between remote and group steps
A received step and a group step each worked out where a step takes the
shade, the group copy with its own inline maths. stepTargets() in
ShadeMotion now does it for both: a tilt-only shade steps its tilt, an
integrated tilt turns its slats to the end first and then steps the lift,
and every other shade steps its lift.

Only edge cases change. A group step on a tilt-only shade already at the
end now goes through like a remote step, and a step that can't be sized
(no travel time) leaves the targets alone in both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:17:02 +02:00
cjkas
5f26340d06 fix: move only the tilt of a tilt-only shade for sun and wind
The three places that act on the sun and wind each set different targets
for a tilt-only shade: the sun timer moved its lift, the no-sun and wind
timers moved tilt and lift, a group's sun flag moved its lift and only a
remote's sun flag moved its tilt. They now share sensorTargets(): a
tilt-only shade moves its tilt to the favorite tilt (or fully) and back to
0, and every other shade its lift, as Up and Down already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:14:20 +02:00
cjkas
8276c1c3cd refactor: move sun and wind automation into ShadeSensors
The sun and wind timers were spread over processFrame, checkMovement and
processInternalCommand. ShadeSensors now holds them and decides when the
weather extends or retracts a shade, when turning the sun flag on moves
it, and when wind blocks a remote Down. Each caller still sets the targets
itself, so no shade moves differently yet.

The timers are now 32-bit like millis(). As 64-bit values holding a 32-bit
millis() they fired at once after the rollover every 49.7 days. The debug
logs of each timer go: they were compiled out at CORE_DEBUG_LEVEL 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:12:34 +02:00
cjkas
0eb0e63631 refactor: move the shade type enums into ShadeTypes.h
group_types, shade_types and tilt_types move out of Somfy.h unchanged into
a header without the shade model, so the shade maths in ShadeMotion and
the sun and wind rules can use tilt_types in the native tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:08:53 +02:00
cjkas
c8d774be7d chore: remove unused ESP-IDF, SVD and template files
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>
2026-10-13 20:54:53 +02:00
cjkas
c0cdf93747 docs: point to the files Somfy.cpp was split into
Add the Somfy.cpp split plan with phases 0 and 1 marked done, what the
split taught about flash size in the debug build, and the phase 2 work
still open. KNOWN_ISSUES.md now names the new files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:48:24 +02:00
cjkas
505108c0c0 refactor: rename what is left of Somfy.cpp to SomfyShade.cpp
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>
2026-10-13 20:47:14 +02:00
cjkas
f64d010987 refactor: move SomfyShadeController into ShadeController.cpp
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>
2026-10-13 20:45:04 +02:00
cjkas
ef1d0a0882 refactor: move SomfyGroup into SomfyGroup.cpp
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>
2026-10-13 20:43:05 +02:00
cjkas
50863123a1 refactor: move SomfyRemote into SomfyRemote.cpp
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>
2026-10-13 20:41:12 +02:00
cjkas
b152f25d11 refactor: move shade GPIO into ShadeGpio.cpp
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>
2026-10-13 20:39:22 +02:00
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