fix(cx2): checksum NNSE packets over their declared length (#2)

Long CX II operations — listing a folder with many entries, downloading a
file of any size — failed with a reported `Busy`.

readPacket checksummed `transferred`, the byte count libusb returned, rather
than `completeLength`, the length the packet declares. The calculator can
hand back more bytes than the packet occupies (a 64-byte packet arriving as
65 was what showed up in tracing), and the stray byte failed the checksum.
The packet was dropped and so never acked, the calculator retransmitted the
same sequence number indefinitely, and the receive loop gave up after 40
reads. `transferred` was also wrong after the multi-chunk continuation loop,
where it holds only the final chunk's size — the reason large stream
transfers were unreliable.

Also add NNSE retransmission to packet_send_cx2. A lost packet or lost ack
was previously fatal, since the packet was written exactly once. Retries now
carry the "not the first try" bit (reqAck bit 3) that TI's own stack sets,
keeping the same seqno so the calculator can drop a duplicate. This path did
not trigger in testing — every packet acked on the first attempt once the
checksum was fixed — so its timeouts are deliberately generous to avoid
retransmitting during slow flash writes.

While here, zero `misc` and `unknown` instead of sending malloc garbage
inside a checksummed header, and handle allocation failure.

Verified against CX II firmware (0451:e022): `ls /` returns all 51 entries
across 8 runs, a 193 KB download is byte-identical across two runs, and
mkdir/rmdir round-trips.

Note the error was never actually Busy. The libnspire crate (0.2.3) matches
its positive NSPIRE_ERR_* constants against libnspire's negative return
codes, so every error falls through to the libusb table and is mislabelled;
-NSPIRE_ERR_INVALPKT and LIBUSB_ERROR_BUSY are both -6. That mismapping is
upstream in the crate and is not addressed here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Josip Jureta 2026-07-22 16:35:23 -04:00 committed by GitHub
parent f6410b5591
commit a0d955d3e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 83 additions and 31 deletions

View file

@ -93,7 +93,7 @@ The same binary works without a GUI:
`--appimage-extract` and launch `squashfs-root/AppRun` instead.
- **`vendor/libnspire-sys`** at the repo root is a locally patched crate wired in
via `[patch.crates-io]`. It raises the CX II NNSE handshake retry limit
(10 → 30). The build will not reproduce upstream behaviour without it.
- **Known upstream limitation:** libnspire's CX II support has been frozen since
2020. Against current CX II firmware, data transfers may return `Busy` or hang.
This is independent of the Tauri version and is not a defect in this port.
(10 → 30), fixes the `readPacket` checksum length bug that made CX II
transfers fail with a reported `Busy`, and adds NNSE retransmission. The build
will not reproduce upstream behaviour without it. See
[`nlink-cli/README.md`](../nlink-cli/README.md) for the details.

View file

@ -41,11 +41,25 @@ cargo build --release
tells it to leave TI devices alone. Restart it after adding the rule:
`sudo systemctl start ModemManager`.
## Known limitation
libnspire's CX II ("NavNet SE") protocol support has been **frozen upstream since 2020**
(`lights0123/libnspire`, last commit 2020-09-30; crates.io `libnspire-sys` 0.3.4 is the newest).
Against **current (2026) CX II firmware** the USB link and handshake work — real filenames
are transferred — but **multi-packet stream transfers are unreliable** and long operations
(e.g. listing a folder with many entries) can fail mid-transfer with `Busy`. Short transfers
are more likely to complete. This is a library/firmware-era mismatch, not a local
configuration problem.
## The `Busy` error on CX II (fixed)
Long operations against current CX II firmware — listing a folder with many entries,
downloading a file of any size — used to fail with `Busy`. Two bugs in the vendored
libnspire, both fixed in `vendor/libnspire-sys/libnspire/src/cx2.cpp`:
1. **`readPacket` checksummed the wrong number of bytes.** It used the byte count the
bulk transfer returned instead of the length the packet declares. The calculator can
hand back more bytes than the packet occupies (a 64-byte packet arriving as 65), and
the stray byte failed the checksum. The packet was then dropped and never acked, so
the calculator retransmitted it forever until the receive loop gave up. The same
variable was also wrong after a multi-chunk read, where it held only the last chunk's
size — this is what made large stream transfers unreliable.
2. **No retransmission.** A lost packet or lost ack was fatal; the packet was sent once
and never repeated. It now retransmits with the "not the first try" bit (`reqAck` bit
3) that TI's own stack uses, keeping the same sequence number.
Note that the error was never really `Busy`: the `libnspire` **crate** (0.2.3, not this
vendored C library) matches its positive `NSPIRE_ERR_*` constants against libnspire's
negative return codes, so every error falls through to the libusb table and is
mislabelled. `-NSPIRE_ERR_INVALPKT` is `-6`, and so is `LIBUSB_ERROR_BUSY`. A reported
`Busy` is really `Invalid packet received`; `Timeout` is really `NACK received`, and so
on. That mismapping is upstream in the crate and is **not** fixed here.

View file

@ -48,6 +48,18 @@ int gettimeofday(struct timeval *t, void *timezone) {
// Windows...
#undef min
/* Default timeout for reads that are just waiting for the calculator to say
something on its own schedule. */
#define NSP_CX2_READ_TIMEOUT 60000
/* How long to wait for an ack before assuming the packet (or its ack) was
lost and sending it again. Kept generous: slow operations (flash writes,
OS install) can legitimately take a while to be acknowledged, and a
needless retransmission is more expensive than a little patience. */
#define NSP_CX2_ACK_TIMEOUT 2000
/* Total time to keep retransmitting one packet before giving up with BUSY.
TI's own stack derives its retry count the same way, as total/ack. */
#define NSP_CX2_ACK_TOTAL_TIMEOUT 60000
enum Address {
AddrAll = 0xFF,
AddrMe = 0xFE,
@ -155,14 +167,14 @@ static uint16_t compute_checksum(const uint8_t *data, uint32_t size)
return acc;
}
static bool readPacket(libusb_device_handle *handle, NNSEMessage *message, int maxlen)
static bool readPacket(libusb_device_handle *handle, NNSEMessage *message, int maxlen, unsigned int timeout = NSP_CX2_READ_TIMEOUT)
{
if(maxlen < sizeof(NNSEMessage))
return false;
int transferred = 0;
memset(message, 0, sizeof(NNSEMessage));
int r = libusb_bulk_transfer(handle, 0x81, reinterpret_cast<unsigned char*>(message), maxlen, &transferred, 60000);
int r = libusb_bulk_transfer(handle, 0x81, reinterpret_cast<unsigned char*>(message), maxlen, &transferred, timeout);
if(r < 0
|| transferred < sizeof(NNSEMessage))
@ -191,7 +203,11 @@ static bool readPacket(libusb_device_handle *handle, NNSEMessage *message, int m
dumpPacket(message);
#endif
if(compute_checksum(reinterpret_cast<uint8_t*>(message), transferred) != 0xFFFF)
/* Checksum over the length the packet declares, not over what the bulk
transfer happened to hand back. The calculator can return more bytes
than the packet occupies, and after the continuation loop above
`transferred` only holds the size of the final chunk anyway. */
if(compute_checksum(reinterpret_cast<uint8_t*>(message), completeLength) != 0xFFFF)
return false;
return true;
@ -409,7 +425,13 @@ int packet_send_cx2(struct nspire_handle *nsp_handle, char *data, int size)
int len = sizeof(NNSEMessage) + size;
NNSEMessage *msg = reinterpret_cast<NNSEMessage*>(malloc(len));
if(!msg)
return -NSPIRE_ERR_NOMEM;
/* misc and unknown are covered by the checksum, so they have to be set to
something deterministic rather than left as malloc garbage. */
msg->misc = 0;
msg->unknown = 0;
msg->service = StreamService;
msg->src = AddrMe;
msg->dest = AddrCalc;
@ -419,34 +441,50 @@ int packet_send_cx2(struct nspire_handle *nsp_handle, char *data, int size)
memcpy(getPacketData(msg), data, size);
int ret = -NSPIRE_ERR_SUCCESS;
if(!writePacket(handle, msg))
ret = -NSPIRE_ERR_BUSY;
else
const int maxlen = sizeof(NNSEMessage) + 1472;
NNSEMessage * const message = reinterpret_cast<NNSEMessage*>(malloc(maxlen));
if(!message)
{
const int maxlen = sizeof(NNSEMessage) + 1472;
NNSEMessage * const message = reinterpret_cast<NNSEMessage*>(malloc(maxlen));
free(msg);
return -NSPIRE_ERR_NOMEM;
}
bool acked = false;
for(int i = 40; i-- && !ret && !acked;)
/* Either the packet or its ack can go missing, and on current CX II
firmware that happens often enough that a single attempt makes long
transfers (a large directory listing, say) fail routinely. Retransmit
the same seqno until it is acked, which is what TI's own stack does. */
const int attempts = NSP_CX2_ACK_TOTAL_TIMEOUT / NSP_CX2_ACK_TIMEOUT;
bool acked = false;
for(int attempt = 0; attempt < attempts && !acked; attempt++)
{
/* Bit 3 marks a retransmission, so the calculator can drop the
duplicate if it did receive the original and only the ack was
lost. The seqno deliberately stays the same. */
msg->reqAck = attempt ? 0x9 : 0x1;
if(!writePacket(handle, msg))
continue;
/* Service whatever arrives until the ack shows up or we run out of
patience and send again. */
while(readPacket(handle, message, maxlen, NSP_CX2_ACK_TIMEOUT))
{
if(!readPacket(handle, message, maxlen))
continue;
handlePacket(nsp_handle, message);
if(message->dest == AddrMe
&& message->service == (StreamService | AckFlag)
&& message->seqno == msg->seqno)
{
acked = true;
break;
}
}
if(!acked)
ret = -NSPIRE_ERR_BUSY;
free(message);
}
int ret = acked ? -NSPIRE_ERR_SUCCESS : -NSPIRE_ERR_BUSY;
free(message);
free(msg);
return ret;