feat: support several remote load units - #160
Merged
Merged
Conversation
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>
This was referenced Sep 23, 2026
…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.
This was referenced Sep 30, 2026
…nits # Conflicts: # Mk2_3phase_RFdatalog_temp/test/README.md
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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #156.
devsupports 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-unitsbranch prototyped the design but was never compiled (it sat behind an#ifdefnobody defined). This is a port-forward of its delta with its defects fixed, not a rebase.The load map
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_UNITSandREMOTE_LOADS_PRESENTare derived from the map by aconstexprcensus, so they can no longer contradict it — the prototype shipped withNO_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 everyREMOTE_REFRESH_CYCLESmains 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 receiverloadPins[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 aroundRFM69::send(). The pending flag shared with the ISR isvolatile, like the other ISR-shared flags inshared_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 onlyREMOTE_NODE_IDand the load list.Bug fixes that fall out of the rewrite
getDualTariffForcingBitmask()read out of bounds — it counted down fromNO_OF_DUMPLOADSwhile indexing an array sizedNO_OF_DUMPLOADS - NO_OF_REMOTE_LOADS, wheneverDUAL_TARIFFand remote loads were both enabled.countLoadON[]ignored remote loads — sizedNO_OF_DUMPLOADSbut only incremented on the local path, so remote loads always datalogged 0.New validation
0xFF— theunused_pinsentinel — 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, aREMOTE_NODE_IDtable 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:LOAD(),ALL_LOADS()), fed through the realOverridePins;validation.h;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 thecountLoadONmove inside the ISR path.Verification
basic,basic_debug,emonesp,rfbuild with no-Wtype-limitswarnings; 170/170 native cases pass; CI green, embedded tests (simavr) and grid simulation included..init_arrayis absent from every build;remoteLoadsandRFM69::are absent frombasic.rfconfig compiles;remoteLoadsis 8 constant-initialized.bssbytes.Flash:
basicis +6 bytes againstdev, not byte-identical as intended — aprintConfiguration()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:
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 withconfigs/remote_loads.patch: 4 loads, 2 local and 1 on each of two remote units (nodes 15 and 16). Each remote unit runs the realRemoteLoadReceiverfirmware in its own simulated AVR, over the RFM69 model, on the same clock as the router.remote_stepremote_overridelink_losslossy_linkThe 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 toEnergy::contribution()and thecal / ntable, which also removes its extra ISR cost.🤖 Generated with Claude Code