Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
97 commits
Select commit Hold shift + click to select a range
773c65c
PROTOTYPE: unsolicited pod-fault listener (stage 1: capture + decrypt…
ps2 Jun 30, 2026
5f5977a
FIELD TEST: default the unsolicited listener ON (revert before merge)
ps2 Jun 30, 2026
88ccec8
Fix listener false-triggering during session negotiation (caused disc…
ps2 Jun 30, 2026
e464244
PROTOTYPE stage 2: register periodic status push + commit push nonce
ps2 Jul 1, 2026
e01a0dc
Gate periodic registration to a healthy, set-up, non-faulted pod
ps2 Jul 1, 2026
feef537
Instrument periodic-status registration + push for a conclusive field…
ps2 Jul 1, 2026
03fab7d
Disable SN0.0= registration — it disconnect-loops a live pod
ps2 Jul 1, 2026
2df6c00
Send SN0.0= as a standard SLPE command, not plain-ASCII AID (+ non-di…
ps2 Jul 1, 2026
13670ed
Instrument BluetoothManager instance lifecycle to pin the multi-centr…
ps2 Jul 1, 2026
cb119e8
Add per-instance ID to OmnipodKitPlugin to settle real-vs-duplicated …
ps2 Jul 1, 2026
b613284
Periodic registration: fire-and-forget (no synchronous read) + log al…
ps2 Jul 1, 2026
7804b39
Advertisement monitor: continuous duplicate scan, full [ADV] logging,…
ps2 Jul 2, 2026
ae48d99
Connect-on-demand: 'normally disconnected' pump connectivity (field t…
ps2 Jul 2, 2026
13a96fa
Connect-on-demand: don't bail early on disconnected pod in bleRunSession
ps2 Jul 2, 2026
eb630f7
Add §5 beacon-capture mode: wildcard scan + [BEACON] raw-field logging
ps2 Jul 2, 2026
5e38237
Connect-on-demand: adopt PeripheralManager while disconnected (fix po…
ps2 Jul 2, 2026
ad2e375
Connect-on-demand: runCommand allowDisconnected for the connect itself
ps2 Jul 2, 2026
815f65e
Connect-on-demand: stop scanning during the connect (allowDuplicates …
ps2 Jul 2, 2026
4694f59
Add debug Trigger-Test-Alert to fire a non-fault pod alert for §5 bea…
ps2 Jul 2, 2026
6c72acf
DASH alert findings writeup + prototype connectionless alert detector
ps2 Jul 2, 2026
5d5140e
Connect-on-demand (c): light scan during connect to cut the ~11-14s l…
ps2 Jul 2, 2026
91a5158
Low-power fault-watch (option 3): scan filtered on the alarm service …
ps2 Jul 2, 2026
feeb7a7
Reconciliation capture: wildcard idle scan + advert cadence + candida…
ps2 Jul 2, 2026
1503dfc
Add suppressCommands measurement mode for a clean uninterrupted idle …
ps2 Jul 2, 2026
16adc30
Experiment: delayed-connect probe (CBConnectPeripheralOptionStartDela…
ps2 Jul 2, 2026
2c679ae
delayed-connect probe: 5min delay, log PID, and write to Loop's persi…
ps2 Jul 2, 2026
938121d
Properly handle and display times for DASH reset type pod faults
itsmojo Jul 2, 2026
d3462c1
delayed-connect probe: self-sustaining loop (re-arm in didDisconnect,…
ps2 Jul 3, 2026
050d3f8
delayed-connect probe: everFg + app lifecycle logging to distinguish …
ps2 Jul 3, 2026
4639eda
Document delayed-connect heartbeat finding; switch defaults to connec…
ps2 Jul 3, 2026
e6d9587
Gate the delayed-connect heartbeat on setMustProvideBLEHeartbeat
ps2 Jul 3, 2026
237930f
Log setMustProvideBLEHeartbeat at the call site
ps2 Jul 3, 2026
e336b94
connect-on-demand: go fully dark for the connect (helper scan was sta…
ps2 Jul 3, 2026
ad82b5f
connect-on-demand: fresh-discovery connect to cut latency (~16s -> ~1…
ps2 Jul 3, 2026
24c3a90
Add poddev.md handoff summary for a fresh agent
ps2 Jul 3, 2026
2812cb2
connect-on-demand: defer fresh-discovery connect a tick after stopScan
ps2 Jul 3, 2026
fbbcbfe
connect-on-demand: flush stale connection state before reconnect
ps2 Jul 3, 2026
d81cd9b
connect-on-demand: scan-free connect (measure plain connect latency)
ps2 Jul 3, 2026
69aee53
connect-on-demand: drive CBCentralManager only on managerQueue
ps2 Jul 3, 2026
b5b9556
Merge pull request #103 from itsmojo/Dash-reset-pod-fault-fix
itsmojo Jul 6, 2026
b347697
Properly handle and display times for DASH reset type pod faults
itsmojo Jul 2, 2026
ea6acdf
Merge pull request #104 from loopandlearn/hotfix-dev-DASH-reset-faults
marionbarker Jul 6, 2026
2f8fdea
heartbeat: serialize delayed-connect probe with commands; fire the he…
ps2 Jul 6, 2026
a9e4682
heartbeat: log probe-fire/command-preempt/pumpManagerBLEHeartbeatDidF…
ps2 Jul 7, 2026
f8b4b62
connect-on-demand: fresh-discovery for command connects (fast user co…
ps2 Jul 7, 2026
ee9f382
connect-on-demand: direct connect on fresh advert + device-log the fr…
ps2 Jul 7, 2026
d8f43b7
connect-on-demand: foreground keep-alive (live connection-gated UI, i…
ps2 Jul 7, 2026
5d3c9c9
fault-listener: enable alarm-filtered idle scan + device-log POD-ALER…
ps2 Jul 7, 2026
95868b0
fault-listener: coexist the alarm scan with the heartbeat probe
ps2 Jul 7, 2026
5cd74e7
fault-listener: decode advert alert byte into AlertSet slot names
ps2 Jul 7, 2026
f17a706
fault-listener: compute alert transition on the firing byte (fix miss…
ps2 Jul 7, 2026
5dd3a15
fault-listener stage 2: surface a connectionless-detected alert to Loop
ps2 Jul 7, 2026
54193a3
fault-listener: quiet re-wakes while an alert is active
ps2 Jul 7, 2026
bb15530
TEMP fault-capture build: wildcard scan + device-log full adverts (re…
ps2 Jul 7, 2026
958ac19
fault-listener: detect pod faults from the advertisement (b2 fault co…
ps2 Jul 7, 2026
83a5da5
fix: pairing broken by alarm-scan filter — discovery mode must scan t…
ps2 Jul 7, 2026
784ae47
fix pairing: suppress heartbeat probe during discovery + log the pair…
ps2 Jul 7, 2026
07e83a4
fix pairing: no heartbeat probe without an active pod + stop scan for…
ps2 Jul 7, 2026
b6ba0cd
prune discarded pod from devices[] on disconnectFromDevice
ps2 Jul 7, 2026
9a522a6
fault: report the incomplete dose to Loop immediately (not ~30s later)
ps2 Jul 7, 2026
f91d298
Merge loopandlearn/OmnipodKit main into unsolicited-fault-listener-pr…
ps2 Jul 8, 2026
05f972d
EXPERIMENT (revert after): suppress heartbeat probe to test if StartD…
ps2 Jul 8, 2026
3c67725
EXPERIMENT (revert after): test alert fires ~5min out so it lands in …
ps2 Jul 8, 2026
dc62e2d
revert probe-suppression experiment: probe is the background wake, no…
ps2 Jul 8, 2026
f0ef35b
ALERT-ADVERT CAPTURE (revert after): re-enable full-advert capture to…
ps2 Jul 8, 2026
27d7d3b
FAULT-WAKE GROUND-TRUTH TEST (revert after): probe OFF, alarm scan only
ps2 Jul 8, 2026
27adbdb
fault-wake CONFIRMED in deep idle (probe off); switch to probe-ON to …
ps2 Jul 8, 2026
728a266
EXPERIMENT (revert after): C00A-only fault scan + probe off — test fr…
ps2 Jul 9, 2026
6aa96e9
adopt C00A-only fault scan as production design (fast connectionless …
ps2 Jul 9, 2026
bc8a118
FAULT-TYPE CAPTURE (revert after): wildcard capture to see a non-occl…
ps2 Jul 9, 2026
5cdafaa
field advert logging: device-log distinct pod adverts in production (…
ps2 Jul 9, 2026
1592781
fix: deactivating a faulted pod could deadlock forever (bound store()…
ps2 Jul 9, 2026
2eef202
cleanup 1/n: connect-delay probe driven purely by Loop's heartbeat re…
ps2 Jul 9, 2026
58745cb
cleanup 2/n: remove field-test scaffolding
ps2 Jul 9, 2026
1223ccc
cleanup 3/n: update flag docs to the shipped design (comments only)
ps2 Jul 9, 2026
61a8376
cleanup 4/n: drop init stack-trace dump; ship unsolicited-listener di…
ps2 Jul 9, 2026
5d206d0
cleanup 5/n: sweep leftover experiment/prototype comments to the ship…
ps2 Jul 9, 2026
ffcd798
Merge loopandlearn/dev (O5 support) into o5-integration
ps2 Jul 9, 2026
9e9cd23
Merge origin/loop-next-dev (4727535) into o5-integration
ps2 Jul 12, 2026
1d434c6
FULL BLE CAPTURE (revert before PR): O5-aware advert + all-characteri…
ps2 Jul 12, 2026
011bfcf
capture: never subscribe the pod's command/data/heartbeat chars (fixe…
ps2 Jul 12, 2026
42ee135
capture: re-add Trigger Test Alert (DASH + O5) to drive an alarm-stat…
ps2 Jul 12, 2026
8ed36cc
test: arm connected-state periodic-status nudge (SN0.0=30) + stay con…
ps2 Jul 12, 2026
d37ae40
periodic-status test: device-log arming, stop churn, skip O5 (needs A…
ps2 Jul 12, 2026
d7210c2
periodic-status: use SN2.0=<seconds> (RE engineer) for DASH+O5; stay …
ps2 Jul 13, 2026
242052f
periodic-status test: listen for adverts after arm (drop keep-alive)
ps2 Jul 13, 2026
0754142
Reading-scheduled StartDelay heartbeat + foreground keep-alive connec…
ps2 Jul 15, 2026
381a6b5
Fix StartDelay heartbeat probe: integer delay, failure backoff, foreg…
ps2 Jul 17, 2026
b1ecb27
Add O5 advertising / fault-signalling field findings
ps2 Jul 17, 2026
9c1481d
Remove BLE capture / periodic-status / test-alert debug scaffolding
ps2 Jul 17, 2026
798c788
Gate DASH connectionless fault detection to our own pod
ps2 Jul 18, 2026
fdafb4a
Respect Pod Keep Alive: hold the pod connected instead of disconnecting
ps2 Jul 18, 2026
6421781
Remove dead SLPE transport functions
ps2 Jul 18, 2026
bc3484f
Drive the BLE heartbeat from a timer while the pod is held connected
ps2 Jul 19, 2026
04ccda8
Revert "Drive the BLE heartbeat from a timer while the pod is held co…
ps2 Jul 19, 2026
25d5551
Disconnect promptly in the background so the StartDelay probe re-arms
ps2 Jul 19, 2026
fd38fca
Remove dead O5 heartbeat-characteristic code
ps2 Jul 19, 2026
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
115 changes: 115 additions & 0 deletions DASH_BEACON_FINDINGS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# DASH Advertisement / Alert Signalling — Field Findings

Correction + data addendum to the RE "Omnipod DASH Advertisement (Beacon) Parsing" spec,
from live capture on a real DASH pod (`DD5D6B83…`, address `0x179F0CF1`).

## Test setup
- OmnipodKit driven in a **connect-on-demand / "normally disconnected"** mode: the pod is left
disconnected and advertising; the phone scans continuously (`scanForPeripherals(withServices:
nil, options:[allowDuplicates:true])`, foreground) and connects only to run a command.
- Every pod-adjacent advertisement is logged with all raw fields (`[ADV]` / `[BEACON]`).
- A **non-destructive alert** (expiration reminder, ~60 s out via `configureAlerts`) was triggered
to move the pod into an alerting state, then acknowledged to clear it — no fault, pod reusable.

## Headline correction to the spec
For an **alert**, the pod does **NOT** switch to the non-connectable `CE1F923D-…` 128-bit beacon
described in the spec. It stays **`connectable = 1`** and keeps advertising its **normal 16-bit
service-UUID list**, encoding the alert state in two changing fields:

- the **2nd 16-bit service UUID**, and
- a **4-byte status word inside the manufacturer data**.

The `CE1F923D` beacon is therefore most likely a **fault-only** path (a terminal fault where the
pod goes non-connectable). That was **not** reproduced here (would require sacrificing the pod).

## The normal ↔ alert ↔ cleared diff (measured)

| state | 2nd service UUID | mfg status word |
|---|---|---|
| normal (idle) | `C001` | `00020000` |
| **alert active** (beeping) | `C005` | `000a0008` |
| acknowledged (cleared) | `C001` | `00020000` (reverts) |

Reversible and repeatable. The 2nd UUID and the mfg status word move together.

## Advertisement field layout (observed, DASH)

Service UUIDs (16-bit), example alerting frame:
```
[4024, C005, 000A, 179F, 0CF1, 0859, 1693, 0015, 5E7B]
│ │ │ └──┬──┘ ...other constant fields...
│ │ │ └ 179F,0CF1 = pod address 0x179F0CF1
│ │ └ 000A = constant ("third service")
│ └ 2nd UUID = STATUS/ALERT carrier: C001 (clear) ↔ C005 (alert)
└ 4024 = DASH main/advertisement service (scan-filter target today)
```

Manufacturer data (company ID `0x0360`), normal vs alert:
```
normal: 6003 021595a189ee252a4c7886a37c2a 000a 00020000 f10c bc
alert: 6003 021595a189ee252a4c7886a37c2a 000a 000a0008 f10c bc
└CID └── pod id / serial (const) ──┘ └cst └STATUS┘ └addr└?
```
- `STATUS` = 4-byte word between the constant `000a` and the address tail `f10c`.
- clear `0x00020000` (bit 17) → alert `0x000a0008` (adds bits 3 and 19 on top).
Consistent with the spec's "alarmCode/alertCode as a bit-field."

## Open items (need more pods / a real fault; not tested here)
1. **Per-alert bit mapping** — only one alert type (expiration reminder) was fired. Which
bit/UUID-value corresponds to which specific alert/alarm needs several alert types diffed.
2. **Fault / `CE1F923D` path** — untested; the spec's 128-bit non-connectable beacon is presumed
the fault path. Needs a deliberately faulted (sacrificed) pod.
3. **Background-scan filter UUID** — the alert rides the `4024` advertisement, so today's
`withServices:[4024]` filter would catch alert changes in the background. The `CE1F923D` fault
beacon may need a different/additional filter — confirm when the fault path is captured.

## Practical takeaway for OmnipodKit
Connectionless **alert detection is achievable now**: in the scan callback, read the 2nd 16-bit
service UUID and/or the mfg `STATUS` word and raise an alert to the pump manager when it deviates
from the clear baseline (`C001` / `00020000`) — no connection required. Detail/acknowledge still
needs a (slow, ~11–14 s) on-demand connect, but detection itself is instant.

---

# Background wake & heartbeat: delayed connect + State Restoration

Correction to the RE conclusion "BLE gives you alarm-wake but no periodic wake." A **periodic
background wake IS achievable** — not via scanning (the pod's disconnected advert is stable, so iOS
coalesces it), but via the **connect** path.

## Mechanism
Issue `central.connect(peripheral, options: [CBConnectPeripheralOptionStartDelayKey: N])` while
disconnected. iOS holds the request pending for `N` seconds, then completes it (the pod advertises
~0.77 s, so it connects shortly after the delay). With State Preservation & Restoration (restore ID
`com.OmnipodKit`) the pending connect **survives app termination** and **relaunches** the app when
it completes. Loop: on each `didDisconnect`, immediately re-issue the delayed connect, so there is
always a pending connect that survives suspension — the loop self-sustains.

## Verified on real hardware (15 h continuous run, DD5D6B83, StartDelay=300 s)
- **iOS relaunches the terminated app in the background — proven.** Tagged each connect with
`everFg` (true once the process has ever been foregrounded). Multiple **new PIDs ran with
`everFg=false` for extended periods** — e.g. one process launched via `willRestoreState` and ran
the loop **~1h42m / ~13 cycles without ever being foregrounded.** A brand-new process running
that long unforegrounded can only be iOS launching it (not a manual open — which is what
`willRestoreState` + a new PID alone would NOT prove).
- **Self-sustaining.** After moving the re-arm into `didDisconnect` (see below), the loop ran ~14 h
with no stalls across suspend / jetsam / relaunch.
- **Timing is fuzzy, not a clock.** For StartDelay=300 s: floor ~300 s, typical ~350–550 s,
frequently 600–800 s, occasional spikes to ~1100–1580 s (~26 min) under heavy iOS throttling.
Good for a "phone-home every several minutes" heartbeat; not for anything time-critical.

## Gotchas learned
- **Re-arm in `didDisconnect`, not via `didDiscover`.** Re-arming through a later scan discovery
stalled (observed ~1h43m dead zone) whenever iOS suspended the app between cycles — the re-arm
never ran and no pending connect was left to wake on. Issuing the next delayed connect directly
in `didDisconnect` leaves a pending connect that survives suspension.
- **`willRestoreState` fires on a manual open too** — it does not, by itself, prove an iOS
relaunch. Use the `everFg`/foreground signal to distinguish.
- **Force-quit (swipe-away) disables BLE relaunch** by design — not a valid relaunch test.
- Stop the `allowDuplicates` scan before the connect (it starves connection completion); iOS
reacquires the pod on its own.

## Intended use
Keep this **off by default.** Use the delayed-connect heartbeat only when the hosting app requests
periodic check-ins. Otherwise: stay **disconnected**, run an **alarm-filtered scan** (`[C005]`) for
instant fault wake, and **connect on demand** only to send commands / read status.
94 changes: 94 additions & 0 deletions O5_ADVERTISING_FINDINGS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# O5 (Omnipod 5) Advertisement / Fault Signalling — Field Findings

Companion to `DASH_BEACON_FINDINGS.md`, for the **O5** pod. From live capture on a real O5 pod
(peripheral `636C6109…`, controllerId `0x002A1C6C`), including a **deliberately induced occlusion
fault** (cannula clamped + repeated large boluses; pod sacrificed).

## Advertisement structure (healthy)

O5 advertises a **single 128-bit service UUID** plus 7 bytes of manufacturer data:

```
svcUUIDs = [ CE1F923D-C539-48EA-7300-0A<controllerId:8hex><status:1byte> ]
mfg = 60 03 00 <counter> <flags> <faultCode> <alertSet>
```

- **Service UUID** = `o5ServiceAdvertisementUUID(controllerId)` — a fixed prefix + the 32-bit
`controllerId` (a.k.a. pdmId, = `myId`) + a **1-byte status suffix**.
- Healthy value observed for 5 days straight: `…0A002A1C6C`**`00`** (suffix `00`).
- Pre-pairing placeholder: `…0AFFFFFFFE00`.
- **Manufacturer data** (`60 03 00 …`):
- byte3 `<counter>` — a ~2-minute "time since last connect" counter; increments ~every 2 min,
**resets to 00 on each connect**. Not a fault signal.
- byte4 `<flags>` — `0x10` healthy.
- byte5 `<faultCode>` — `0x00` healthy.
- byte6 `<alertSet>` — alert bitmask (`0x00` = none), same slot scheme as DASH.

## The occlusion fault (measured)

At the fault, **both** the UUID suffix and the manufacturer data changed, and the change **persisted**:

```
HEALTHY svcUUIDs=[CE1F923D-…-0A002A1C6C00] mfg=60 03 00 <cnt> 10 00 00
FAULTED svcUUIDs=[CE1F923D-…-0A002A1C6C02] mfg=60 03 00 <cnt> 18 14 00
↑↑ ↑↑ ↑↑
```

- **Service UUID suffix `00` → `02`.**
- **mfg byte4 `10` → `18`** (a "faulted" flag), **byte5 `00` → `14`** (`0x14` = occlusion — the same
fault-code value DASH uses).
- The counter (byte3) kept incrementing/resetting normally.

**Timing evidence (this is the important part — it's a real state change, not a blip):**
- `…6C02` was **never advertised once in the 5 days before the fault**.
- Healthy `…6C00` **stopped** at the fault; **zero** `…6C00` frames appeared afterward.
- `…6C02` **persisted ~10 minutes** across 6 distinct frames (until the pod was deactivated), with
`faultCode 0x14` stable in every one.

## RE engineer static analysis (TWISDK) — refines the model

- The UUID suffix is a **4-state space: `00, 01, 02, 03`**. TWISDK has literal templates
`CE1F923D-C539-48EA-7300-0A%@00` through `…%@03`, and a real scan request lists all four.
- The parsed field is named generically **`destinationStatus`**, with **separate** `alarmCode` /
`alertCode` fields. The connected-status fault taxonomy (occlusion subtypes, etc.) lives in the
PodSDK status/alarm models, **not** in the advert.
- Conclusion: `02` is a **coarse advertised "pod attention/status" bucket, NOT occlusion-specific**.
There is no static evidence for a suffix → fault-type mapping. Our `00 = normal, 02 = faulted`
observation is consistent with this, but the **exact fault type must be resolved from the
connected status**, not the UUID byte.

## Implications for connectionless (deep-idle) O5 fault detection

Feasible, analogous to the DASH C00A scan: filter the idle scan on the **non-normal suffix(es)**
(`CE1F923D-…-0A<controllerId>02`, built from our known controllerId) so a fault is a genuinely NEW
discovery that wakes a suspended app. The advert change is only a **"go look" wake trigger**; the
actual fault/alert is always read from the connected pod status.

### Open questions (before finalizing the filter)
1. **What are suffixes `01` and `03`?** Unknown (only `00` and `02` observed). Likely other
attention/alert states.
2. **Is any non-`00` suffix *persistent*?** ← the one that matters. Per the DASH lesson, a
perpetually-advertised state (like DASH `C005` alert-configured) in the filter makes iOS
*coalesce* the fault re-discovery → slow wakes. Our pod stayed at `00` for 5 days even with
reminders configured (encouraging — suggests O5 holds `00` until an actual event), but this is
**not yet confirmed**. Capturing an O5 **alert** (fire an expiration-reminder / low-reservoir
alert on a normal pod) would show which suffix it uses and whether it sticks.

## Safety (does the scan risk another user's pod?)

- **Filter is not pod-unique.** The O5 `controllerId` is drawn from `O5CertificateStore`
(`O5RegistrationData.allValues.randomElement()`); **compiled certs are shared across an app
build**, so controllerIds can collide across users (the source explicitly notes the value "can't
be … semi-unique across for all users"). So a nearby stranger's faulted pod *can* match our
filter and wake us.
- **But it cannot false-alarm or cross-command.** A wake triggers a connect to **our own** pod (by
its unique `bleIdentifier`) + a status read; the alarm is issued only if **our** pod is faulted.
Commands are encrypted/signed with our pod's session keys (LTK), which a foreign pod lacks.
- **Mitigation for the residual spurious-wake cost:** gate the fault handler on
`autoConnectIDs.contains(peripheral.identifier)` (own-pod BLE identity) so foreign adverts are
dropped without a connect. (The current DASH path doesn't do this and should get the same guard.)

## Status
The O5 connectionless fault scan is **not yet implemented** — this is the research backing it. O5
currently relies on the StartDelay heartbeat + connect-on-demand to catch a fault on the next status
read (same as DASH before its C00A scan).
6 changes: 6 additions & 0 deletions OmnipodKit/Bluetooth/BleMessageTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,12 @@ class BlePodMessageTransport: MessageTransport {
incrementNonceSeq()
let decrypted = try enDecrypt.decrypt(readMessage, nonceSeq)

// Unconditional raw-response capture (before the prefix check throws it away). On a
// "rejected" registration this is THE datum we need to iterate the envelope.
log.default("O5 AID RawResp: type=%{public}@ seq=%{public}d len=%{public}d hex=%{public}@ ascii=%{public}@ expectPrefix=%{public}@",
String(describing: decrypted.type), decrypted.sequenceNumber, decrypted.payload.count,
decrypted.payload.hexadecimalString, String(data: decrypted.payload, encoding: .utf8) ?? "<non-ascii>", responsePrefix)

// Extract response data using the caller-provided response prefix.
// AID responses use plain ASCII key=value format (no SLPE length prefix),
// so we just strip the prefix and return everything after it.
Expand Down
Loading