Skip to content

feat: support several remote load units - #160

Merged
FredM67 merged 16 commits into
devfrom
feat/multiple-remote-units
Sep 30, 2026
Merged

FredM67 merged 16 commits into
devfrom
feat/multiple-remote-units

Conversation

@FredM67

@FredM67 FredM67 commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Closes #156.

dev supports exactly one remote load unit, with local loads declared first and remote ones last, in two separate arrays. This lifts that to three units, each a separate Arduino carrying up to eight loads, declared in a single array in any order.

The abandoned Support-multiple-remote-units branch prototyped the design but was never compiled (it sat behind an #ifdef nobody defined). This is a port-forward of its delta with its defects fixed, not a rebase.

The load map

inline constexpr uint8_t physicalLoadPin[NO_OF_DUMPLOADS]{
  Load::local(5),      // load 0: local TRIAC on D5
  Load::remote(1, 8),  // load 1: remote unit 1, status LED on D8
  Load::remote(2, 9),  // load 2: remote unit 2, status LED on D9
  Load::remote(1)      // load 3: remote unit 1, no LED
};

One packed byte per load: bits 6-7 the unit (0 = local, 1-3 = remote), bits 0-5 the physical pin or, for a remote load, an optional status LED. NO_OF_REMOTE_LOADS, NO_OF_REMOTE_UNITS and REMOTE_LOADS_PRESENT are derived from the map by a constexpr census, so they can no longer contradict it — the prototype shipped with NO_OF_REMOTE_LOADS{3} and a two-unit manager.

Priorities index the map, so local and remote loads share one rotation; the "remote loads must come last" rule is gone.

Transmission

RemoteLoadCore<N> keeps one payload byte per unit, sent on change and refreshed every REMOTE_REFRESH_CYCLES mains cycles so a receiver that missed a frame catches up. Bit b of a unit's payload is the b'th load of that unit in ascending map order; on the receiver loadPins[0] is bit 0. Each unit is addressed separately — a change affecting only unit 2 sends nothing to unit 1.

RemoteLoadCore::sendPending(nodeIds, send) owns the send loop; the firmware passes a lambda around RFM69::send(). The pending flag shared with the ISR is volatile, like the other ISR-shared flags in shared_var.h — no interrupts-off window: flag and payload are single bytes, and the worst race sends the same byte twice.

The core has no Arduino dependency, so it runs in the native tests, and it is constant-initialized, so it stays out of .init_array (the regression PR #155 had to fix).

RemoteLoadReceiver/ needs no code change: build and upload it once per unit, changing only REMOTE_NODE_ID and the load list.

Bug fixes that fall out of the rewrite

  • getDualTariffForcingBitmask() read out of bounds — it counted down from NO_OF_DUMPLOADS while indexing an array sized NO_OF_DUMPLOADS - NO_OF_REMOTE_LOADS, whenever DUAL_TARIFF and remote loads were both enabled.
  • countLoadON[] ignored remote loads — sized NO_OF_DUMPLOADS but only incremented on the local path, so remote loads always datalogged 0.

New validation

0xFF — the unused_pin sentinel — decodes as unit 3 / pin 63 under the packed encoding, so a local load left unconfigured would silently become a remote one. That is now rejected, along with too many loads on one unit, more than 8 remote loads, a REMOTE_NODE_ID table shorter than the unit count, and node IDs that repeat, collide with the router or fall outside 1-30. Each was verified to fire by building a deliberately broken config.

Tests

48 native cases in test_remote_loads, no stubs. The helpers take the map (as a template argument), the node-ID table and the sender as parameters instead of reading the config globals, so the suite covers:

  • the packed encoding, the census, unit grouping and bit order — including a regression pin for the prototype's index-reversal bug;
  • change detection and the keep-alive refresh;
  • the override pins derived from the map (LOAD(), ALL_LOADS()), fed through the real OverridePins;
  • the two config checks behind validation.h;
  • the send loop, with the radio replaced by a recording callable: unit n goes to REMOTE_NODE_ID[n-1], only due units are sent. Reversing that mapping fails three tests.

Not covered: the one-line radio().send() call and the countLoadON move inside the ISR path.

Verification

  • basic, basic_debug, emonesp, rf build with no -Wtype-limits warnings; 170/170 native cases pass; CI green, embedded tests (simavr) and grid simulation included.
  • .init_array is absent from every build; remoteLoads and RFM69:: are absent from basic.
  • A 5-load / 2-unit / dual-tariff / temperature-sensor rf config compiles; remoteLoads is 8 constant-initialized .bss bytes.

Flash: basic is +6 bytes against dev, not byte-identical as intended — a printConfiguration() debug string. The ISR itself is 20 bytes smaller and RAM 1 byte lower. basic_debug +2, emonesp +6, rf +6.

ISR cost with remote units

updateLoads< physicalLoadPin >() takes the map as a template argument and builds each unit's payload by compile-time recursion, so every load's unit and bit are constants. At run time that leaves, per remote load, one state test and one OR into a register.

Static count from the disassembly, 6 loads over 3 units, every remote load ON and every unit due:

First version Now
Once per mains cycle (payloads + scheduling) ~430 cycles (~27 µs) ~125 cycles (~8 µs)
Every ISR call (9.6 kHz) +13 cycles (stack frame) 0
Flash, this config 13176 12996

Worst allowed config (8 remote loads over 3 units): ~155 cycles (~10 µs), in line with dev's single-unit loop. Only builds with remote units pay any of this; default builds only carry the +6 bytes above.

Simulation

make -C sim check-rf (grid_sim, #169-#174) runs this firmware with configs/remote_loads.patch: 4 loads, 2 local and 1 on each of two remote units (nodes 15 and 16). Each remote unit runs the real RemoteLoadReceiver firmware in its own simulated AVR, over the RFM69 model, on the same clock as the router.

Scenario What it checks
remote_step surplus steps: loads switch on and off by priority, local and remote alike; no RF gap over 0.15 s for either unit
remote_override the override pin forces every load on, the remote ones included; no RF gap over 0.15 s
link_loss no frame reaches unit 15 for 1 s: it drops its load after the 500 ms timeout and takes it back once frames return, while unit 16 keeps its frames and its load
lossy_link 10 % of the frames lost at random: neither unit loses its link

The simulation found one limit of the receiver: with a refresh every 100 ms, 4 frames lost in a row already make a 500 ms gap, right at RF_TIMEOUT_MS. At 20 % loss, unit 16 lost its link once in 30 s (its load off for 0.2 ms).

Not yet run on hardware — neither the RF link with several units nor an oscilloscope check of the ISR.

Merge order

#163 (integer energy bucket) is merged, and this branch is up to date with dev. #162 (predictive switching) comes after this one: it is a real rework, not only a text conflict — its prediction uses the float bucket, and must move to Energy::contribution() and the cal / n table, which also removes its extra ISR cost.

🤖 Generated with Claude Code

FredM67 and others added 8 commits September 23, 2026 16:50
Loads used to be split across two arrays, with local loads first and
remote ones last, and only a single remote unit could be addressed.

physicalLoadPin[] now holds every load in any order, one packed byte
each: bits 6-7 carry the unit (0 = local, 1-3 = remote), bits 0-5 the
physical pin for a local load or an optional status LED for a remote
one. Load::local()/Load::remote() build the entries and a constexpr
census derives NO_OF_REMOTE_LOADS, NO_OF_REMOTE_UNITS and
REMOTE_LOADS_PRESENT from the map, so those counts can no longer
disagree with it.

RemoteLoadCore<N> groups the loads by unit and keeps one payload byte
per unit, sent on change and refreshed every REMOTE_REFRESH_CYCLES
mains cycles. Bit b of a unit's payload is the b'th load of that unit
in ascending map order. It is Arduino-free (stdint, utils_bits.h,
load_map.h) so it can be tested on the host, and constant-initialized
so it stays out of .init_array. REMOTE_NODE_ID becomes a table, one
entry per unit.

Two bugs fall out of the rewrite:

- getDualTariffForcingBitmask() counted down from NO_OF_DUMPLOADS while
  indexing an array sized NO_OF_DUMPLOADS - NO_OF_REMOTE_LOADS, reading
  out of bounds whenever DUAL_TARIFF and remote loads were both on. It
  now splits local and remote loads by type.
- countLoadON[] is sized NO_OF_DUMPLOADS but was only incremented on the
  local path, so remote loads always datalogged 0. It now counts on load
  state rather than on pin writes.

New static_asserts reject a local entry left at the unused_pin sentinel
(0xFF decodes as unit 3 / pin 63 under the packed encoding), more than
MAX_LOADS_PER_UNIT loads on one unit, more than 8 remote loads, a
REMOTE_NODE_ID table shorter than the unit count, and node IDs that
collide, repeat or fall outside 1-30.

Closes #156

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
29 host-run cases over load_map.h and remote_loads_core.h, no stubs:
packed encoding and the constexpr census, grouping loads by unit, the
ascending bit order, change detection and draining, the keep-alive
refresh, and the edge cases (no unit, reset, a unit the router does not
talk to).

Two of them pin the bugs the prototype had: load k must be paired with
state k rather than state N-1-k, and the first load of a unit must be
bit 0 even when a local load precedes it in the map.

Wired into the test-native CI matrix, otherwise the suite never runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both READMEs now describe the unified load map instead of the two-array
split, the three-unit / eight-loads-per-unit ceiling, the constants the
compiler derives from the map, the ascending bit order the receiver has
to match, and the REMOTE_NODE_ID table. Remote loads no longer have to
come last in the priority list, so that rule is gone too.

Also refresh the test inventories: test/README.md and CLAUDE.md still
listed suites deleted in ebdd833 and missed several that exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NO_OF_REMOTE_UNITS and NO_OF_REMOTE_LOADS are uint8_t constants that
are 0 in the default all-local config, so `i < N` trips -Wtype-limits
("comparison is always false"), even inside a discarded if constexpr.
Widening the counters to int and casting silenced it at the cost of
noise, and remote_loads.h and utils_override_helpers.h still warned.

Loop with `i != N` instead, which -Wtype-limits does not flag, and
check the REMOTE_NODE_ID table length by multiplying rather than
dividing. No -Wtype-limits warning left in any environment; flash and
RAM unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The pending-transmission flag is shared between the ADC ISR and loop().
It was left non-volatile and claimed inside a SREG/cli() critical
section, relying on cli() as a compiler barrier. Nothing else in the
code base does that: ISR-shared flags are simply volatile, as in
shared_var.h and as the single-unit version on dev was.

The critical section bought nothing either. Flag and payload are single
bytes, so each access is atomic on AVR; if the ISR runs between the
clear and the payload read, the newer payload is sent and the flag
stays set, so at worst the same byte goes out twice.

Costs 24 bytes of flash in a two-unit build, nothing when no remote
unit is configured.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…c is testable

The override helpers, the two config checks and the send loop read the
config globals (physicalLoadPin, REMOTE_NODE_ID) or called the radio
directly, which kept them out of reach of the native tests. They now
take what they need as parameters, the way the census functions and
RemoteLoadCore::updateLoads() already do:

- overridePinOf(), localLoadsMask(), remoteLoadsMask() in utils_override.h;
  LOAD(), ALL_LOCAL_LOADS(), ALL_REMOTE_LOADS() and the dual-tariff split
  in main.cpp become thin calls into them
- Load::isValidMap() and areValidNodeIds() replace check_load_map() and
  check_remote_node_ids() in validation.h
- RemoteLoadCore::sendPending(nodeIds, send) owns the send loop; the
  firmware passes a lambda around RFM69::send()

Default builds are byte-identical; a two-unit rf build is 4 bytes smaller.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…loads

19 more native cases on the functions the previous commit made
testable, still without stubs:

- overridePinOf() / localLoadsMask() / remoteLoadsMask() on mixed maps,
  and fed through the real OverridePins as config.h does
- Load::isValidMap(): serial and missing pins, a bad status LED, and the
  unused_pin sentinel that decodes as a remote load
- areValidNodeIds(): range, duplicates, the router's own ID, a short table
- RemoteLoadCore::sendPending() with the radio replaced by a recording
  callable: unit n goes to REMOTE_NODE_ID[n-1], only due units are sent,
  what was sent is drained, the refresh reaches every unit

Reversing the unit-to-node-ID mapping in sendPending() fails three of them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
updateLoads() took the load map as a function argument and scanned it
at run time: per load, extract the unit, index two per-unit scratch
arrays, and shift a bit into place with a variable-count loop. For six
loads over three units that came to ~430 cycles (~27 us) in the ISR
call that starts each mains cycle, and the scratch arrays gave the
whole ISR a stack frame, +13 cycles on every one of its 9.6 kHz calls.
dev's single-unit loop was well under 100 cycles.

The map is now a template argument, updateLoads< physicalLoadPin >(),
and each unit's payload is built by compile-time recursion over the
map, so every load's unit and bit are constants. What remains at run
time is, per remote load, one state test and one OR into a register.

Same six-load, three-unit config, from the disassembly:
- payloads + scheduling: ~430 -> ~125 cycles (~8 us), worst case
  (8 remote loads) ~155 cycles
- stack frame gone: 0 cycles added to the other ISR calls
- flash -180 bytes (-96 for a two-unit build); default builds unchanged

The tests pass their maps as template arguments too, so local maps
become static constexpr. utils_bits.h is no longer needed by the core.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nits

Conflict resolution:
- main.cpp: getDualTariffForcingBitmask() keeps both changes - the local and
  remote masks from this branch, and the bOffPeak parameter from #166 (the
  function no longer keeps its own stale copy of the off-peak pin state).
- build.yml: the native tests run in a single job since #167, so
  test_remote_loads needs no matrix entry.
- utils.h: header date only.
FredM67 and others added 2 commits September 30, 2026 21:28
The remote-loads patch now uses the Load::remote() load map: a fourth load is
added, and loads 3 and 4 go to remote units 1 and 2 (RF nodes 15 and 16).
remote_step gets a third surplus stage where unit 2 regulates while unit 1
stays fully on; remote_override forces both units on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nits

Brings in the non-blocking serial output (#172). The two-unit RF scenarios
now require refresh frames at most 0.15 s apart for both nodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FredM67 and others added 2 commits September 30, 2026 23:15
…nits

Brings in the receiver firmware in grid_sim (#173). Both remote units now
run the RemoteLoadReceiver firmware in check-rf (RX_NODES 15 16):
- link_loss: unit 16 keeps its frames and its load while unit 15's are lost
- lossy_link: 10 % frame loss instead of 20 %. With a 100 ms refresh, 4 frames
  lost in a row already reach the 500 ms timeout; at 20 %, unit 16 lost its
  link once in 30 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@FredM67
FredM67 merged commit 03fca90 into dev Sep 30, 2026
10 checks passed
@FredM67
FredM67 deleted the feat/multiple-remote-units branch September 30, 2026 21:43
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