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 {