Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<host>: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://<host>: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.

Expand Down
12 changes: 12 additions & 0 deletions pkg/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io/ioutil"
"net/http"
"strings"
"time"

"github.com/avereha/pod/pkg/pod"
"github.com/gorilla/websocket"
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions pkg/bluetooth/bluetooth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
64 changes: 62 additions & 2 deletions pkg/bluetooth/impossible.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions pkg/bluetooth/impossible_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
6 changes: 6 additions & 0 deletions pkg/pod/pod.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading