Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/firmware-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ abstraction hides, ADR-0002 needs revisiting rather than patching.
|---|---|
| `startSendRaw` | Enqueue frame to the RF engine with node id and sim time. Return false if already transmitting. |
| `isSendComplete` | True once sim time ≥ start + airtime. |
| `recvRaw` | Pop from this node's delivered queue. **Only CRC-passing frames reach here** — everything else is recorded but withheld, exactly like real hardware. |
| `recvRaw` | Pop from this node's delivered queue. **Only CRC-passing frames reach here.** A frame the receiver locked onto and lost reaches the chip as a header error or a CRC error, as the part reports it; one it could detect and never locked onto is recorded and withheld, and the chip is told so it can take back the header it guessed at. |
| `getEstAirtimeFor` | Must equal our own airtime formula. Test this first. |
| `getNoiseFloor` | Real value from the channel, including external emitters. |
| `setCADEnabled` / CAD result | Answer from actual channel energy, not a guess. This is where firmware CSMA becomes observable. |
Expand Down
50 changes: 50 additions & 0 deletions docs/shortcomings.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,56 @@ the same pair and puts it in the status line, the sweep panel prints it beside
the cost estimate before the run starts, and the results panel repeats it above
the table. Use native for anything being compared.

### 3.2b A failed frame reaches the chip from interference, and from nothing else

A frame a receiver locked onto and lost reaches the firmware's chip as the part
reports it: a header it could not read raises `HeaderErr` and nothing more, and
a header that read with a payload that did not raises `RxDone` beside `CrcErr`,
with the corrupt bytes in the buffer. In calculated mode the engine decides
which from where a collision landed: more than one destroyed symbol inside
the eight-symbol header block, which is coded at CR 4/8, is a header error, and
anything else that is lost is a CRC error. In waveform mode the demodulator
decides.

A frame the receiver could detect and never locked onto is reported too, as
the third case. In calculated mode that is a frame swamped by an interferer it
could not capture over that arrived while the preamble was still to be found;
in waveform mode it is any frame the demodulator found no preamble lock in.
The part raises nothing for it, so the chip model withdraws the `HeaderValid`
it raised from how long the carrier lasted, and leaves `PreambleDetected` set.

Native builds with strict receive keep the detection flags as the part does:
`PreambleDetected` and `HeaderValid` stay set until the firmware clears them,
and a frame the receiver was not listening for from its preamble to its end is
not received, so a re-arm or a CAD scan part-way through a frame loses it.

Only a node that says it takes each message is told: a native build whose
bridge sets the capability bits in its radio stats record. Emulated boards, and
native builds from before those bits, are told nothing, and their chip clears
the detection flags when the carrier ends, as before.

Four things are still kinder than the part:

- **Only interference fails a frame.** A signal near the floor that the chip
would lock onto and then lose is, in calculated mode, simply not heard. Real
radios raise header and CRC errors on weak signals all the time.
- **The failure arrives when the packet ends.** The calculated path judges a
reception once the whole packet is on the air, so `HeaderErr` is raised at the
end of the frame rather than the end of its header.
- **`HeaderValid` is raised from the carrier, not from a decoded header.** The
chip model cannot decode, so it raises the flag once a carrier has lasted as
long as a header takes, and only withdraws it when the frame ends without a
lock. During such a frame a firmware sees a header where the part would show
only a preamble, and holds off transmitting for longer than it would on air.
- **The processor takes no time.** A native node runs its loop in no simulated
time, so a firmware that is slow to collect a packet loses nothing to the
delay, where on a real board the next frame can arrive in the meantime.

**Consequence.** A firmware's error paths now run, and on native builds with
strict receive a fault in how it handles a stale `PreambleDetected`, or a
receiver re-armed mid-frame, can be seen. A fault that needs the processor to be
slow cannot, yet.

### 3.3 Native nodes cannot catch anything architecture-dependent

A native node is x86-64 or arm64, 64-bit, with a different `int` width in
Expand Down
69 changes: 67 additions & 2 deletions internal/firmware/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,43 @@ const (
// it - only the engine has every waveform in flight and how strongly each
// one arrives at a given antenna.
kindChannelBusy = 0x08

// kindFrameFailed is host → node: a frame this receiver locked onto and
// could not trust. The first payload byte says where it failed, the rest is
// the frame as it arrived. A real SX1262 says so - HeaderErr for a header
// it could not read, RxDone beside CrcErr for a payload it could not - and
// a firmware that never hears either has error paths nothing exercises.
// Sent only to a node whose radio stats record carries capFrameFailed: a
// native bridge built before this kind ends its connection on a kind it
// does not know, so a node that never says it understands is never sent it
// and sees what it always saw - nothing.
kindFrameFailed = 0x0A

// capFrameFailed is the bit in byte 47 of the radio stats record by which a
// node says it takes kindFrameFailed.
capFrameFailed = 0x01
// capNoLock is the bit by which it says its chip reads FailedNoLock. A chip
// older than that reads the code as a CRC error and would raise RxDone for
// a frame it never locked onto, so the code goes only where this is set.
capNoLock = 0x02
)

// FrameFailure is where a frame the receiver locked onto went wrong, numbered
// as virtual-sx1262's vsx_deliver_frame_failed numbers it.
type FrameFailure byte

const (
// FailedCRC is a header that decoded and a payload that did not: the chip
// raises RxDone beside CrcErr and the corrupt bytes are in its buffer.
FailedCRC FrameFailure = 1
// FailedHeader is a header the receiver could not read: the chip raises
// HeaderErr, and no RxDone follows.
FailedHeader FrameFailure = 2
// FailedNoLock is a frame the receiver could detect and never locked onto.
// The part raises nothing for it and leaves PreambleDetected latched; the
// chip model withdraws the HeaderValid it raised from the carrier's length,
// because that was its stand-in for a header it cannot decode.
FailedNoLock FrameFailure = 3
)

// ErrClosed is returned once a bridge has been shut down.
Expand Down Expand Up @@ -100,6 +137,12 @@ type Bridge struct {
// stats is the radio's own account of what it was asked.
stats RadioStats

// takesFailed is whether the node has said it understands kindFrameFailed.
// A native bridge built before that kind closes its connection on any kind
// it does not know, so it is sent only to a node that has asked for it.
takesFailed bool
takesNoLock bool

// done is closed when the bridge is. Anything waiting on the node must
// select on it: a node whose process has gone will never ack, and a wait
// with no way to hear about that hangs for as long as its deadline allows -
Expand Down Expand Up @@ -255,6 +298,8 @@ func (b *Bridge) read(c net.Conn) {
}
b.mu.Lock()
b.stats = st
b.takesFailed = n >= 48 && buf[47]&capFrameFailed != 0
b.takesNoLock = n >= 48 && buf[47]&capNoLock != 0
b.mu.Unlock()
}

Expand All @@ -274,15 +319,35 @@ func (b *Bridge) read(c net.Conn) {
}

// Deliver hands a frame to the emulated firmware — a frame the channel decided
// this node received. Only CRC-passing frames should be delivered; everything
// else belongs in the ledger and is withheld, exactly as on real hardware.
// this node received. Only CRC-passing frames go this way; a frame the receiver
// locked onto and could not trust goes through DeliverFailed, and one it never
// locked onto reaches the firmware not at all, exactly as on real hardware.
func (b *Bridge) Deliver(frame []byte) error {
if err := b.send(kindFrame, frame); err != nil {
return fmt.Errorf("firmware: deliver: %w", err)
}
return nil
}

// DeliverFailed tells the firmware's chip that a frame it locked onto failed,
// and where. Nothing is decoded from it; the frame rides along because a CRC
// failure leaves the corrupt bytes in the chip's buffer, as the part does.
//
// A node that has not said it takes the kind is told nothing, which is what it
// was always told.
func (b *Bridge) DeliverFailed(frame []byte, why FrameFailure) error {
b.mu.Lock()
takes := b.takesFailed && (why != FailedNoLock || b.takesNoLock)
b.mu.Unlock()
if !takes {
return nil
}
if err := b.send(kindFrameFailed, append([]byte{byte(why)}, frame...)); err != nil {
return fmt.Errorf("firmware: deliver failed frame: %w", err)
}
return nil
}

func (b *Bridge) send(kind byte, payload []byte) error {
b.mu.Lock()
c, closed := b.conn, b.closed
Expand Down
115 changes: 115 additions & 0 deletions internal/firmware/bridge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,121 @@ func TestDeliveryReachesTheEmulator(t *testing.T) {
}
}

// A frame the receiver locked onto and lost reaches the chip as its own kind,
// with where it failed in front of the bytes, so the chip can raise HeaderErr
// or CrcErr rather than nothing - but only once the node has said it takes
// that kind. A native bridge built before it ends its connection on a kind it
// does not know, so a node that never says so is sent nothing.
func TestAFailedFrameReachesOnlyANodeThatTakesIt(t *testing.T) {
b, _ := Listen("127.0.0.1:0", "GB7XYZ")
defer func() { _ = b.Close() }()
c := fakeRadio(t, b.Addr())
defer func() { _ = c.Close() }()

for i := 0; i < 50 && !b.Attached(); i++ {
time.Sleep(10 * time.Millisecond)
}
if err := b.DeliverFailed([]byte{0x11}, FailedCRC); err != nil {
t.Fatal(err)
}

record := make([]byte, 48)
record[47] = capFrameFailed
if _, err := c.Write(append([]byte{kindRadioStats, 0, 48}, record...)); err != nil {
t.Fatal(err)
}
for i := 0; i < 100; i++ {
b.mu.Lock()
takes := b.takesFailed
b.mu.Unlock()
if takes {
break
}
time.Sleep(5 * time.Millisecond)
}
if err := b.DeliverFailed([]byte{0x22, 0xBB}, FailedHeader); err != nil {
t.Fatal(err)
}

var hdr [3]byte
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
if _, err := io.ReadFull(c, hdr[:]); err != nil {
t.Fatal(err)
}
if hdr[0] != kindFrameFailed {
t.Fatalf("wrong frame kind 0x%02x, want 0x0a", hdr[0])
}
buf := make([]byte, binary.BigEndian.Uint16(hdr[1:]))
if _, err := io.ReadFull(c, buf); err != nil {
t.Fatal(err)
}
if string(buf) != string([]byte{2, 0x22, 0xBB}) {
t.Errorf("emulator received % x, want 02 22 bb: the failure sent before it said it takes the kind leaked", buf)
}
}

// A chip older than ABI 1.7 reads failure 3 as a CRC error and would raise
// RxDone for a frame it never locked onto, so a node that takes failed frames
// but has not said its chip reads that code is never sent it.
func TestANeverLockedFrameReachesOnlyAChipThatReadsIt(t *testing.T) {
b, _ := Listen("127.0.0.1:0", "GB7XYZ")
defer func() { _ = b.Close() }()
c := fakeRadio(t, b.Addr())
defer func() { _ = c.Close() }()

for i := 0; i < 50 && !b.Attached(); i++ {
time.Sleep(10 * time.Millisecond)
}
say := func(caps byte, want func() bool) {
record := make([]byte, 48)
record[47] = caps
if _, err := c.Write(append([]byte{kindRadioStats, 0, 48}, record...)); err != nil {
t.Fatal(err)
}
for i := 0; i < 100; i++ {
b.mu.Lock()
ok := want()
b.mu.Unlock()
if ok {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("the bridge never read capability byte 0x%02x", caps)
}
read := func() []byte {
var hdr [3]byte
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
if _, err := io.ReadFull(c, hdr[:]); err != nil {
t.Fatal(err)
}
buf := make([]byte, binary.BigEndian.Uint16(hdr[1:]))
if _, err := io.ReadFull(c, buf); err != nil {
t.Fatal(err)
}
return buf
}

say(capFrameFailed, func() bool { return b.takesFailed })
if err := b.DeliverFailed([]byte{0x11}, FailedNoLock); err != nil {
t.Fatal(err)
}
if err := b.DeliverFailed([]byte{0x22}, FailedHeader); err != nil {
t.Fatal(err)
}
if got := read(); string(got) != string([]byte{2, 0x22}) {
t.Fatalf("received % x, want 02 22: a chip that never said it reads failure 3 was sent it", got)
}

say(capFrameFailed|capNoLock, func() bool { return b.takesNoLock })
if err := b.DeliverFailed([]byte{0x33}, FailedNoLock); err != nil {
t.Fatal(err)
}
if got := read(); string(got) != string([]byte{3, 0x33}) {
t.Errorf("received % x, want 03 33", got)
}
}

// The point of the bridge: an emulated node's frame goes through the SAME
// channel as a native one. If it took a different path the two backends could
// not be compared, which is the whole reason ADR-0010 keeps both.
Expand Down
Loading
Loading