Commit graph

494 commits

Author SHA1 Message Date
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
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
2ee7af51ab refactor: share shade remote matching in SomfyShade::findRemote
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>
2026-10-12 18:01:31 +02:00
cjkas
b4d480caa0 fix: send memStatus once per network broadcast
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>
2026-10-12 18:00:52 +02:00
cjkas
08d2cd7a0c fix: read the rolling code from NVS only until it has been written
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>
2026-10-12 18:00:51 +02:00
cjkas
a49fa5d953 fix: don't hold position updates back while the radio is full
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>
2026-10-12 17:59:48 +02:00
cjkas
ab0eba2035 fix: leave out /controller items that overflow instead of breaking the JSON
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>
2026-10-12 17:59:39 +02:00
cjkas
03eb3b3d99 fix: let a priority stop overtake a delayed radio job
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>
2026-10-12 17:59:10 +02:00
cjkas
1cc55e07f0 fix: keep the core dump in flash after reporting it
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>
2026-10-12 17:58:12 +02:00
cjkas
77d3e18cce fix: log only timings of 100ms or more by default
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>
2026-10-12 17:57:47 +02:00
cjkas
6cec411fa8 fix: stop the log lock lifting loop() above the radio task
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>
2026-10-12 17:57:33 +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
55b1db4508 fix: stop warning about late pings
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>
2026-10-11 22:23:11 +02:00
cjkas
b6ed24f5bb fix: hook the log lock into log_printfv
The Arduino core already links with --wrap=log_printf for the ESP
Insights hook in libesp_diagnostics, so defining __wrap_log_printf
failed to link with a duplicate definition. That hook prints through
log_printfv, so wrap that instead and take the lock there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:23:11 +02:00
cjkas
0610dc4369 feat: log where /discovery spends its time
The /discovery handler takes about 200ms on the async_tcp task on every
call with no obvious cause. When it takes 50ms or more, log how long
the header, memory stats, rooms, shades, groups and sending took.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:50:49 +02:00
cjkas
66d264e4b5 fix: broadcast memStatus every 30s
It went out whenever free heap moved by 1.5 KB and 7s had passed,
which with normal traffic was about 240 times an hour. Send it every
30s instead. It is also the one event always sent, so 30s keeps Home
Assistant well inside its 60s receive timeout. Per-client sends and
the web UI's room are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:50:47 +02:00
cjkas
bd7ed17a97 feat: log AP signal levels and roam for a 6 dB gain
The roaming scan found two APs for the SSID every 10 minutes but the log
never showed their signal, so there was no way to tell whether the
device at -70 dBm had a better AP to move to. Log each AP on the
roaming scans, show roams at info level, and switch for a 6 dB gain
instead of 10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:50:46 +02:00
cjkas
f1eac31957 fix: report the real flipPosition for linked shades
serializeShadeRef sent flipCommands as flipPosition, so group linked
shades and the /shadeCommand reply showed the wrong setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:50:42 +02:00
cjkas
6b19d24eb4 fix: print each log line whole
The Arduino core's log_printf() has its UART lock commented out and
writes with ets_printf(), so lines logged at the same time from
async_tcp and loop() came out interleaved character by character. It
also formats short lines into one static buffer shared by every task.

Wrap log_printf at link time and hold a mutex around the core's
log_printfv(). It never blocks before the scheduler runs, in an ISR or
in a critical section, and waits at most 50ms before printing anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:50:41 +02:00
cjkas
dd47c04905 feat: log timings for slow work, socket clients and boot crashes
Add Timing.h and use it to log what holds up the main loop, how long a
new socket client's setup takes, slow socket sends, and how long each
web request took and from which client. Socket connects and disconnects
are logged with how long the client stayed, and late pings as warnings.
Routine sends and pings are not logged, as each line costs several ms
on a 115200 baud console.

Report a stored crash once with the crashed and running build IDs, then
erase it, since it was reported on every boot and its backtrace only
decodes against the build that crashed. Mount the file system once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:35:43 +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
3a8736f4d4 refactor: share the shade travel maths in ShadeMotion.cpp
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>
2026-10-11 13:37:48 +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
b2294ced34 fix: escape control characters and count JSON string lengths correctly
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>
2026-10-11 13:30:42 +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
d723c1024d refactor: move JsonFormatter into JsonFormatter.cpp
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>
2026-10-11 13:29:30 +02:00
cjkas
60419a37aa fix: parse the command names the firmware reports
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>
2026-10-11 13:20:49 +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
bb16c90a5a refactor: move the Somfy frame codec into SomfyFrame.cpp
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>
2026-10-11 13:18:37 +02:00
cjkas
02df2daaab fix: report half-hour time zones correctly in ISO timestamps
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>
2026-10-11 12:54:56 +02:00
cjkas
aa23b4624d ci: run native unit tests before firmware builds
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>
2026-10-11 12:54:48 +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
1f394ca9df fix: back-pressure web commands and report radio failures
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>
2026-10-11 12:46:40 +02:00
cjkas
00a084048f fix: swap restored radio config under the radio lock
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>
2026-10-11 12:46:40 +02:00
cjkas
352f36e478 fix: commit favorite only once SETMY is fully on air
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>
2026-10-11 12:46:40 +02:00
cjkas
2a45dba37a fix: cap repeats per radio job to keep loop under the WDT
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>
2026-10-11 12:46:40 +02:00
cjkas
44227bfd87 fix: keep rolling codes in order when a stop is queued
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>
2026-10-11 12:46:40 +02:00
cjkas
130b5b6b0a fix: hold simulation only for shades a command starts moving
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>
2026-10-11 12:46:39 +02:00
cjkas
2a54740f52 fix: never leave radio TX suspended after a scan or aborted OTA
- 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>
2026-10-11 12:23:20 +02:00
cjkas
e55d9e48f7 fix: keep shade simulation in step with what goes on air
- 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>
2026-10-11 12:21:25 +02:00
cjkas
ad15ecacd4 fix: propagate radio TX result to callers instead of pre-checking
- 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>
2026-10-11 12:14:29 +02:00
cjkas
a426efe53f fix: make radio TX queue flush-safe and report job outcomes
- 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>
2026-10-11 12:08:48 +02:00
cjkas
9d16d6cc6d fix: address radio TX task review findings
- 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>
2026-10-10 20:39:18 +02:00
cjkas
9a9afa5e86 fix: move radio TX to a dedicated task so HA WebSocket stays connected
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>
2026-10-10 20:13:31 +02:00
github-actions[bot]
b117c34cb8 chore: bump version to v3.0.14 2026-10-10 17:01:25 +00:00