From 27777f093e51ae86810cf70744444d3333589eb0 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 4 Nov 2026 00:30:48 -0600 Subject: [PATCH] Add failNextConnects: leave service discovery unanswered, then hang up POST /command {"command": "failNextConnects", "value": N, "hangUpAfter": 7} makes the next N connects fail the way some pods do in loopandlearn/OmnipodKit#199: the link comes up, service and characteristic discovery go unanswered, and the pod hangs up after hangUpAfter seconds with CBErrorDomain code 7. Only the ImpossiBLE transport supports it, and the app's ImpossiBLE has to pass the error domain and code through on didDisconnect. --- README.md | 2 +- pkg/api/server.go | 12 ++++++ pkg/bluetooth/bluetooth.go | 14 +++++++ pkg/bluetooth/impossible.go | 64 +++++++++++++++++++++++++++++++- pkg/bluetooth/impossible_test.go | 55 +++++++++++++++++++++++++++ pkg/pod/pod.go | 6 +++ 6 files changed, 150 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index de3e173..5bc4194 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Fake pod implementation * This version also mimics a behavior we see in some DASH pods where the pod disconnects every 3 minutes; this can be used with iOS hooks to make a heartbeat to run Loop in situations where a BLE CGM is not available. -* It serves a control panel at http://:8080/ (`-api` changes the address): the pod's reservoir, delivery, age, alerts and fault, with controls to set the reservoir and pod age, raise alerts, trigger or clear a fault, and crash on the next command to test unacknowledged commands. The panel drives a JSON API: `GET /status`, and `POST /command` with `{"command": "changeReservoir", "value": 45}` (also `setActiveTime` in minutes, `setAlerts` as a slot mask, `setFault` with a fault code, 0 to clear, `crashNextCommand` with `"beforeProcessing": true|false`, and `ignoreNextMessages` with a count, which keeps the link up but leaves that many messages unanswered, as a pod that didn't hear them would). +* It serves a control panel at http://:8080/ (`-api` changes the address): the pod's reservoir, delivery, age, alerts and fault, with controls to set the reservoir and pod age, raise alerts, trigger or clear a fault, and crash on the next command to test unacknowledged commands. The panel drives a JSON API: `GET /status`, and `POST /command` with `{"command": "changeReservoir", "value": 45}` (also `setActiveTime` in minutes, `setAlerts` as a slot mask, `setFault` with a fault code, 0 to clear, `crashNextCommand` with `"beforeProcessing": true|false`, `ignoreNextMessages` with a count, which keeps the link up but leaves that many messages unanswered, as a pod that didn't hear them would, and `failNextConnects` with a count and an optional `hangUpAfter` in seconds, 7 by default, which leaves service discovery unanswered on that many connects and then hangs up with CoreBluetooth's "peripheral disconnected" error, as some pods do in the field; this needs `-transport impossible` and an ImpossiBLE that passes `errorDomain` and `errorCode` through on a disconnect). * The websocket API at `/ws`, used by the separate [NodeJS/React frontend](https://github.com/ps2/pod_simulator_frontend), is still there. diff --git a/pkg/api/server.go b/pkg/api/server.go index 9749839..4dfabaa 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -7,6 +7,7 @@ import ( "io/ioutil" "net/http" "strings" + "time" "github.com/avereha/pod/pkg/pod" "github.com/gorilla/websocket" @@ -173,6 +174,17 @@ func (s *Server) handleCommand(bytes []byte) error { return err } s.pod.SetActiveTime(int(value)) + case "failNextConnects": + if err := needValue("connect count", 0, 100); err != nil { + return err + } + hangUpAfter := 7.0 + if v, ok := msg["hangUpAfter"].(float64); ok { + hangUpAfter = v + } + if err := s.pod.FailNextConnects(int(value), time.Duration(hangUpAfter*float64(time.Second))); err != nil { + return err + } case "ignoreNextMessages": if err := needValue("message count", 0, 100); err != nil { return err diff --git a/pkg/bluetooth/bluetooth.go b/pkg/bluetooth/bluetooth.go index 3961c41..22e5f73 100644 --- a/pkg/bluetooth/bluetooth.go +++ b/pkg/bluetooth/bluetooth.go @@ -198,6 +198,20 @@ func (b *Ble) Disconnected() { b.genMtx.Unlock() } +// FailNextConnects makes the next count connects fail: service discovery +// goes unanswered and the pod hangs up after hangUpAfter. Only the +// ImpossiBLE transport can do this. +func (b *Ble) FailNextConnects(count int, hangUpAfter time.Duration) error { + r, ok := b.radio.(interface { + FailNextConnects(int, time.Duration) + }) + if !ok { + return fmt.Errorf("failNextConnects needs -transport impossible") + } + r.FailNextConnects(count, hangUpAfter) + return nil +} + // IsConnected says whether the phone is connected. func (b *Ble) IsConnected() bool { b.genMtx.Lock() diff --git a/pkg/bluetooth/impossible.go b/pkg/bluetooth/impossible.go index 3e104fe..3ae3dfa 100644 --- a/pkg/bluetooth/impossible.go +++ b/pkg/bluetooth/impossible.go @@ -36,6 +36,22 @@ type simRadio struct { state AdvertState notifying map[string]bool // characteristic ids delayed *time.Timer // a connect held for its start delay + + failConnects int // connects left to fail, see FailNextConnects + hangUpAfter time.Duration // how long a failing connect lasts + stalling bool // this connection leaves service discovery unanswered + hangUp *time.Timer // the failing connection's hang-up +} + +// FailNextConnects makes the next count connects fail as some pods do in +// the field: the link comes up, service discovery is never answered, and +// the pod hangs up after hangUpAfter with CoreBluetooth's "peripheral +// disconnected" error. +func (r *simRadio) FailNextConnects(count int, hangUpAfter time.Duration) { + r.mu.Lock() + r.failConnects = count + r.hangUpAfter = hangUpAfter + r.mu.Unlock() } type simClient struct { @@ -169,13 +185,23 @@ func (r *simRadio) connect(c *simClient) { } r.delayed = nil already := r.connected + failing := false if !already { r.connected = true r.notifying = map[string]bool{} + if r.failConnects > 0 { + r.failConnects-- + r.stalling = true + failing = true + r.hangUp = time.AfterFunc(r.hangUpAfter, func() { r.remoteHangUp(c) }) + } } r.mu.Unlock() if !already { log.Infof("pkg bluetooth; ** New connection from: %s", c.bundle) + if failing { + log.Infof("pkg bluetooth; failing this connection: no service discovery, hanging up after %v (failNextConnects)", r.hangUpAfter) + } r.ble.Connected() } c.send(map[string]interface{}{"type": "didConnect", "id": r.id}) @@ -238,9 +264,17 @@ func (r *simRadio) handle(c *simClient, msg map[string]interface{}) { r.mu.Unlock() r.disconnect(c, "app") case "discoverServices": + if r.isStalling() { + log.Infof("pkg bluetooth; leaving service discovery unanswered (failNextConnects)") + return + } c.send(map[string]interface{}{"type": "didDiscoverServices", "id": id, "error": "", "services": []map[string]interface{}{{"id": r.serviceID(), "uuid": r.profile.ServiceUUID, "primary": true}}}) case "discoverCharacteristics": + if r.isStalling() { + log.Infof("pkg bluetooth; leaving characteristic discovery unanswered (failNextConnects)") + return + } serviceID := str(msg, "serviceId") chars := []map[string]interface{}{} if serviceID == r.serviceID() { @@ -409,15 +443,41 @@ func (r *simRadio) Disconnect() { } func (r *simRadio) disconnect(c *simClient, by string) { + r.disconnectWithError(c, by, nil) +} + +func (r *simRadio) isStalling() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.stalling +} + +// remoteHangUp ends a failing connection the way a pod does, with +// CBErrorPeripheralDisconnected. +func (r *simRadio) remoteHangUp(c *simClient) { + r.disconnectWithError(c, "pod (failNextConnects)", map[string]interface{}{ + "error": "The specified device has disconnected from us.", "errorDomain": "CBErrorDomain", "errorCode": 7}) +} + +func (r *simRadio) disconnectWithError(c *simClient, by string, cbError map[string]interface{}) { r.mu.Lock() was := r.connected && c != nil && r.client == c if was { r.connected = false } + r.stalling = false + if r.hangUp != nil { + r.hangUp.Stop() + r.hangUp = nil + } r.mu.Unlock() if c != nil { - c.send(map[string]interface{}{"type": "didDisconnect", "id": r.id, "error": "", - "timestamp": float64(time.Now().UnixNano())/1e9 - 978307200, "isReconnecting": false}) + msg := map[string]interface{}{"type": "didDisconnect", "id": r.id, "error": "", + "timestamp": float64(time.Now().UnixNano())/1e9 - 978307200, "isReconnecting": false} + for k, v := range cbError { + msg[k] = v + } + c.send(msg) } if was { log.Infof("pkg bluetooth; ** disconnected by the %s", by) diff --git a/pkg/bluetooth/impossible_test.go b/pkg/bluetooth/impossible_test.go index 3e6357d..53c7113 100644 --- a/pkg/bluetooth/impossible_test.go +++ b/pkg/bluetooth/impossible_test.go @@ -244,3 +244,58 @@ func TestSimRadioServesAnOmnipod5(t *testing.T) { t.Errorf("the pod serves %v, want the command characteristic and %s", got, o5DataCharUUID) } } + +func TestSimRadioFailsTheNextConnectAtServiceDiscovery(t *testing.T) { + dir, err := ioutil.TempDir("/tmp", "podsim") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + path := filepath.Join(dir, "impossible.sock") + const id = "11111111-2222-4333-8444-555555555555" + b, err := New(func(b *Ble) (Radio, error) { return NewSimRadio(b, path, id, nil) }, ProfileFor(ModeDash)) + if err != nil { + t.Fatal(err) + } + conn, err := net.Dial("unix", path) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + app := &fakeApp{t: t, conn: conn, in: bufio.NewScanner(conn)} + app.send(map[string]interface{}{"type": "hello", "bundleId": "org.loopkit.Loop", "pid": 0}) + app.send(map[string]interface{}{"type": "scan"}) + app.expect("didDiscover") + + if err := b.FailNextConnects(1, 200*time.Millisecond); err != nil { + t.Fatal(err) + } + app.send(map[string]interface{}{"type": "connect", "id": id}) + app.expect("didConnect") + app.send(map[string]interface{}{"type": "discoverServices", "id": id}) + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + for app.in.Scan() { + var msg map[string]interface{} + if err := json.Unmarshal(app.in.Bytes(), &msg); err != nil { + t.Fatal(err) + } + if msg["type"] == "didDiscoverServices" { + t.Fatal("answered service discovery on a failing connect") + } + if msg["type"] == "didDisconnect" { + if msg["errorDomain"] != "CBErrorDomain" || msg["errorCode"] != float64(7) { + t.Fatalf("hang-up %v", msg) + } + break + } + } + if b.IsConnected() { + t.Fatal("still connected after hanging up") + } + + // Only the one connect fails. + app.send(map[string]interface{}{"type": "connect", "id": id}) + app.expect("didConnect") + app.send(map[string]interface{}{"type": "discoverServices", "id": id}) + app.expect("didDiscoverServices") +} diff --git a/pkg/pod/pod.go b/pkg/pod/pod.go index 5f5bc21..308c306 100644 --- a/pkg/pod/pod.go +++ b/pkg/pod/pod.go @@ -855,6 +855,12 @@ func (p *Pod) IgnoreNextMessages(count int) { p.mtx.Unlock() } +// FailNextConnects makes the next count connects fail at service +// discovery, with the pod hanging up after hangUpAfter. +func (p *Pod) FailNextConnects(count int, hangUpAfter time.Duration) error { + return p.ble.FailNextConnects(count, hangUpAfter) +} + func (p *Pod) CrashNextCommand(beforeProcessing bool) { p.mtx.Lock() if beforeProcessing {