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 {