From 90d62d991e0f172cc72c4da9eadbba96cf7a8642 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 9 Nov 2026 18:44:39 -0600 Subject: [PATCH] Add hangUpOnNextMessages: hang up on a message, before or after answering A pod in the field sometimes drops the link during a command, or soon after one. The simulator could leave a message unanswered with the link up, fail a connect during setup, or crash, but not hang up and stay available. With this command it hangs up, with CBErrorPeripheralDisconnected on the ImpossiBLE transport, on each of the next count messages: when the message arrives, or with afterAnswering once the phone has acknowledged the answer. It accepts the phone's reconnect either way. --- README.md | 2 +- pkg/api/server.go | 6 ++++++ pkg/bluetooth/bluetooth.go | 10 +++++++++ pkg/bluetooth/impossible.go | 17 +++++++++++---- pkg/bluetooth/impossible_test.go | 36 ++++++++++++++++++++++++++++++++ pkg/pod/pod.go | 33 +++++++++++++++++++++++++++++ 6 files changed, 99 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 82c4b53..f786509 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`, `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). A fault stops a bolus in progress, and the pod reports the part left undelivered however late it is read, as a real pod does. +* 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, `hangUpOnNextMessages` with a count, which hangs up without answering when each of that many messages arrives, as a pod that drops the link during a command does, or with `"afterAnswering": true` once the phone has acknowledged the answer, 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). A fault stops a bolus in progress, and the pod reports the part left undelivered however late it is read, as a real pod does. * 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 4dfabaa..961e65c 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -190,6 +190,12 @@ func (s *Server) handleCommand(bytes []byte) error { return err } s.pod.IgnoreNextMessages(int(value)) + case "hangUpOnNextMessages": + if err := needValue("message count", 0, 100); err != nil { + return err + } + afterAnswering, _ := msg["afterAnswering"].(bool) + s.pod.HangUpOnNextMessages(int(value), afterAnswering) case "crashNextCommand": beforeProcessing, ok := msg["beforeProcessing"].(bool) if !ok { diff --git a/pkg/bluetooth/bluetooth.go b/pkg/bluetooth/bluetooth.go index 22e5f73..a4f2188 100644 --- a/pkg/bluetooth/bluetooth.go +++ b/pkg/bluetooth/bluetooth.go @@ -355,6 +355,16 @@ func (b *Ble) ShutdownConnection() { b.radio.Disconnect() } +// HangUp ends the connection the way a pod does on its own. On a Bluetooth +// adapter the phone already sees that as a remote close. +func (b *Ble) HangUp() { + if r, ok := b.radio.(interface{ HangUp() }); ok { + r.HangUp() + return + } + b.radio.Disconnect() +} + // MTUShortfall reports a link too small to carry a packet of this profile. // The phone fragments a message into packets but does not fragment a packet, // so a short link truncates every write silently, and pairing stalls on the diff --git a/pkg/bluetooth/impossible.go b/pkg/bluetooth/impossible.go index 3ae3dfa..572e82a 100644 --- a/pkg/bluetooth/impossible.go +++ b/pkg/bluetooth/impossible.go @@ -193,7 +193,7 @@ func (r *simRadio) connect(c *simClient) { r.failConnects-- r.stalling = true failing = true - r.hangUp = time.AfterFunc(r.hangUpAfter, func() { r.remoteHangUp(c) }) + r.hangUp = time.AfterFunc(r.hangUpAfter, func() { r.remoteHangUp(c, "pod (failNextConnects)") }) } } r.mu.Unlock() @@ -452,10 +452,19 @@ func (r *simRadio) isStalling() bool { return r.stalling } -// remoteHangUp ends a failing connection the way a pod does, with +// HangUp is the pod ending the connection on its own, with +// CBErrorPeripheralDisconnected as the phone sees a pod's hang-up. +func (r *simRadio) HangUp() { + r.mu.Lock() + c := r.client + r.mu.Unlock() + r.remoteHangUp(c, "pod") +} + +// remoteHangUp ends a connection the way a pod does, with // CBErrorPeripheralDisconnected. -func (r *simRadio) remoteHangUp(c *simClient) { - r.disconnectWithError(c, "pod (failNextConnects)", map[string]interface{}{ +func (r *simRadio) remoteHangUp(c *simClient, by string) { + r.disconnectWithError(c, by, map[string]interface{}{ "error": "The specified device has disconnected from us.", "errorDomain": "CBErrorDomain", "errorCode": 7}) } diff --git a/pkg/bluetooth/impossible_test.go b/pkg/bluetooth/impossible_test.go index 53c7113..3691f7e 100644 --- a/pkg/bluetooth/impossible_test.go +++ b/pkg/bluetooth/impossible_test.go @@ -299,3 +299,39 @@ func TestSimRadioFailsTheNextConnectAtServiceDiscovery(t *testing.T) { app.send(map[string]interface{}{"type": "discoverServices", "id": id}) app.expect("didDiscoverServices") } + +func TestSimRadioHangsUpAsAPod(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") + app.send(map[string]interface{}{"type": "connect", "id": id}) + app.expect("didConnect") + + b.HangUp() + msg := app.expect("didDisconnect") + if msg["errorDomain"] != "CBErrorDomain" || msg["errorCode"] != float64(7) { + t.Fatalf("hang-up %v", msg) + } + if b.IsConnected() { + t.Fatal("still connected after hanging up") + } + app.send(map[string]interface{}{"type": "connect", "id": id}) + app.expect("didConnect") +} diff --git a/pkg/pod/pod.go b/pkg/pod/pod.go index 42e9af5..85c3de4 100644 --- a/pkg/pod/pod.go +++ b/pkg/pod/pod.go @@ -48,6 +48,8 @@ type Pod struct { var crashBeforeProcessingCommand bool var crashAfterProcessingCommand bool var ignoreMessages int +var hangUpMessages int +var hangUpAfterAnswering bool func New(ble *bluetooth.Ble, state *PODState) *Pod { ret := &Pod{ @@ -350,6 +352,20 @@ func (p *Pod) CommandLoop(pMsg PodMsgBody) { continue } + p.mtx.Lock() + hangUp := hangUpMessages > 0 + if hangUp { + hangUpMessages-- + } + hangUpAfter := hangUp && hangUpAfterAnswering + p.mtx.Unlock() + if hangUp && !hangUpAfter { + log.Infof("pkg pod; hanging up on message seq %d without answering (hangUpOnNextMessages)", msg.SequenceNumber) + p.ble.HangUp() + go p.StartAcceptingCommands() + return + } + if msg.SequenceNumber == lastMsgSeq { // this is a retry because we did not answer yet // ignore duplicate commands/mesages @@ -537,6 +553,12 @@ func (p *Pod) CommandLoop(pMsg PodMsgBody) { log.Debugf("notifyingStateChange") p.notifyStateChange() + if hangUpAfter { + log.Infof("pkg pod; hanging up after answering message seq %d (hangUpOnNextMessages)", lastMsgSeq) + p.ble.HangUp() + go p.StartAcceptingCommands() + return + } } } @@ -854,6 +876,17 @@ func (p *Pod) IgnoreNextMessages(count int) { p.mtx.Unlock() } +// HangUpOnNextMessages makes the pod hang up on each of the next count +// messages: without answering, as a pod that drops the link during a +// command does, or once the phone has acknowledged the answer, as a pod +// that hangs up soon after a command does. +func (p *Pod) HangUpOnNextMessages(count int, afterAnswering bool) { + p.mtx.Lock() + hangUpMessages = count + hangUpAfterAnswering = afterAnswering + 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 {