Skip to content

Fix S31 ethernet transmit wedge; count every dropped frame; name the card vendors - #60

Merged
ewowi merged 3 commits into
mainfrom
next-iteration
Jul 31, 2026
Merged

Fix S31 ethernet transmit wedge; count every dropped frame; name the card vendors#60
ewowi merged 3 commits into
mainfrom
next-iteration

Conversation

@ewowi

@ewowi ewowi commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

What this fixes

An ESP32-S31 streaming panel-card frames degraded with uptime and then wedged completely — every esp_eth_transmit refused, only a reboot recovering it. It read as a hardware fault for hours. It was two sdkconfig lines.

The arithmetic that caused it. CONFIG_ETH_DMA_BUFFER_SIZE defaults to 512 B. A panel-card frame is 1512 B, so every frame consumed three descriptors — a 10-descriptor TX ring held ~3.3 frames while the driver fires 132 back-to-back. 1536 B (64-byte aligned) gives one descriptor per frame.

The second half is a race. CONFIG_ETH_TRANSMIT_MUTEX defaults off, and mac->transmit advances a shared descriptor pointer with no locking. With the eth netif up, lwIP and the render task both reach it.

bench run buffer ring mutex result
original 512 10 off wedges at 9 and 20 min, ~19 000 refused frames in 20 min
all three 1536 30 on 1 h 05 clean
A 1536 30 off 3 000 drops in 4 min
B 1536 10 on 0 drops in 11 min
shipped 1536 10 on clean, verified again over a further hour

Only what earns its place ships. Buffer size and the mutex are both load-bearing — removing the mutex alone brought drops back even with 30 descriptors. A 30-deep ring ran no cleaner than 10, so it was reverted, saving ~30 KB of internal RAM. Net cost: ~20 KB (the size bump applies to both rings, and the RX half buys the panel driver nothing).

The diagnostic that found it

esp_eth_transmit refuses a down link before touching the MAC (ESP_ERR_INVALID_STATE) and a full ring after (ESP_ERR_NO_MEM). We collapsed both into one bool, so "5.3 million drops" was unreadable — it could have been either fault. Splitting them settled it in one build: every failure was ring, none was link.

The driver's status carries it: 1000 Mbit - 3432 pkt/s, N lost (x link, y ring). Kept deliberately — panel-card tuning continues, and this is what makes it measurable.

Recovery

ethRestartTx() (esp_eth_stop/start) re-runs negotiation and resets the descriptor rings — the only way back from a wedge, since no ioctl writes the driver's link flag. Attempted once per wedge, never repeatedly: a restart cannot fix an unplugged cable, and retrying would bounce the interface under a user reading the card. Bench-verified recovering twice, ~17 s each.

Still shipped even though the config fix removes the wedges it was written for: it is insurance against a wedge from another cause, and it is now pinned by tests.

Also in this branch

  • A flaky test, fixedDistortionWavesEffect asserts two distant lights differ, but its hue is the average of two time-advanced sines and the test ticks once, so the phase was whatever the process uptime happened to be. Measured: 2.28% of start times make the two sampled lights identical, matching the 2.3% predicted from the sine pair. That is ~1-in-44 on any CI run, on any branch, and it predates this work — the realtime sanitizer just runs slowly enough to hit it more often. Now freezes the clock, using the pattern BouncingBallsEffect already established. The other 15 time-driven effect tests were checked; they assert time-invariant properties.
  • repo_health.py reads its delta baseline from git, not the working tree. Repeated runs previously compared against each other, so a second run showed a delta of ~0 and the real change vanished. Deltas now cover all pending changes and are stable across runs. Carry-forward still reads the working tree — it wants the newest numbers, the delta wants the committed ones. Snapshot shape is validated, and a malformed baseline is announced rather than silently read as "no previous numbers".
  • README credits — WLED and WLED-MM first (projectMM is born out of WLED, and every part here is a MoonModule), FPP as the inspiration for PanelCardDriver, and WLED-iOS alongside WLED-Android.
  • lessons.md — the descriptor arithmetic, the mutex race, and the two wrong turns that cost hours.

Other card vendors

ColorLight is one of several receiver-card makers, so the driver is named for the category and
carries a format selector. The class documentation now lists
NovaStar, ColorLight,
Linsn, Mooncell,
Huidu, DBstar and Xixun, and invites contributions: adding one is a
*Packet.h plus a kFormatOptions entry, since the window, correction, chunking and platform seam
are shared, and the desktop capture path pins byte layouts with no hardware.

It also says what is not known: each vendor speaks its own proprietary L2 protocol, so a
ColorLight frame will not drive a NovaStar card, and whether another format fits this driver's
row-plus-sync model is unverified. The shape is an invitation, not a promise.

A gate bug found on the way

check_esp32_built compared the S3 binary against every source, including ones that firmware
does not compile. PanelCardDriver.h is gated out of the S3 build, so ninja correctly does nothing,
the binary's timestamp never moves, and the gate reported it stale forever with no rebuild able to
clear it. Any comment-only edit to that header would have blocked every future commit.

It now asks ninja's own dependency database what each firmware compiles. Verified with two controls
that must fire: the S3 against a header it does compile, and the S31 against PanelCardDriver.h.
Both correctly report stale, so the check got more precise rather than merely quieter.

Reviewed

Three review rounds, 19 findings, all processed — including three real bugs in the recovery path, all in code written during review:

  • The once-per-wedge bound reset whenever the streak dropped below threshold rather than when frames flowed, so a wedge surviving its restart could rebuild the streak in ~260 ms and bounce the interface every ~20 s — the exact loop the bound existed to prevent.
  • ethRestartTx's return value was dropped. A failed esp_eth_start left Ethernet stopped forever while the card reported "no ethernet link", indistinguishable from an unplugged cable.
  • Only row failures incremented the drop counter, so the four brightness/sync frames per tick failed invisibly — and dropped described a different set of frames than the (link, ring) totals beside it, making the comparison meaningless.

The pre-merge round found three more, all in the same recovery path:

  • ethRestartTx blocks the render thread for up to ~4 seconds, against a comment of mine
    claiming microseconds. esp_eth_start restarts autonegotiation, which polls the PHY with
    vTaskDelay(100 ms) up to autonego_timeout_ms (4000 by default, and a wedged link is down so it
    runs to the timeout). Verified in the IDF source; the comment now states the real cost and why the
    trade is acceptable.
  • A failed restart masqueraded as an unplugged cable. ethLinkUp_ was cleared before
    esp_eth_stop, so a failed stop left transmit permanently refused behind a "no ethernet link"
    warning. Ordering fixed, and the error is latched so the next tick cannot soften it.
  • The once-per-wedge tests could not fail. The desktop restart clears the streak, so the wedge
    branch was never re-entered and they passed with the guard deleted. Rewritten to rebuild the
    streak, and the guard was mutation-tested: deleted → fails, restored → passes.

Writing the tests for "new behaviour unpinned" exposed a fourth: the wedge branch was unreachable on desktop, because the link check ran first and ethLinkUp() is false there. That is why the recovery shipped untested. The fix is an ordering correction with its own justification — a wedge is defined by sends failing, which is knowable whatever the link claims.

Verification

  • 7/7 pre-merge gates green; four ESP32 variants build (S31, S3, P4, classic).
  • 26 unit tests for the driver (was 21), plus 9 Python tests for the snapshot validation.
  • Bench: 1 h 05 recorded clean against wedges at 9 and 20 minutes, confirmed again over a further hour, plus the PO's own observation across a morning.
  • Regression check after the review fixes: reflashed and run past both historical failure points (9:04 and 20:23). 22+ minutes, zero drops, zero wedges, 4 251 packets/s against a 4 224 baseline.

Honest limits

  • The fix is verified on the S31 panel path only. Full-MTU senders (DDP, large HTTP) shared the same 3.3-frame ring and should also benefit, but that is reasoning, not measurement.
  • No scenario contract for the driver: it needs a receiver card on the wire, so performance.md records bench numbers rather than an asserted ceiling.

🤖 Generated with Claude Code

An S31 streaming to panel cards degraded with uptime and then wedged entirely,
recovering only on a reboot. Two sdkconfig lines fix it: the DMA buffer was
smaller than an ethernet frame, and the transmit mutex was off while two tasks
shared the interface. Bench: 19 000 refused frames and two wedges in 20 minutes,
against a clean hour after.

Performance: no tick-path change. The fix costs ~20 KB of internal DMA RAM on
the S31 (both rings, unconditional).

**Core**
- ethSendFailCounts splits send failures by cause. esp_eth_transmit refuses a
  down link BEFORE touching the MAC (ESP_ERR_INVALID_STATE) and a full ring after
  (ESP_ERR_NO_MEM); one counter could not tell them apart, which is what made the
  bug unreadable for hours. Every failure here turned out to be the ring.
- ethRestartTx re-runs link negotiation and resets the descriptor rings — the
  only way back from a wedge, since no ioctl writes the driver's link flag.

**Light domain**
- PanelCardDriver attempts recovery once per wedge and reports what happened.
  The wedge check now runs BEFORE the link check: a wedge is defined by sends
  failing, which is knowable whatever the link claims, and checking the link
  first made the branch unreachable wherever the platform reports no link.
- The status line carries the split: "1000 Mbit - 3432 pkt/s, N lost (x link,
  y ring)".

**Scripts/MoonDeck**
- repo_health reads its delta baseline from git rather than the working tree, so
  repeated runs give identical deltas instead of comparing against the previous
  run, and the delta covers all pending changes. Carry-forward still reads the
  working tree — it wants the newest numbers, the delta wants the committed ones.

**Tests**
- 26 unit tests (was 21): the wedge detection, the once-per-wedge bound, a wedge
  that survives its restart, and the cause-split counter.

**Docs/CI**
- lessons.md: the descriptor arithmetic, the mutex race, and the two wrong turns
  — misreading a rate test as ruling out back-pressure, and one counter serving
  two faults.
- The driver's catalog screenshot, regenerated against the fixed firmware.

**Reviews**
- 👾 the once-per-wedge bound reset on a merely-healthy link, so a surviving
  wedge could bounce the interface every ~20 s -> now gated on frames flowing.
- 👾 ethRestartTx's return was dropped; a failed start left ethernet stopped and
  reported as an unplugged cable -> now its own error.
- 👾 desktop ethSendFailCounts returned the streak, not a cumulative total ->
  fixed; the status would have read (0 link, 0 ring) while drops climbed.
- 👾 repo_health carry-forward would revert fresh perf numbers to the committed
  ones -> delta and carry-forward now read from different sources.
- 👾 a comment orphaned from its function by this change -> moved back.
- 👾 statusBuf_ sized for the wrong line; truncation would cut the ring count ->
  96 B.
- 👾 "~46 KB" read as a saving when it is a total, and the size bump's own ~20 KB
  was unstated -> both corrected.
- 👾 new behaviour unpinned -> tests added, which is what exposed the unreachable
  wedge branch above.

Bench-isolated, so only what earns its place ships: buffer size and the mutex are
both load-bearing (removing the mutex alone returned 3 000 drops in 4 minutes),
while a 30-deep ring ran no cleaner than 10 and was reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main Ethernet wedge fix and related failure-counting changes in the pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/light/drivers/PanelCardDriver.h (1)

306-338: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reconcile dropped with linkDown + ringFull; they track different scopes.

dropped (from framesDroppedTotal_) only counts failures from the per-row send loop. It does not count failures from the brightness frame (lines 213-219) or the sync frame (lines 250-255). platform::ethSendFailCounts() counts every failed ethSendRaw() call, including brightness and sync frames.

The status line shows both numbers together: "%u lost (%u link, %u ring)". Because the two counters have different scopes, linkDown + ringFull can exceed dropped, for example "3 lost (5 link, 2 ring)". This misleads exactly the kind of troubleshooting this diagnostic is built for.

Also, if (dropped) gates the whole per-cause line. When only brightness/sync sends fail during a tick (all row sends succeed), dropped stays 0, so real linkDown/ringFull increments from that tick stay invisible in this status line.

Count every ethSendRaw() failure in framesDroppedTotal_, including brightness and sync frames, so dropped and linkDown + ringFull describe the same population. Alternatively, drop the separate dropped local counter and gate/report directly from linkDown + ringFull.

🐛 Proposed fix: count brightness/sync failures too
         {
             const size_t len = buildColorLightBrightnessPacket(packet_, kCardGain);
             // Sent TWICE, like the sync below. Card firmware v13+ acts on the second copy only;
             // older firmware ignores the duplicate, so sending both costs one frame and works on
             // every version rather than making the behaviour depend on a firmware probe we do not do.
-            if (platform::ethSendRaw(packet_, len)) framesSent_++;
-            if (platform::ethSendRaw(packet_, len)) framesSent_++;
+            if (platform::ethSendRaw(packet_, len)) framesSent_++; else framesDroppedTotal_++;
+            if (platform::ethSendRaw(packet_, len)) framesSent_++; else framesDroppedTotal_++;
         }
...
         if (anyRowSent) {
             const size_t len = buildColorLightSyncPacket(packet_, kCardGain);
             // Twice, for the same firmware reason as the brightness frame above.
-            if (platform::ethSendRaw(packet_, len)) framesSent_++;
-            if (platform::ethSendRaw(packet_, len)) framesSent_++;
+            if (platform::ethSendRaw(packet_, len)) framesSent_++; else framesDroppedTotal_++;
+            if (platform::ethSendRaw(packet_, len)) framesSent_++; else framesDroppedTotal_++;
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/light/drivers/PanelCardDriver.h` around lines 306 - 338, Update the
send-failure accounting in the brightness and sync frame paths alongside the
per-row send loop so every failed ethSendRaw() increments framesDroppedTotal_.
Keep the dropped status gate and "%u lost (%u link, %u ring)" report in the
status logic, ensuring dropped and the totals returned by
platform::ethSendFailCounts() cover the same failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@moondeck/check/repo_health.py`:
- Around line 221-228: Validate loaded snapshot data and each required section
in load_previous and load_working_tree, accepting only mappings and reporting
malformed JSON or wrong shapes visibly. Preserve the existing snapshot as the
baseline and prevent write and main from overwriting it until valid data is
available; use the working-tree fallback only when Git cannot provide HEAD.
Ensure merge_carry_forward receives mappings, and add regression tests covering
malformed JSON and valid JSON with invalid root or section shapes.

In `@src/platform/esp32/platform_esp32.cpp`:
- Around line 973-983: Update ethRestartTx() to check the return value of
esp_eth_stop() and avoid calling esp_eth_start() when stopping fails, returning
failure immediately. Ensure the stop/start handling has an explicit bounded
worst-case duration suitable for the MM_NONBLOCKING PanelCardDriver::tick1s()
path, and document that bound near ethRestartTx() or its call site.

---

Outside diff comments:
In `@src/light/drivers/PanelCardDriver.h`:
- Around line 306-338: Update the send-failure accounting in the brightness and
sync frame paths alongside the per-row send loop so every failed ethSendRaw()
increments framesDroppedTotal_. Keep the dropped status gate and "%u lost (%u
link, %u ring)" report in the status logic, ensuring dropped and the totals
returned by platform::ethSendFailCounts() cover the same failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 95af0a24-d922-4277-b5eb-a08200736de6

📥 Commits

Reviewing files that changed from the base of the PR and between 49795a4 and bf38686.

⛔ Files ignored due to path filters (1)
  • docs/assets/light/drivers/PanelCardDriver.png is excluded by !**/*.png
📒 Files selected for processing (10)
  • docs/history/lessons.md
  • docs/metrics/repo-health.json
  • docs/metrics/repo-health.md
  • esp32/sdkconfig.defaults.esp32s31
  • moondeck/check/repo_health.py
  • src/light/drivers/PanelCardDriver.h
  • src/platform/desktop/platform_desktop.cpp
  • src/platform/esp32/platform_esp32.cpp
  • src/platform/platform.h
  • test/unit/light/unit_PanelCardDriver.cpp

Comment thread moondeck/check/repo_health.py Outdated
Comment thread src/platform/esp32/platform_esp32.cpp
Review findings on the panel-card work, plus a test that had been failing in CI
at random since it was written. The panel driver counted only its row frames as
dropped, so its status compared two different sets of frames and the comparison
meant nothing.

Performance: unchanged.

**Light domain**
- PanelCardDriver counts brightness and sync failures too. Only row failures were
  counted, so four frames per tick failed invisibly and `dropped` described a
  different set of frames than the platform's per-cause totals it sits beside.

**Core**
- ethRestartTx checks esp_eth_stop before starting: a failed stop leaves the
  driver in a state we did not establish, and starting on top of that compounds
  it. The caller already turns a false return into "restart the device".
- Documented why the stop/start is safe from the 1 Hz tick: register writes plus
  an esp_timer stop/start, microseconds rather than the seconds negotiation takes,
  and only ever in a wedged state where no frames are going out.

**Scripts/MoonDeck**
- repo_health validates the snapshot shape at load. merge_carry_forward does
  `old.get(section, {})`, so a JSON list or scalar would raise and a section
  holding a list would corrupt the merge. A rejection is announced rather than
  read as "no previous numbers", which would report every metric as new and look
  like a clean slate instead of a broken baseline.

**Tests**
- unit_DistortionWavesEffect freezes the clock. The effect's hue is the average of
  two time-advanced sines and the test ticks once, so the phase was whatever the
  process uptime happened to be — and at 2.28% of start times the two sampled
  lights land on the same hue. Measured, and it matches the 2.3% predicted from
  the sine pair. It had a ~1-in-44 chance of failing on any run, on any branch,
  and the realtime sanitizer runs slowly enough to hit it. Same ClockGuard pattern
  BouncingBallsEffect already uses. The other 15 time-driven effect tests were
  checked: they assert time-invariant properties, so only this one was exposed.
- 9 Python regression tests for the snapshot validation, including a control that
  a valid snapshot passes through untouched.

**Docs/CI**
- README credits: WLED and WLED-MM first (projectMM is born out of WLED, and every
  part here is a MoonModule), and FPP as the inspiration for PanelCardDriver.
- README em-dashes removed (26), rewritten rather than substituted.

**Reviews**
- 🐇 brightness/sync failures were not counted -> fixed.
- 🐇 esp_eth_stop's return was ignored -> checked.
- 🐇 repo_health accepted any JSON shape -> validated, announced, and pinned by
  tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ewowi ewowi changed the title Fix S31 ethernet transmit wedge: DMA buffer size and transmit mutex Fix S31 ethernet transmit wedge; count every dropped frame; fix a flaky test Jul 31, 2026
Pre-merge review findings on the panel-card work. The recovery path was the weak
spot: it blocked the render thread for up to 4 seconds against a comment claiming
microseconds, a failed restart hid behind an unplugged-cable warning, and the
tests that were supposed to pin the guard could not fail.

Performance: no change to the steady-state tick. The recovery blocks up to ~4 s
when it fires, which is now stated rather than mis-stated; it fires only in a
wedged state where no frames are going out.

**Core**
- ethRestartTx: clear ethLinkUp_ only AFTER esp_eth_stop succeeds. Clearing first
  and then failing left transmit permanently refused with no event able to set the
  flag again, behind a "no ethernet link" warning.
- A link-down refusal now increments the link counter. It was silent, so `dropped`
  and the per-cause totals beside it described different sets of frames.
- Documented the real cost of ethRestartTx: esp_eth_start restarts autonegotiation,
  which polls the PHY with vTaskDelay(100 ms) up to autonego_timeout_ms (4000 by
  default, and a wedged link is down so it runs to the timeout). The previous
  comment claimed microseconds and no link poll, which was wrong on both counts.

**Light domain**
- A failed restart is latched, so the next tick cannot soften it to the link
  warning. It is the one state a user cannot resolve by plugging a cable back in.
- PanelCardDriver's more-info section lists the other receiver-card vendors
  (NovaStar, ColorLight, Linsn, Mooncell, Huidu, DBstar, Xixun) with links, and
  invites contributions: a *Packet.h plus a kFormatOptions entry, with the window,
  correction, chunking and platform seam already shared. It also says what is NOT
  known — each vendor speaks its own proprietary L2 protocol, and whether another
  format fits this driver's row-plus-sync model is unverified.

**Scripts/MoonDeck**
- check_esp32_built asks ninja's dependency database what each firmware actually
  compiles. PanelCardDriver.h is gated out of the S3 build, so ninja correctly
  does nothing, the binary's timestamp never moves, and the gate reported it stale
  forever with no rebuild able to clear it. Any comment-only edit to that header
  would have blocked every future commit.

**Tests**
- The once-per-wedge tests were vacuous: the desktop restart clears the streak, so
  the wedge branch was never re-entered and they passed with the guard deleted.
  They now rebuild the streak between ticks, and the guard was mutation-tested
  (deleted -> fails, restored -> passes).
- A test for the failed-restart path, reachable via a new setTestEthRestartFails
  hook.
- Two tests for the git baseline, including one proving the delta comes from the
  commit rather than the working tree.
- clearClaims also resets the platform's simulated-failure switches: they are
  process-global, so a test leaving one set poisoned the next, which is why one
  case passed alone and failed in sequence.

**Docs/CI**
- performance.md: re-measured on the fixed firmware, and it now records the DMA
  finding and its ~20 KB cost. Corrected a claim that ~32 FPS was the driver's
  ceiling; it is the render's, and a lighter effect mix measures ~56 FPS.
- README: WLED-iOS alongside WLED-Android in the Moustachauve credit.
- No em-dashes in any line this branch adds.

**Reviews**
- 👾 ethRestartTx blocks ~4 s on the render thread, comment said microseconds ->
  measured in the IDF source, comment corrected, trade stated.
- 👾 a failed restart masqueraded as an unplugged cable -> ordering fixed, error
  latched.
- 👾 the once-per-wedge tests could not fail -> rewritten and mutation-tested.
- 👾 dropped vs the per-cause totals counted different frames -> link-down counted.
- 👾 load_previous untested -> two tests with a tmp git repo.
- 👾 the restart-failed branch was untestable -> test hook added.
- 👾 new prose added em-dashes -> removed from every added line.
- 👾 carried-forward flash figures for targets not rebuilt: accepted, that is the
  run-before-commit shape of the KPI gate rather than a branch defect.

Bench: flashed to the S31 and ran past both historical failure points (wedges at
9:04 and 20:23 before the fix). 22+ minutes, zero drops, zero wedges, 4 251
packets/s against a 4 224 baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ewowi ewowi changed the title Fix S31 ethernet transmit wedge; count every dropped frame; fix a flaky test Fix S31 ethernet transmit wedge; count every dropped frame; name the card vendors Jul 31, 2026
@ewowi
ewowi merged commit b423df8 into main Jul 31, 2026
8 checks passed
@ewowi
ewowi deleted the next-iteration branch July 31, 2026 11:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant