From 6dc06686c42abd86c686a930ede964f279ee36b1 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 12 Oct 2026 01:52:14 -0500 Subject: [PATCH] Serve a control panel and an iOS Simulator transport The pod executable now serves its own control panel, like g7sim's, at the API address (-api, default :8080): the pod's state, and controls for the reservoir, pod age, alerts, faults and crashing on the next command. It drives a JSON API (GET /status, POST /command) with the commands the websocket takes; /ws stays for the separate React frontend. Bad commands are now errors, not a fatal exit. -transport impossible serves the pod to an app in the iOS Simulator through ImpossiBLE's socket instead of a Bluetooth adapter, so Loop can pair with and run a simulated pod on any Mac. The Bluetooth layer is split so the message framing is shared: the paypal/gatt radio builds on Linux only, and the Simulator radio anywhere. It holds a connect for its start delay, as iOS does: OmnipodKit's heartbeat relies on that. A reconnect while pairing or establishing a session, or before the phone acknowledges a response, used to leave the pod waiting on the old connection forever (over Bluetooth it usually exited instead); it now starts over on the new one. --- .gitignore | 2 + README.md | 33 ++- main.go | 40 ++- pkg/api/server.go | 110 +++++--- pkg/api/web.go | 334 ++++++++++++++++++++++++ pkg/bluetooth/bluetooth.go | 271 +++++++------------- pkg/bluetooth/gatt_linux.go | 164 ++++++++++++ pkg/bluetooth/gatt_other.go | 10 + pkg/bluetooth/impossible.go | 422 +++++++++++++++++++++++++++++++ pkg/bluetooth/impossible_test.go | 174 +++++++++++++ pkg/pod/pod.go | 89 +++++-- pkg/pod/state.go | 12 + pkg/pod/status.go | 107 ++++++++ 13 files changed, 1518 insertions(+), 250 deletions(-) create mode 100644 .gitignore create mode 100644 pkg/api/web.go create mode 100644 pkg/bluetooth/gatt_linux.go create mode 100644 pkg/bluetooth/gatt_other.go create mode 100644 pkg/bluetooth/impossible.go create mode 100644 pkg/bluetooth/impossible_test.go create mode 100644 pkg/pod/status.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7910ca0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/pod +*.toml diff --git a/README.md b/README.md index cd74e3d..cb2eec7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,11 @@ 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 also has a websocket based API that can used by a separate [NodeJS/React frontend](https://github.com/ps2/pod_simulator_frontend), that is installed and run separately for now. +* 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, and `crashNextCommand` with `"beforeProcessing": true|false`). + +* The websocket API at `/ws`, used by the separate [NodeJS/React frontend](https://github.com/ps2/pod_simulator_frontend), is still there. + +* It can serve an app in the iOS Simulator instead of a phone; see [Run against the iOS Simulator](#run-against-the-ios-simulator). Requirements: 1. Version of iOS code (Loop app) that will interact with this simulator - Loop dev branch or FreeAPS freeaps_dev branch @@ -71,6 +75,33 @@ FATA[####] pkg bluetooth; ** disconnect: Simple restore communication as stated above. +## Run against the iOS Simulator + +`-transport impossible` serves the pod to an app in the iOS Simulator through +[ImpossiBLE](https://github.com/mickeyl/ImpossiBLE): no Raspberry Pi and no radio, so it runs on any Mac. The app is +launched with ImpossiBLE's library injected, as [LoopKit/g7sim](https://github.com/LoopKit/g7sim) describes for the G7 +emulator (its `simulator/build-impossible.sh` builds the library), and talks to the pod over ImpossiBLE's socket: + +``` +go build +./pod -transport impossible -fresh +``` + +Loop's Omnipod support (OmnipodKit) mocks pod commands in the Simulator unless it finds ImpossiBLE, and then pairs, +primes and runs the simulated pod as it would a real one. + +ImpossiBLE connects the app to one socket, `/tmp/impossible.sock`. To run the pod alongside g7sim for the same app, give +each its own socket and let g7sim's `impossible-mux` hold ImpossiBLE's: + +``` +./pod -transport impossible -socket /tmp/impossible-pod.sock +g7sim -transport impossible -socket /tmp/impossible-g7.sock +impossible-mux /tmp/impossible-g7.sock /tmp/impossible-pod.sock +``` + +The pod holds a connect that asks for a start delay (`CBConnectPeripheralOptionStartDelayKey`) for that delay, as iOS +does, and hangs up after 3 minutes without a command, as above. + # Original README.md We maintained the original README file below. It may be helpful if someone plans to cross-compile the code and just transfer the executable. diff --git a/main.go b/main.go index 12a6bb5..1c26251 100644 --- a/main.go +++ b/main.go @@ -2,7 +2,6 @@ package main import ( "flag" - "time" "github.com/avereha/pod/pkg/api" "github.com/avereha/pod/pkg/bluetooth" @@ -18,6 +17,9 @@ func main() { // if both verbose and quiet are chosen, e.g., -v -q, the verbose dominates var traceLevel = flag.Bool("v", false, "verbose off by default, TraceLevel") var infoLevel = flag.Bool("q", false, "quiet off by default, InfoLevel") + var apiAddr = flag.String("api", ":8080", "address of the control panel and its API") + var transport = flag.String("transport", "ble", "ble (a Linux Bluetooth adapter) or impossible (ImpossiBLE's socket, for an app in the iOS Simulator)") + var socket = flag.String("socket", "/tmp/impossible.sock", "ImpossiBLE socket for -transport impossible") flag.Parse() @@ -35,34 +37,44 @@ func main() { FullTimestamp: true, }) - // TODO: This is kinda ugly, move state reader into own file and pass state to both BLE and pod - state := &pod.PODState{ - Filename: *stateFile, - } - var err error - if !(*freshState) { + var state *pod.PODState + if *freshState { + state = pod.NewFreshState(*stateFile) + } else { + var err error state, err = pod.NewState(*stateFile) if err != nil { log.Fatalf("pkg pod; could not restore pod state from %s: %+v", *stateFile, err) } } + if state.PeripheralID == "" { + state.PeripheralID = bluetooth.PeripheralID() + } log.Tracef("podId %x", state.Id) - ble, err := bluetooth.New("hci0", state.Id) - //defer ble.Close() + var attach func(*bluetooth.Ble) (bluetooth.Radio, error) + switch *transport { + case "ble": + attach = func(b *bluetooth.Ble) (bluetooth.Radio, error) { return bluetooth.NewGattRadio(b, state.Id) } + case "impossible": + attach = func(b *bluetooth.Ble) (bluetooth.Radio, error) { + return bluetooth.NewSimRadio(b, *socket, state.PeripheralID, state.Id) + } + default: + log.Fatalf("transport %q: want ble or impossible", *transport) + } + ble, err := bluetooth.New(attach) if err != nil { - log.Fatalf("Could not start BLE: %s", err) + log.Fatalf("Could not start %s: %s", *transport, err) } - p := pod.New(ble, *stateFile, *freshState) + p := pod.New(ble, state) go func() { p.StartAcceptingCommands() }() log.Info("Starting API") s := api.New(p) - s.Start() - - time.Sleep(9999 * time.Second) + s.Start(*apiAddr) } diff --git a/pkg/api/server.go b/pkg/api/server.go index e9d3182..a2cffd5 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -2,8 +2,11 @@ package api import ( "encoding/json" + "errors" "fmt" + "io/ioutil" "net/http" + "strings" "github.com/avereha/pod/pkg/pod" "github.com/gorilla/websocket" @@ -26,18 +29,23 @@ func New(pod *pod.Pod) *Server { return ret } -func (s *Server) Start() { - fmt.Println("Pod simulator web api listening on :8080") +func (s *Server) Start(addr string) { s.setupRoutes() - fmt.Println("Setting Web Message Hook") s.pod.SetWebMessageHook(func(msg []byte) { s.sendMessage(msg) }) - http.ListenAndServe(":8080", nil) + log.Infof("Pod simulator control panel on http://%s/", displayAddr(addr)) + log.Fatal(http.ListenAndServe(addr, nil)) +} + +func displayAddr(addr string) string { + if strings.HasPrefix(addr, ":") { + return "localhost" + addr + } + return addr } func (s *Server) sendMessage(msg []byte) { - fmt.Println("writing to websocket") if s.conn != nil { if err := s.conn.WriteMessage(websocket.TextMessage, msg); err != nil { log.Println(err) @@ -48,19 +56,48 @@ func (s *Server) sendMessage(msg []byte) { func (s *Server) setupRoutes() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintf(w, "This is an API to the pod simulator intended to be used with a separate web client.") + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, indexHTML) + }) + http.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, s.pod.Status()) }) + http.HandleFunc("/command", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST a command", http.StatusMethodNotAllowed) + return + } + body, err := ioutil.ReadAll(r.Body) + if err == nil { + err = s.handleCommand(body) + } + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, s.pod.Status()) + }) + // The websocket API, for github.com/ps2/pod_simulator_frontend. http.Handle("/ws", s) } -func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - fmt.Println(r.Host) +func writeJSON(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + json.NewEncoder(w).Encode(v) +} +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // upgrade this connection to a WebSocket // connection ws, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Println(err) + return } s.conn = ws @@ -87,56 +124,65 @@ func (s *Server) reader(conn *websocket.Conn) { log.Println(err) return } - fmt.Println("Received: " + string(p)) - s.handleCommand(p) + log.Debugf("Received: %s", p) + if err := s.handleCommand(p); err != nil { + log.Error(err) + } } } -func (s *Server) handleCommand(bytes []byte) { +func (s *Server) handleCommand(bytes []byte) error { var msg map[string]interface{} if err := json.Unmarshal(bytes, &msg); err != nil { - log.Error(err) - return + return err } - var ( - command string - value float64 - ok bool - ) - - if command, ok = msg["command"].(string); !ok { - log.Fatal("command is not a string or not in msg") + command, ok := msg["command"].(string) + if !ok { + return errors.New("command is not a string or not in msg") + } + value, hasValue := msg["value"].(float64) + needValue := func(what string, min, max float64) error { + if !hasValue { + return fmt.Errorf("%s is not a number or not in msg", what) + } + if value < min || value > max { + return fmt.Errorf("%s %v: want %v to %v", what, value, min, max) + } + return nil } switch command { case "changeReservoir": - if value, ok = msg["value"].(float64); !ok { - log.Fatal("reservoir value is not a number or not in msg") + if err := needValue("reservoir", 0, 200); err != nil { + return err } s.pod.SetReservoir(float32(value)) case "setAlerts": - if value, ok = msg["value"].(float64); !ok { - log.Fatal("alert value is not a number or not in msg") + if err := needValue("alert mask", 0, 255); err != nil { + return err } s.pod.SetAlerts(uint8(value)) case "setFault": - if value, ok = msg["value"].(float64); !ok { - log.Fatal("fault value is not a number or not in msg") + if err := needValue("fault", 0, 255); err != nil { + return err } s.pod.SetFault(uint8(value)) case "setActiveTime": - if value, ok = msg["value"].(float64); !ok { - log.Fatal("active time in minutes is not a number or not in msg") + if err := needValue("active time in minutes", 0, 100*60); err != nil { + return err } s.pod.SetActiveTime(int(value)) case "crashNextCommand": - var beforeProcessing bool - if beforeProcessing, ok = msg["beforeProcessing"].(bool); !ok { - log.Fatal("beforeProcessing is not a bool or not in msg") + beforeProcessing, ok := msg["beforeProcessing"].(bool) + if !ok { + return errors.New("beforeProcessing is not a bool or not in msg") } s.pod.CrashNextCommand(beforeProcessing) + default: + return fmt.Errorf("unknown command %q", command) } + return nil } // We'll need to define an Upgrader diff --git a/pkg/api/web.go b/pkg/api/web.go new file mode 100644 index 0000000..c4a88d3 --- /dev/null +++ b/pkg/api/web.go @@ -0,0 +1,334 @@ +package api + +// indexHTML is the control panel served at /. It is self-contained (no +// external assets, so it works on a Pi with no internet) and drives the +// JSON API: GET /status, POST /command. +const indexHTML = ` + + + + +Pod simulator + + + +
+

DASH pod

+ + — +
+
+
+

Pod

+
+ — + U in reservoir + — +
+
+
Delivered
—
+
Pod age
—
+
Basal
—
+
Temp basal
—
+
Bolus
—
+
Alerts
—
+
Fault
—
+
+
+ +
+

Reservoir

+
+
+
+
+
The pod reports the level only below 50 U; above that it reports “50+ U”.
+
+ +
+

Pod age

+
+
+
+
+
+ Jump to: + + + + +
+
At 80 hours the pod faults with 0x1C at the next command.
+
+ +
+

Active alerts

+
+
+
+ +
+

Fault

+ + +
+ + +
+
+ +
+

Unacknowledged command

+
+ + +
+
The simulator exits at the next command that changes delivery, leaving the phone without a response. Restart it (run.sh does) to see how the app recovers.
+
+ +
+

Comms

+ + + + + + +
Message sequence—
Command sequence—
Nonce sequence—
EAP-AKA sequence—
Last programming sequence—
+
+
+
+
For development use only — not an Insulet product. This pod simulates delivery; never use it for therapy.
+ + +` diff --git a/pkg/bluetooth/bluetooth.go b/pkg/bluetooth/bluetooth.go index 17e7af8..8e6c6a7 100644 --- a/pkg/bluetooth/bluetooth.go +++ b/pkg/bluetooth/bluetooth.go @@ -5,15 +5,12 @@ import ( "encoding/binary" "encoding/hex" "errors" - "fmt" "hash/crc32" "sync" "time" "github.com/avereha/pod/pkg/message" "github.com/davecgh/go-spew/spew" - "github.com/paypal/gatt" - "github.com/paypal/gatt/linux/cmd" log "github.com/sirupsen/logrus" ) @@ -28,6 +25,39 @@ var ( CmdFail = Packet([]byte{5}) ) +// Radio carries the pod's command and data characteristics to the phone: +// a Bluetooth adapter, or the iOS Simulator through ImpossiBLE. +type Radio interface { + NotifyCmd(Packet) error + NotifyData(Packet) error + Advertise(podId []byte) error + Disconnect() +} + +const AdvertisedName = " :: Fake POD ::" + +// AdvertisedServices is the list of 16-bit service UUIDs a DASH pod +// advertises; the 4th and 5th carry its id, FFFF FFFE before pairing. +func AdvertisedServices(podId []byte) []uint16 { + idOne, idTwo := uint16(0xffff), uint16(0xfffe) + if podId != nil { + idOne = binary.BigEndian.Uint16(podId[0:2]) + idTwo = binary.BigEndian.Uint16(podId[2:4]) + } + return []uint16{ + 0x4024, + 0x2470, + 0x000a, + idOne, + idTwo, + // these 4 are copied from lotNo and lotSeq from fixed string in versionresponse.go + 0x0814, + 0x6DB1, + 0x0006, + 0xE451, + } +} + type Ble struct { dataInput chan Packet cmdInput chan Packet @@ -39,38 +69,18 @@ type Ble struct { stopLoop chan bool reconnected chan bool - device *gatt.Device - central *gatt.Central + radio Radio // connGen counts central connections; servingGen is the one the // message loop was started for. They differ only after a real reconnect. genMtx sync.Mutex connGen uint64 servingGen uint64 - - cmdNotifier gatt.Notifier - cmdNotifierMtx sync.Mutex - - dataNotifier gatt.Notifier - dataNotifierMtx sync.Mutex -} - -var DefaultServerOptions = []gatt.Option{ - gatt.LnxMaxConnections(1), - gatt.LnxDeviceID(-1, true), - gatt.LnxSetAdvertisingParameters(&cmd.LESetAdvertisingParameters{ - AdvertisingIntervalMin: 0x00f4, - AdvertisingIntervalMax: 0x00f4, - AdvertisingChannelMap: 0x7, - }), + connected bool } -func New(adapterID string, podId []byte) (*Ble, error) { - d, err := gatt.NewDevice(DefaultServerOptions...) - if err != nil { - log.Fatalf("pkg bluetooth; failed to open device, err: %s", err) - } - +// New starts the pod's link layer on the radio that attach returns. +func New(attach func(*Ble) (Radio, error)) (*Ble, error) { b := &Ble{ dataInput: make(chan Packet, 5), cmdInput: make(chan Packet, 5), @@ -79,178 +89,79 @@ func New(adapterID string, podId []byte) (*Ble, error) { messageInput: make(chan *message.Message, 5), messageOutput: make(chan *message.Message, 2), reconnected: make(chan bool, 1), - device: &d, } + r, err := attach(b) + if err != nil { + return nil, err + } + b.radio = r - d.Handle( - gatt.CentralConnected(func(c gatt.Central) { - fmt.Println("pkg bluetooth; ** New connection from: ", c.ID()) - b.StopMessageLoop() - b.central = &c - b.genMtx.Lock() - b.connGen++ - b.genMtx.Unlock() - // Wake any in-flight CommandLoop so it can tear down and - // re-establish a session on this new connection. - select { - case b.reconnected <- true: - default: - } - }), - gatt.CentralDisconnected(func(c gatt.Central) { - log.Tracef("pkg bluetooth; ** disconnect: %s", c.ID()) - }), - ) - - // Start cmd writing goroutine go func() { - for { - packet := <-b.cmdOutput - b.cmdNotifierMtx.Lock() - if b.cmdNotifier.Done() { - log.Fatalf("pkg bluetooth; CMD closed") - } - ret, err := b.cmdNotifier.Write(packet) - b.cmdNotifierMtx.Unlock() - log.Tracef("pkg bluetooth; CMD notification return: %d/%s", ret, hex.EncodeToString(packet)) - if err != nil { - log.Fatalf("pkg bluetooth; error writing CMD: %s", err) + for packet := range b.cmdOutput { + if err := b.radio.NotifyCmd(packet); err != nil { + log.Warnf("pkg bluetooth; CMD %s not sent: %s", packet, err) } } }() - - // Start data writing goroutine go func() { - for { - packet := <-b.dataOutput - b.dataNotifierMtx.Lock() - if b.dataNotifier.Done() { - log.Fatalf("pkg bluetooth; DATA closed") - } - ret, err := b.dataNotifier.Write(packet) - b.dataNotifierMtx.Unlock() - log.Tracef("pkg bluetooth; DATA notification return: %d/%s", ret, hex.EncodeToString(packet)) - if err != nil { - log.Fatalf("pkg bluetooth; error writing DATA: %s ", err) + for packet := range b.dataOutput { + if err := b.radio.NotifyData(packet); err != nil { + log.Warnf("pkg bluetooth; DATA %s not sent: %s", packet, err) } } }() + return b, nil +} - // A mandatory handler for monitoring device state. - onStateChanged := func(d gatt.Device, s gatt.State) { - fmt.Printf("state: %s\n", s) - switch s { - case gatt.StatePoweredOn: - var serviceUUID = gatt.MustParseUUID("1a7e-4024-e3ed-4464-8b7e-751e03d0dc5f") - var cmdCharUUID = gatt.MustParseUUID("1a7e-2441-e3ed-4464-8b7e-751e03d0dc5f") - var dataCharUUID = gatt.MustParseUUID("1a7e-2442-e3ed-4464-8b7e-751e03d0dc5f") - - s := gatt.NewService(serviceUUID) - - cmdCharacteristic := s.AddCharacteristic(cmdCharUUID) - cmdCharacteristic.HandleWriteFunc( - func(r gatt.Request, data []byte) (status byte) { - log.Tracef("received CMD, %x", data) - ret := make([]byte, len(data)) - copy(ret, data) - b.cmdInput <- Packet(ret) - return 0 - }) - - cmdCharacteristic.HandleNotifyFunc( - func(r gatt.Request, n gatt.Notifier) { - b.cmdNotifierMtx.Lock() - b.cmdNotifier = n - b.cmdNotifierMtx.Unlock() - log.Infof("pkg bluetooth; handling CMD notifications on new connection from: %s", r.Central.ID()) - }) - - dataCharacteristic := s.AddCharacteristic(dataCharUUID) - dataCharacteristic.HandleNotifyFunc( - func(r gatt.Request, n gatt.Notifier) { - b.dataNotifierMtx.Lock() - b.dataNotifier = n - b.dataNotifierMtx.Unlock() - log.Infof("pkg bluetooth; handling DATA notifications on new connection from: %s", r.Central.ID()) - log.Infof(" *** OK to send commands from the phone app ***") - }) - - dataCharacteristic.HandleWriteFunc( - func(r gatt.Request, data []byte) (status byte) { - log.Tracef("pkg bluetooth; received DATA,%x, -- %d", data, len(data)) - ret := make([]byte, len(data)) - copy(ret, data) - b.dataInput <- Packet(ret) - return 0 - }) - - err = d.AddService(s) - if err != nil { - log.Fatalf("pkg bluetooth; could not add service: %s", err) - } - - podIdServiceOne := gatt.UUID16(0xffff) - podIdServiceTwo := gatt.UUID16(0xfffe) - if podId != nil { - podIdServiceOne = gatt.UUID16(binary.BigEndian.Uint16(podId[0:2])) - podIdServiceTwo = gatt.UUID16(binary.BigEndian.Uint16(podId[2:4])) - } - - // Advertise device name and service's UUIDs. - err = d.AdvertiseNameAndServices(" :: Fake POD ::", []gatt.UUID{ - gatt.UUID16(0x4024), - - gatt.UUID16(0x2470), - gatt.UUID16(0x000a), - - podIdServiceOne, - podIdServiceTwo, - - // these 4 are copied from lotNo and lotSeq from fixed string in versionresponse.go - gatt.UUID16(0x0814), - gatt.UUID16(0x6DB1), - gatt.UUID16(0x0006), - gatt.UUID16(0xE451), - }) - if err != nil { - log.Fatalf("pkg bluetooth; could not advertise: %s", err) - } - default: +// Connected is the radio reporting a new connection from the phone. +func (b *Ble) Connected() { + b.StopMessageLoop() + // What the previous connection left unread is no part of the new one. + for _, ch := range []chan Packet{b.cmdInput, b.dataInput} { + for len(ch) > 0 { + <-ch } } - err = d.Init(onStateChanged) - if err != nil { - log.Fatalf("pkg bluetooth; could not init bluetooth: %s", err) + for len(b.messageInput) > 0 { + <-b.messageInput + } + b.genMtx.Lock() + b.connGen++ + b.connected = true + b.genMtx.Unlock() + // Wake any in-flight CommandLoop so it can tear down and + // re-establish a session on this new connection. + select { + case b.reconnected <- true: + default: } - return b, nil } -func (b *Ble) RefreshAdvertisingWithSpecifiedId(id []byte) error { // 4 bytes, first 2 usually empty - log.Debugf("RefreshAdvertisingWithSpecifiedId %x", id) - // Looking at the paypal/gatt source code, we don't need to call StopAdvertising, - // but just call AdvertiseNameAndServices and it should update +func (b *Ble) Disconnected() { + b.genMtx.Lock() + b.connected = false + b.genMtx.Unlock() +} - log.Tracef("podIdServiceOne %v", gatt.UUID16(binary.BigEndian.Uint16(id[0:2]))) - log.Tracef("podIdServiceTwo %v", gatt.UUID16(binary.BigEndian.Uint16(id[2:4]))) - err := (*b.device).AdvertiseNameAndServices(" :: Fake POD ::", []gatt.UUID{ - gatt.UUID16(0x4024), +// IsConnected says whether the phone is connected. +func (b *Ble) IsConnected() bool { + b.genMtx.Lock() + defer b.genMtx.Unlock() + return b.connected +} - gatt.UUID16(0x2470), - gatt.UUID16(0x000a), +// CmdWritten and DataWritten are the phone's writes to the two characteristics. +func (b *Ble) CmdWritten(data []byte) { + b.cmdInput <- Packet(append([]byte(nil), data...)) +} - gatt.UUID16(binary.BigEndian.Uint16(id[0:2])), - gatt.UUID16(binary.BigEndian.Uint16(id[2:4])), +func (b *Ble) DataWritten(data []byte) { + b.dataInput <- Packet(append([]byte(nil), data...)) +} - // these 4 are copied from lotNo and lotSeq from fixed string in versionresponse.go - gatt.UUID16(0x0814), - gatt.UUID16(0x6DB1), - gatt.UUID16(0x0006), - gatt.UUID16(0xE451), - }) - if err != nil { - log.Infof("pkg bluetooth; could not re-advertise: %s", err) - } - return err +func (b *Ble) RefreshAdvertisingWithSpecifiedId(id []byte) error { // 4 bytes, first 2 usually empty + log.Debugf("RefreshAdvertisingWithSpecifiedId %x", id) + return b.radio.Advertise(id) } func (b *Ble) WriteCmd(packet Packet) error { @@ -319,7 +230,7 @@ func (b *Ble) servingCurrentConnection() bool { } func (b *Ble) ShutdownConnection() { - (*b.central).Close() + b.radio.Disconnect() } func (b *Ble) WriteMessage(message *message.Message) { diff --git a/pkg/bluetooth/gatt_linux.go b/pkg/bluetooth/gatt_linux.go new file mode 100644 index 0000000..d518d5b --- /dev/null +++ b/pkg/bluetooth/gatt_linux.go @@ -0,0 +1,164 @@ +package bluetooth + +import ( + "encoding/hex" + "fmt" + "sync" + + "github.com/paypal/gatt" + "github.com/paypal/gatt/linux/cmd" + log "github.com/sirupsen/logrus" +) + +// gattRadio serves the pod from a Linux Bluetooth adapter. +type gattRadio struct { + device gatt.Device + central gatt.Central + + cmdNotifier gatt.Notifier + cmdNotifierMtx sync.Mutex + + dataNotifier gatt.Notifier + dataNotifierMtx sync.Mutex +} + +var DefaultServerOptions = []gatt.Option{ + gatt.LnxMaxConnections(1), + gatt.LnxDeviceID(-1, true), + gatt.LnxSetAdvertisingParameters(&cmd.LESetAdvertisingParameters{ + AdvertisingIntervalMin: 0x00f4, + AdvertisingIntervalMax: 0x00f4, + AdvertisingChannelMap: 0x7, + }), +} + +// NewGattRadio serves the pod from the first Bluetooth adapter. +func NewGattRadio(b *Ble, podId []byte) (Radio, error) { + d, err := gatt.NewDevice(DefaultServerOptions...) + if err != nil { + log.Fatalf("pkg bluetooth; failed to open device, err: %s", err) + } + r := &gattRadio{device: d} + + d.Handle( + gatt.CentralConnected(func(c gatt.Central) { + fmt.Println("pkg bluetooth; ** New connection from: ", c.ID()) + r.central = c + b.Connected() + }), + gatt.CentralDisconnected(func(c gatt.Central) { + log.Tracef("pkg bluetooth; ** disconnect: %s", c.ID()) + b.Disconnected() + }), + ) + + // A mandatory handler for monitoring device state. + onStateChanged := func(d gatt.Device, s gatt.State) { + fmt.Printf("state: %s\n", s) + switch s { + case gatt.StatePoweredOn: + var serviceUUID = gatt.MustParseUUID("1a7e-4024-e3ed-4464-8b7e-751e03d0dc5f") + var cmdCharUUID = gatt.MustParseUUID("1a7e-2441-e3ed-4464-8b7e-751e03d0dc5f") + var dataCharUUID = gatt.MustParseUUID("1a7e-2442-e3ed-4464-8b7e-751e03d0dc5f") + + s := gatt.NewService(serviceUUID) + + cmdCharacteristic := s.AddCharacteristic(cmdCharUUID) + cmdCharacteristic.HandleWriteFunc( + func(r gatt.Request, data []byte) (status byte) { + log.Tracef("received CMD, %x", data) + b.CmdWritten(data) + return 0 + }) + + cmdCharacteristic.HandleNotifyFunc( + func(req gatt.Request, n gatt.Notifier) { + r.cmdNotifierMtx.Lock() + r.cmdNotifier = n + r.cmdNotifierMtx.Unlock() + log.Infof("pkg bluetooth; handling CMD notifications on new connection from: %s", req.Central.ID()) + }) + + dataCharacteristic := s.AddCharacteristic(dataCharUUID) + dataCharacteristic.HandleNotifyFunc( + func(req gatt.Request, n gatt.Notifier) { + r.dataNotifierMtx.Lock() + r.dataNotifier = n + r.dataNotifierMtx.Unlock() + log.Infof("pkg bluetooth; handling DATA notifications on new connection from: %s", req.Central.ID()) + log.Infof(" *** OK to send commands from the phone app ***") + }) + + dataCharacteristic.HandleWriteFunc( + func(r gatt.Request, data []byte) (status byte) { + log.Tracef("pkg bluetooth; received DATA,%x, -- %d", data, len(data)) + b.DataWritten(data) + return 0 + }) + + err = d.AddService(s) + if err != nil { + log.Fatalf("pkg bluetooth; could not add service: %s", err) + } + + if err := r.advertise(podId); err != nil { + log.Fatalf("pkg bluetooth; could not advertise: %s", err) + } + default: + } + } + err = d.Init(onStateChanged) + if err != nil { + log.Fatalf("pkg bluetooth; could not init bluetooth: %s", err) + } + return r, nil +} + +func (r *gattRadio) advertise(podId []byte) error { + var uuids []gatt.UUID + for _, u := range AdvertisedServices(podId) { + uuids = append(uuids, gatt.UUID16(u)) + } + return r.device.AdvertiseNameAndServices(AdvertisedName, uuids) +} + +// Advertise updates the advertisement; paypal/gatt needs no StopAdvertising first. +func (r *gattRadio) Advertise(podId []byte) error { + err := r.advertise(podId) + if err != nil { + log.Infof("pkg bluetooth; could not re-advertise: %s", err) + } + return err +} + +func (r *gattRadio) NotifyCmd(packet Packet) error { + r.cmdNotifierMtx.Lock() + defer r.cmdNotifierMtx.Unlock() + if r.cmdNotifier.Done() { + log.Fatalf("pkg bluetooth; CMD closed") + } + ret, err := r.cmdNotifier.Write(packet) + log.Tracef("pkg bluetooth; CMD notification return: %d/%s", ret, hex.EncodeToString(packet)) + if err != nil { + log.Fatalf("pkg bluetooth; error writing CMD: %s", err) + } + return nil +} + +func (r *gattRadio) NotifyData(packet Packet) error { + r.dataNotifierMtx.Lock() + defer r.dataNotifierMtx.Unlock() + if r.dataNotifier.Done() { + log.Fatalf("pkg bluetooth; DATA closed") + } + ret, err := r.dataNotifier.Write(packet) + log.Tracef("pkg bluetooth; DATA notification return: %d/%s", ret, hex.EncodeToString(packet)) + if err != nil { + log.Fatalf("pkg bluetooth; error writing DATA: %s ", err) + } + return nil +} + +func (r *gattRadio) Disconnect() { + r.central.Close() +} diff --git a/pkg/bluetooth/gatt_other.go b/pkg/bluetooth/gatt_other.go new file mode 100644 index 0000000..6ab53dc --- /dev/null +++ b/pkg/bluetooth/gatt_other.go @@ -0,0 +1,10 @@ +//go:build !linux +// +build !linux + +package bluetooth + +import "errors" + +func NewGattRadio(b *Ble, podId []byte) (Radio, error) { + return nil, errors.New("the Bluetooth transport needs Linux; use -transport impossible for the iOS Simulator") +} diff --git a/pkg/bluetooth/impossible.go b/pkg/bluetooth/impossible.go new file mode 100644 index 0000000..b948f7b --- /dev/null +++ b/pkg/bluetooth/impossible.go @@ -0,0 +1,422 @@ +package bluetooth + +import ( + "bufio" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net" + "os" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +// simRadio serves the pod to an app in the iOS Simulator through ImpossiBLE +// (github.com/mickeyl/ImpossiBLE) instead of a Bluetooth adapter. The app +// links ImpossiBLE, whose CoreBluetooth shims speak newline-delimited JSON +// over a Unix socket; the pod takes the provider's end of that socket, in +// place of ImpossiBLE's menu bar app. Like that app it serves one client, and +// a new connection takes over from the old one. +type simRadio struct { + ble *Ble + id string // the peripheral identifier the app sees + + mu sync.Mutex + client *simClient + scanning bool + connected bool + podId []byte + notifying map[string]bool // characteristic ids + delayed *time.Timer // a connect held for its start delay +} + +type simClient struct { + conn net.Conn + bundle string + mu sync.Mutex + closed bool +} + +func (c *simClient) send(msg map[string]interface{}) { + b, err := json.Marshal(msg) + if err != nil { + log.Warnf("pkg bluetooth; simulator: encoding %v: %v", msg["type"], err) + return + } + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return + } + c.conn.SetWriteDeadline(time.Now().Add(2 * time.Second)) + if _, err := c.conn.Write(append(b, '\n')); err != nil { + log.Warnf("pkg bluetooth; simulator: send %v: %v", msg["type"], err) + } +} + +func (c *simClient) close() { + c.mu.Lock() + c.closed = true + c.mu.Unlock() + c.conn.Close() +} + +const ( + propWriteNoResponse = 0x04 + propWrite = 0x08 + propNotify = 0x10 + + podServiceUUID = "1A7E4024-E3ED-4464-8B7E-751E03D0DC5F" + cmdCharUUID = "1A7E2441-E3ED-4464-8B7E-751E03D0DC5F" + dataCharUUID = "1A7E2442-E3ED-4464-8B7E-751E03D0DC5F" +) + +// NewSimRadio serves the pod on ImpossiBLE's socket at path, as the +// peripheral with identifier id. +func NewSimRadio(b *Ble, path, id string, podId []byte) (Radio, error) { + // A live provider (ImpossiBLE's app, g7sim, another pod) answers on the + // socket; a stale file from one that died does not. + if c, err := net.Dial("unix", path); err == nil { + c.Close() + return nil, fmt.Errorf("%s is served by another provider; quit it (ImpossiBLE's menu bar app?) first", path) + } + os.Remove(path) + l, err := net.Listen("unix", path) + if err != nil { + return nil, err + } + r := &simRadio{ble: b, id: id, podId: podId, notifying: map[string]bool{}} + log.Infof("pkg bluetooth; serving the iOS Simulator on %s as peripheral %s", path, id) + go func() { + for { + conn, err := l.Accept() + if err != nil { + log.Fatalf("pkg bluetooth; simulator: accept: %v", err) + } + go r.serve(&simClient{conn: conn}) + } + }() + return r, nil +} + +func (r *simRadio) serve(c *simClient) { + sc := bufio.NewScanner(c.conn) + sc.Buffer(make([]byte, 64*1024), 4*1024*1024) + for sc.Scan() { + var msg map[string]interface{} + if err := json.Unmarshal(sc.Bytes(), &msg); err != nil { + log.Warnf("pkg bluetooth; simulator: bad message: %v", err) + continue + } + r.handle(c, msg) + } + r.dropClient(c) +} + +// dropClient forgets an app that went away; its connection ends with it. +func (r *simRadio) dropClient(c *simClient) { + r.mu.Lock() + wasConnected := false + if r.client == c { + r.client = nil + r.scanning = false + r.stopDelayedConnect() + wasConnected = r.connected + r.connected = false + } + r.mu.Unlock() + if wasConnected { + log.Infof("pkg bluetooth; simulator: %s went away", c.bundle) + r.ble.Disconnected() + } + c.close() +} + +func str(msg map[string]interface{}, key string) string { + s, _ := msg[key].(string) + return s +} + +func (r *simRadio) current(c *simClient) bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.client == c +} + +func startDelay(msg map[string]interface{}) time.Duration { + options, _ := msg["options"].(map[string]interface{}) + seconds, _ := options["kCBConnectOptionStartDelay"].(float64) + return time.Duration(seconds * float64(time.Second)) +} + +func (r *simRadio) stopDelayedConnect() { + if r.delayed != nil { + r.delayed.Stop() + r.delayed = nil + } +} + +// connect completes the app's connect; the pod is always connectable. +func (r *simRadio) connect(c *simClient) { + r.mu.Lock() + if r.client != c { + r.mu.Unlock() + return + } + r.delayed = nil + already := r.connected + if !already { + r.connected = true + r.notifying = map[string]bool{} + } + r.mu.Unlock() + if !already { + log.Infof("pkg bluetooth; ** New connection from: %s", c.bundle) + r.ble.Connected() + } + c.send(map[string]interface{}{"type": "didConnect", "id": r.id}) +} + +func (r *simRadio) serviceID() string { return r.id + ":" + podServiceUUID } + +func (r *simRadio) charID(uuid string) string { return r.serviceID() + ":" + uuid } + +func (r *simRadio) handle(c *simClient, msg map[string]interface{}) { + t := str(msg, "type") + if t == "hello" { + c.bundle = str(msg, "bundleId") + log.Infof("pkg bluetooth; simulator: %s (ImpossiBLE %s, pid %v) connected", c.bundle, str(msg, "clientVersion"), msg["pid"]) + r.mu.Lock() + old := r.client + r.client = c + r.mu.Unlock() + if old != nil { + log.Infof("pkg bluetooth; simulator: a new app connected; dropping the previous one") + old.send(map[string]interface{}{"type": "connectionRejected", "code": "clientBusy", + "message": "another app connected to the pod simulator"}) + r.dropClient(old) + } + return + } + if !r.current(c) { + return + } + id := str(msg, "id") + switch t { + case "scan": + r.mu.Lock() + r.scanning = true + r.mu.Unlock() + r.discovered() + case "stopScan": + r.mu.Lock() + r.scanning = false + r.mu.Unlock() + case "connect": + if id != r.id { + c.send(map[string]interface{}{"type": "didFailConnect", "id": id, "error": "unknown peripheral"}) + return + } + r.mu.Lock() + r.stopDelayedConnect() + if delay := startDelay(msg); delay > 0 && !r.connected { + // iOS holds the request this long before it starts to connect. + log.Infof("pkg bluetooth; holding the app's connect for its %v start delay", delay) + r.delayed = time.AfterFunc(delay, func() { r.connect(c) }) + r.mu.Unlock() + return + } + r.mu.Unlock() + r.connect(c) + case "cancel": + r.mu.Lock() + r.stopDelayedConnect() + r.mu.Unlock() + r.disconnect(c, "app") + case "discoverServices": + c.send(map[string]interface{}{"type": "didDiscoverServices", "id": id, "error": "", + "services": []map[string]interface{}{{"id": r.serviceID(), "uuid": podServiceUUID, "primary": true}}}) + case "discoverCharacteristics": + serviceID := str(msg, "serviceId") + chars := []map[string]interface{}{} + if serviceID == r.serviceID() { + for _, u := range []string{cmdCharUUID, dataCharUUID} { + chars = append(chars, map[string]interface{}{"id": r.charID(u), "uuid": u, + "properties": propWrite | propWriteNoResponse | propNotify}) + } + } + c.send(map[string]interface{}{"type": "didDiscoverCharacteristics", "id": id, + "serviceId": serviceID, "characteristics": chars, "error": ""}) + case "discoverDescriptors": + c.send(map[string]interface{}{"type": "didDiscoverDescriptors", "id": id, + "characteristicId": str(msg, "characteristicId"), "descriptors": []interface{}{}, "error": ""}) + case "discoverIncludedServices": + c.send(map[string]interface{}{"type": "didDiscoverIncludedServices", "id": id, + "serviceId": str(msg, "serviceId"), "includedServices": []interface{}{}, "error": ""}) + case "read": + c.send(map[string]interface{}{"type": "didUpdateValue", "id": id, + "characteristicId": str(msg, "characteristicId"), "value": "", "error": ""}) + case "write": + r.write(c, msg) + case "setNotify": + r.setNotify(c, msg) + case "readRSSI": + c.send(map[string]interface{}{"type": "didReadRSSI", "id": id, "rssi": -60, "error": ""}) + case "registerForConnectionEvents", "clearMockConfiguration": + case "setMockConfiguration": + c.send(map[string]interface{}{"type": "didSetMockConfiguration", "ok": false, + "error": "the pod simulator serves its own pod and takes no mock configuration"}) + case "openL2CAP": + c.send(map[string]interface{}{"type": "didOpenL2CAP", "id": id, "channelId": "", "psm": msg["psm"], + "error": "the pod has no L2CAP channels"}) + default: + log.Debugf("pkg bluetooth; simulator: ignoring %s", t) + } +} + +// discovered reports the advertisement to a scanning app while the pod is +// advertising, which it does whenever it has no connection. +func (r *simRadio) discovered() { + r.mu.Lock() + c, scanning, connected, podId := r.client, r.scanning, r.connected, r.podId + r.mu.Unlock() + if c == nil || !scanning || connected { + return + } + var uuids []string + for _, u := range AdvertisedServices(podId) { + uuids = append(uuids, fmt.Sprintf("%04X", u)) + } + c.send(map[string]interface{}{"type": "didDiscover", "id": r.id, "name": AdvertisedName, "rssi": -60, + "adv": map[string]interface{}{ + "kCBAdvDataLocalName": AdvertisedName, + "kCBAdvDataIsConnectable": true, + "kCBAdvDataServiceUUIDs": uuids, + }}) +} + +func (r *simRadio) write(c *simClient, msg map[string]interface{}) { + id, charID := str(msg, "id"), str(msg, "characteristicId") + data, err := base64.StdEncoding.DecodeString(str(msg, "value")) + r.mu.Lock() + connected := r.connected + r.mu.Unlock() + reply := map[string]interface{}{"type": "didWriteValue", "id": id, "characteristicId": charID, "error": ""} + switch { + case err != nil: + reply["error"] = "bad value" + case charID != r.charID(cmdCharUUID) && charID != r.charID(dataCharUUID): + reply["error"] = "unknown characteristic" + case !connected: + reply["error"] = "not connected" + } + if wt, _ := msg["writeType"].(float64); wt == 0 { + c.send(reply) + } + if reply["error"] != "" { + return + } + if charID == r.charID(cmdCharUUID) { + log.Tracef("received CMD, %x", data) + r.ble.CmdWritten(data) + } else { + log.Tracef("pkg bluetooth; received DATA,%x, -- %d", data, len(data)) + r.ble.DataWritten(data) + } +} + +func (r *simRadio) setNotify(c *simClient, msg map[string]interface{}) { + id, charID := str(msg, "id"), str(msg, "characteristicId") + enabled, _ := msg["enabled"].(bool) + if n, ok := msg["enabled"].(float64); ok { + enabled = n != 0 + } + reply := map[string]interface{}{"type": "didUpdateNotification", "id": id, "characteristicId": charID, + "enabled": enabled, "error": ""} + r.mu.Lock() + switch { + case charID != r.charID(cmdCharUUID) && charID != r.charID(dataCharUUID): + reply["error"] = "unknown characteristic" + case !r.connected: + reply["error"] = "not connected" + default: + r.notifying[charID] = enabled + } + r.mu.Unlock() + if reply["error"] != "" { + reply["enabled"] = false + } + c.send(reply) + if enabled && charID == r.charID(dataCharUUID) && reply["error"] == "" { + log.Infof(" *** OK to send commands from the phone app ***") + } +} + +func (r *simRadio) notify(uuid string, packet Packet) error { + charID := r.charID(uuid) + r.mu.Lock() + c, connected, subscribed := r.client, r.connected, r.notifying[charID] + r.mu.Unlock() + switch { + case c == nil || !connected: + return errors.New("not connected") + case !subscribed: + return errors.New("not subscribed") + } + c.send(map[string]interface{}{"type": "didUpdateValue", "id": r.id, "characteristicId": charID, + "value": base64.StdEncoding.EncodeToString(packet), "error": ""}) + return nil +} + +func (r *simRadio) NotifyCmd(packet Packet) error { return r.notify(cmdCharUUID, packet) } + +func (r *simRadio) NotifyData(packet Packet) error { return r.notify(dataCharUUID, packet) } + +func (r *simRadio) Advertise(podId []byte) error { + r.mu.Lock() + r.podId = podId + r.mu.Unlock() + r.discovered() + return nil +} + +// Disconnect is the pod hanging up. +func (r *simRadio) Disconnect() { + r.mu.Lock() + c := r.client + r.mu.Unlock() + r.disconnect(c, "pod") +} + +func (r *simRadio) disconnect(c *simClient, by string) { + r.mu.Lock() + was := r.connected && c != nil && r.client == c + if was { + r.connected = false + } + 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}) + } + if was { + log.Infof("pkg bluetooth; ** disconnected by the %s", by) + r.ble.Disconnected() + r.discovered() + } +} + +// PeripheralID makes a random identifier in the form iOS uses. +func PeripheralID() string { + b := make([]byte, 16) + rand.Read(b) + b[6] = b[6]&0x0f | 0x40 + b[8] = b[8]&0x3f | 0x80 + x := strings.ToUpper(fmt.Sprintf("%x", b)) + return x[0:8] + "-" + x[8:12] + "-" + x[12:16] + "-" + x[16:20] + "-" + x[20:32] +} diff --git a/pkg/bluetooth/impossible_test.go b/pkg/bluetooth/impossible_test.go new file mode 100644 index 0000000..e9173c4 --- /dev/null +++ b/pkg/bluetooth/impossible_test.go @@ -0,0 +1,174 @@ +package bluetooth + +import ( + "bufio" + "encoding/base64" + "encoding/json" + "io/ioutil" + "net" + "os" + "path/filepath" + "testing" + "time" +) + +type fakeApp struct { + t *testing.T + conn net.Conn + in *bufio.Scanner +} + +func (a *fakeApp) send(msg map[string]interface{}) { + b, _ := json.Marshal(msg) + if _, err := a.conn.Write(append(b, '\n')); err != nil { + a.t.Fatal(err) + } +} + +func (a *fakeApp) expect(typ string) map[string]interface{} { + a.t.Helper() + a.conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + for a.in.Scan() { + var msg map[string]interface{} + if err := json.Unmarshal(a.in.Bytes(), &msg); err != nil { + a.t.Fatal(err) + } + if msg["type"] == typ { + return msg + } + } + a.t.Fatalf("no %s: %v", typ, a.in.Err()) + return nil +} + +func TestSimRadioPairsAndCarriesBothCharacteristics(t *testing.T) { + // Unix socket paths are short; $TMPDIR on macOS is too long for one. + 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) }) + 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"}) + adv := app.expect("didDiscover")["adv"].(map[string]interface{}) + uuids := adv["kCBAdvDataServiceUUIDs"].([]interface{}) + if len(uuids) != 9 || uuids[0] != "4024" || uuids[2] != "000A" || uuids[3] != "FFFF" || uuids[4] != "FFFE" { + t.Fatalf("an unpaired pod advertises %v", uuids) + } + + app.send(map[string]interface{}{"type": "connect", "id": id}) + app.expect("didConnect") + if !b.IsConnected() { + t.Fatal("not connected") + } + app.send(map[string]interface{}{"type": "discoverServices", "id": id}) + svc := app.expect("didDiscoverServices")["services"].([]interface{})[0].(map[string]interface{}) + app.send(map[string]interface{}{"type": "discoverCharacteristics", "id": id, "serviceId": svc["id"]}) + chars := app.expect("didDiscoverCharacteristics")["characteristics"].([]interface{}) + if len(chars) != 2 { + t.Fatalf("characteristics %v", chars) + } + cmdChar := chars[0].(map[string]interface{})["id"] + dataChar := chars[1].(map[string]interface{})["id"] + + // Before the app subscribes, the pod cannot notify. + if err := b.radio.NotifyCmd(CmdCTS); err == nil { + t.Fatal("notified an unsubscribed characteristic") + } + for _, c := range []interface{}{cmdChar, dataChar} { + app.send(map[string]interface{}{"type": "setNotify", "id": id, "characteristicId": c, "enabled": true}) + if n := app.expect("didUpdateNotification"); n["enabled"] != true || n["error"] != "" { + t.Fatalf("subscribing: %v", n) + } + } + + app.send(map[string]interface{}{"type": "write", "id": id, "characteristicId": cmdChar, "writeType": 0, + "value": base64.StdEncoding.EncodeToString(CmdRTS)}) + app.expect("didWriteValue") + if p, _ := b.ReadCmd(); string(p) != string(CmdRTS) { + t.Fatalf("command characteristic carried %x", p) + } + app.send(map[string]interface{}{"type": "write", "id": id, "characteristicId": dataChar, "writeType": 1, + "value": base64.StdEncoding.EncodeToString([]byte{0, 1, 2})}) + if p, _ := b.ReadData(); string(p) != "\x00\x01\x02" { + t.Fatalf("data characteristic carried %x", p) + } + + b.WriteCmd(CmdCTS) + v := app.expect("didUpdateValue") + if v["characteristicId"] != cmdChar || v["value"] != base64.StdEncoding.EncodeToString(CmdCTS) { + t.Fatalf("pod notified %v", v) + } + + // After pairing the pod advertises its new id; a hang-up tells the app + // and the pod advertises again. + if err := b.RefreshAdvertisingWithSpecifiedId([]byte{0x17, 0x0d, 0x2a, 0x40}); err != nil { + t.Fatal(err) + } + b.ShutdownConnection() + app.expect("didDisconnect") + if b.IsConnected() { + t.Fatal("still connected") + } + uuids = app.expect("didDiscover")["adv"].(map[string]interface{})["kCBAdvDataServiceUUIDs"].([]interface{}) + if uuids[3] != "170D" || uuids[4] != "2A40" { + t.Fatalf("a paired pod advertises %v", uuids) + } +} + +func TestSimRadioHoldsAConnectForItsStartDelay(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) }) + 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}) + + delayed := map[string]interface{}{"type": "connect", "id": id, + "options": map[string]interface{}{"kCBConnectOptionStartDelay": 0.3}} + app.send(delayed) + time.Sleep(100 * time.Millisecond) + if b.IsConnected() { + t.Fatal("connected before the start delay") + } + app.expect("didConnect") + if !b.IsConnected() { + t.Fatal("not connected") + } + + // A cancel during the delay withdraws the connect. + app.send(map[string]interface{}{"type": "cancel", "id": id}) + app.expect("didDisconnect") + app.send(delayed) + app.send(map[string]interface{}{"type": "cancel", "id": id}) + app.expect("didDisconnect") + time.Sleep(400 * time.Millisecond) + if b.IsConnected() { + t.Fatal("a cancelled connect completed") + } +} diff --git a/pkg/pod/pod.go b/pkg/pod/pod.go index 6bab320..207d153 100644 --- a/pkg/pod/pod.go +++ b/pkg/pod/pod.go @@ -8,6 +8,7 @@ import ( "github.com/avereha/pod/pkg/bluetooth" "github.com/avereha/pod/pkg/command" "github.com/avereha/pod/pkg/eap" + "github.com/avereha/pod/pkg/message" "github.com/avereha/pod/pkg/pair" "github.com/avereha/pod/pkg/encrypt" @@ -33,32 +34,23 @@ type Pod struct { state *PODState mtx sync.Mutex webMessageHook func([]byte) + + // snapshot is the state as of the last completed command, for the + // status page: mtx is held while a command waits for the phone's ACK. + snapMtx sync.Mutex + snapshot PODState } // Once one of these are set, the next command will crash the executable. var crashBeforeProcessingCommand bool var crashAfterProcessingCommand bool -func New(ble *bluetooth.Ble, stateFile string, freshState bool) *Pod { - var err error - - state := &PODState{ - Reservoir: 150 / 0.05, - ActivationTime: time.Now(), - Filename: stateFile, - } - if !freshState { - state, err = NewState(stateFile) - if err != nil { - log.Fatalf("pkg pod; could not restore pod state from %s: %+v", stateFile, err) - } - } - +func New(ble *bluetooth.Ble, state *PODState) *Pod { ret := &Pod{ ble: ble, state: state, } - + ret.snapshot = *state return ret } @@ -101,15 +93,36 @@ func (p *Pod) StartAcceptingCommands() { } } +// readSetup waits for the phone's next pairing or session message. A new +// connection abandons the exchange: the phone starts over on it. +func (p *Pod) readSetup() (*message.Message, bool) { + for { + msg, _, didReconnect := p.ble.ReadMessageWithTimeout(time.Minute) + if didReconnect { + log.Infof("pkg pod; new connection — starting over") + go p.StartAcceptingCommands() + return nil, false + } + if msg != nil { + return msg, true + } + } +} + func (p *Pod) StartActivation() { pair := &pair.Pair{} - msg, _ := p.ble.ReadMessage() + msg, ok := p.readSetup() + if !ok { + return + } if err := pair.ParseSP1SP2(msg); err != nil { log.Fatalf("pkg pod; error parsing SP1SP2 %s", err) } // read PDM public key and nonce - msg, _ = p.ble.ReadMessage() + if msg, ok = p.readSetup(); !ok { + return + } if err := pair.ParseSPS1(msg); err != nil { log.Fatalf("pkg pod; error parsing SPS1 %s", err) } @@ -122,7 +135,9 @@ func (p *Pod) StartActivation() { p.ble.WriteMessage(msg) // read PDM conf value - msg, _ = p.ble.ReadMessage() + if msg, ok = p.readSetup(); !ok { + return + } pair.ParseSPS2(msg) // send POD conf value @@ -133,7 +148,9 @@ func (p *Pod) StartActivation() { p.ble.WriteMessage(msg) // receive SP0GP0 constant from PDM - msg, _ = p.ble.ReadMessage() + if msg, ok = p.readSetup(); !ok { + return + } err = pair.ParseSP0GP0(msg) if err != nil { log.Fatalf("pkg pod; could not parse SP0GP0: %s", err) @@ -161,7 +178,10 @@ func (p *Pod) EapAka() { session := eap.NewEapAkaChallenge(p.state.LTK, p.state.EapAkaSeq) - msg, _ := p.ble.ReadMessage() + msg, ok := p.readSetup() + if !ok { + return + } err := session.ParseChallenge(msg) if err != nil { log.Fatalf("pkg pod; error parsing the EAP-AKA challenge: %s", err) @@ -173,7 +193,9 @@ func (p *Pod) EapAka() { } p.ble.WriteMessage(msg) - msg, _ = p.ble.ReadMessage() + if msg, ok = p.readSetup(); !ok { + return + } log.Debugf("pkg pod; success? %x", msg.Payload) // TODO: figure out how error looks like err = session.ParseSuccess(msg) if err != nil { @@ -193,6 +215,9 @@ func (p *Pod) EapAka() { if err != nil { log.Fatalf("pkg pod; Could not save the pod state: %s", err) } + p.mtx.Lock() + p.takeSnapshot() + p.mtx.Unlock() // initialize pMsg var pMsg PodMsgBody @@ -330,7 +355,19 @@ func (p *Pod) CommandLoop(pMsg PodMsgBody) { p.ble.WriteMessage(msg) log.Debugf("pkg pod; reading response ACK. Nonce seq %d", p.state.NonceSeq) - msg, _ = p.ble.ReadMessage() + msg, _, didReconnect = p.ble.ReadMessageWithTimeout(3 * time.Minute) + if msg == nil { + // The phone never acknowledged; it will ask again on its next session. + p.state.Save() + p.takeSnapshot() + p.mtx.Unlock() + log.Infof("pkg pod; no response ACK — re-establishing session") + if !didReconnect { + p.ble.ShutdownConnection() + } + go p.StartAcceptingCommands() + return + } // TODO check for SEQ numbers here and the Ack flag decrypted, err = encrypt.DecryptMessage(p.state.CK, p.state.NoncePrefix, p.state.NonceSeq, msg) if err != nil { @@ -341,6 +378,7 @@ func (p *Pod) CommandLoop(pMsg PodMsgBody) { log.Fatalf("pkg pod; this should be empty message with ACK header %s", spew.Sdump(msg)) } p.state.Save() + p.takeSnapshot() p.mtx.Unlock() log.Debugf("notifyingStateChange") @@ -582,6 +620,7 @@ func (p *Pod) SetReservoir(newVal float32) { p.mtx.Lock() p.state.Reservoir = uint16(newVal * 20) p.state.Save() + p.takeSnapshot() p.mtx.Unlock() } @@ -598,6 +637,7 @@ func (p *Pod) SetAlerts(newVal uint8) { } } p.state.Save() + p.takeSnapshot() p.mtx.Unlock() } @@ -606,6 +646,7 @@ func (p *Pod) SetFault(newVal uint8) { p.state.FaultEvent = newVal p.state.FaultTime = p.state.MinutesActive() p.state.Save() + p.takeSnapshot() p.mtx.Unlock() } @@ -613,6 +654,7 @@ func (p *Pod) SetActiveTime(newVal int) { p.mtx.Lock() p.state.ActivationTime = time.Now().Add(-time.Duration(newVal) * time.Minute) p.state.Save() + p.takeSnapshot() p.mtx.Unlock() } @@ -624,5 +666,6 @@ func (p *Pod) CrashNextCommand(beforeProcessing bool) { crashAfterProcessingCommand = true } p.state.Save() + p.takeSnapshot() p.mtx.Unlock() } diff --git a/pkg/pod/state.go b/pkg/pod/state.go index 0467a3c..1a0dd8b 100644 --- a/pkg/pod/state.go +++ b/pkg/pod/state.go @@ -44,9 +44,21 @@ type PODState struct { ExtendedBolusActive bool `toml:"extended_bolus_active"` BasalActive bool `toml:"basal_active"` + // PeripheralID is the identifier the iOS Simulator transport gives the pod. + PeripheralID string `toml:"peripheral_id"` + Filename string } +// NewFreshState is a new pod, not yet paired. +func NewFreshState(filename string) *PODState { + return &PODState{ + Reservoir: 150 / 0.05, + ActivationTime: time.Now(), + Filename: filename, + } +} + func NewState(filename string) (*PODState, error) { var ret PODState ret.Filename = filename diff --git a/pkg/pod/status.go b/pkg/pod/status.go new file mode 100644 index 0000000..d70230c --- /dev/null +++ b/pkg/pod/status.go @@ -0,0 +1,107 @@ +package pod + +import ( + "fmt" + "time" + + "github.com/avereha/pod/pkg/response" +) + +var progressNames = map[response.PodProgress]string{ + response.PodProgressInitial: "Initial", + response.PodProgressMemoryInitialized: "Memory initialized", + response.PodProgressReminderInitialized: "Reminder initialized", + response.PodProgressPairingCompleted: "Pairing completed", + response.PodProgressPriming: "Priming", + response.PodProgressPrimingCompleted: "Priming completed", + response.PodProgressBasalInitialized: "Basal initialized", + response.PodProgressInsertingCannula: "Inserting cannula", + response.PodProgressRunningAbove50U: "Running, above 50 U", + response.PodProgressRunningBelow50U: "Running, below 50 U", + response.PodProgressFault: "Fault", + response.PodProgressActivationTimeExceeded: "Activation time exceeded", + response.PodProgressPodInactive: "Inactive", +} + +// Status is the pod as the control panel shows it. +type Status struct { + ID string `json:"id"` + Paired bool `json:"paired"` + Connected bool `json:"connected"` + Progress int `json:"progress"` + ProgressName string `json:"progressName"` + Reservoir float64 `json:"reservoir"` + Delivered float64 `json:"delivered"` + ActivationTime time.Time `json:"activationTime"` + MinutesActive int `json:"minutesActive"` + Alerts uint8 `json:"alerts"` + Fault uint8 `json:"fault"` + FaultMinutes uint16 `json:"faultMinutes"` + BasalActive bool `json:"basalActive"` + TempBasalActive bool `json:"tempBasalActive"` + TempBasalEnd time.Time `json:"tempBasalEnd"` + BolusActive bool `json:"bolusActive"` + BolusRemaining float64 `json:"bolusRemaining"` + ExtendedBolusActive bool `json:"extendedBolusActive"` + CrashNext string `json:"crashNext"` + MsgSeq uint8 `json:"msgSeq"` + CmdSeq uint8 `json:"cmdSeq"` + NonceSeq uint64 `json:"nonceSeq"` + EapAkaSeq uint64 `json:"eapAkaSeq"` + LastProgSeqNum uint8 `json:"lastProgSeqNum"` +} + +func (p *Pod) takeSnapshot() { + p.snapMtx.Lock() + p.snapshot = *p.state + p.snapMtx.Unlock() +} + +// Status reports the pod as of its last completed command or change. +func (p *Pod) Status() Status { + p.snapMtx.Lock() + s := p.snapshot + crash := "" + if crashBeforeProcessingCommand { + crash = "before" + } else if crashAfterProcessingCommand { + crash = "after" + } + p.snapMtx.Unlock() + + now := time.Now() + name := progressNames[s.PodProgress] + if name == "" { + name = fmt.Sprintf("%d", s.PodProgress) + } + id := "" + if len(s.Id) == 4 { + id = fmt.Sprintf("%08X", s.Id) + } + return Status{ + ID: id, + Paired: s.LTK != nil, + Connected: p.ble.IsConnected(), + Progress: int(s.PodProgress), + ProgressName: name, + Reservoir: float64(s.Reservoir) * 0.05, + Delivered: float64(s.Delivered) * 0.05, + ActivationTime: s.ActivationTime, + MinutesActive: int(s.MinutesActive()), + Alerts: s.ActiveAlertSlots, + Fault: s.FaultEvent, + FaultMinutes: s.FaultTime, + BasalActive: s.BasalActive, + TempBasalActive: s.TempBasalEnd.After(now), + TempBasalEnd: s.TempBasalEnd, + BolusActive: s.BolusEnd.After(now), + BolusRemaining: float64(s.BolusRemaining()) * 0.05, + ExtendedBolusActive: s.ExtendedBolusActive, + CrashNext: crash, + MsgSeq: s.MsgSeq, + CmdSeq: s.CmdSeq, + NonceSeq: s.NonceSeq, + EapAkaSeq: s.EapAkaSeq, + LastProgSeqNum: s.LastProgSeqNum, + } +}