483 lines
11 KiB
Go
483 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"io/ioutil"
|
|
"log"
|
|
"os"
|
|
"path"
|
|
"time"
|
|
|
|
"git.wow.st/gmp/ble"
|
|
|
|
"gopkg.in/yaml.v2"
|
|
|
|
"gioui.org/app"
|
|
"gioui.org/io/system"
|
|
"gioui.org/layout"
|
|
"gioui.org/f32"
|
|
"gioui.org/op"
|
|
"gioui.org/op/clip"
|
|
"gioui.org/op/paint"
|
|
"gioui.org/text"
|
|
"gioui.org/unit"
|
|
"gioui.org/widget"
|
|
"gioui.org/widget/material"
|
|
|
|
"gioui.org/font/gofont"
|
|
)
|
|
|
|
type conf struct {
|
|
Autoconnect string
|
|
}
|
|
|
|
var Config conf
|
|
var conffile string
|
|
|
|
func main() {
|
|
conffile = path.Join(getConfDir(), "config.yml")
|
|
if _, err := os.Stat(conffile); os.IsNotExist(err) {
|
|
fd, err := os.Create(conffile)
|
|
if err != nil {
|
|
log.Fatal("Cannot create configuration file: ", err)
|
|
}
|
|
fd.Close()
|
|
}
|
|
|
|
confbytes, err := ioutil.ReadFile(conffile)
|
|
if err != nil {
|
|
log.Fatal("Cannot read configuration file: ", err)
|
|
}
|
|
if err = yaml.UnmarshalStrict(confbytes, &Config); err != nil {
|
|
log.Fatal("Cannot parse configuration file: ", err)
|
|
}
|
|
go eventloop()
|
|
app.Main()
|
|
}
|
|
|
|
func saveConfig() {
|
|
confbytes, err := yaml.Marshal(&Config)
|
|
if err != nil {
|
|
log.Fatal("Cannot encode configuration: ", err)
|
|
}
|
|
err = ioutil.WriteFile(conffile, confbytes, 0700)
|
|
}
|
|
|
|
func hrDecode(x []byte) int {
|
|
if len(x) < 4 {
|
|
return 0
|
|
}
|
|
flags := x[0]
|
|
if flags&0x80 != 0 { // uint16 format
|
|
return int(binary.BigEndian.Uint16(x[1:3]))
|
|
} else {
|
|
return int(x[1])
|
|
}
|
|
}
|
|
|
|
type Stopwatch struct {
|
|
lasttime time.Time
|
|
elapsed time.Time
|
|
running bool
|
|
startstopBtn *widget.Button
|
|
resetBtn *widget.Button
|
|
h, m, s int
|
|
}
|
|
|
|
func NewStopwatch() Stopwatch {
|
|
return Stopwatch{
|
|
startstopBtn: &widget.Button{},
|
|
resetBtn: &widget.Button{},
|
|
}
|
|
}
|
|
|
|
func eventloop() {
|
|
w := app.NewWindow(
|
|
app.Size(unit.Dp(350), unit.Dp(600)),
|
|
app.Title("HRM"),
|
|
)
|
|
gofont.Register()
|
|
th := material.NewTheme()
|
|
th.TextSize = unit.Sp(fontSize)
|
|
gtx := &layout.Context{Queue: w.Queue()}
|
|
|
|
sysinset := &layout.Inset{}
|
|
resetSysinset := func(x system.Insets) {
|
|
sysinset.Top = x.Top
|
|
sysinset.Bottom = x.Bottom
|
|
sysinset.Left = x.Left
|
|
sysinset.Right = x.Right
|
|
}
|
|
margin := layout.UniformInset(unit.Dp(10))
|
|
|
|
b := ble.NewBLE()
|
|
b.Enable(w)
|
|
|
|
state := "starting"
|
|
var hr int
|
|
var periph ble.Peripheral
|
|
var wide bool
|
|
periphs := make([]ble.Peripheral, 0)
|
|
btns := make([]*widget.Button, 0)
|
|
backBtn := &widget.Button{}
|
|
|
|
var page, offpage, scanpage, connpage, hrpage func()
|
|
|
|
f := &layout.Flex{Axis: layout.Vertical}
|
|
offpage = func() {
|
|
f.Layout(gtx,
|
|
f.Rigid(gtx, func() {
|
|
th.Body1("Heart Rate Monitor").Layout(gtx)
|
|
}),
|
|
f.Rigid(gtx, func() {
|
|
th.Body1("Bluetooth is Powered Off").Layout(gtx)
|
|
}),
|
|
)
|
|
}
|
|
|
|
appname := func() {
|
|
layout.Align(layout.Center).Layout(gtx, func() {
|
|
th.Body1("Heart Rate Monitor").Layout(gtx)
|
|
})
|
|
}
|
|
appstate := func() {
|
|
layout.Align(layout.Center).Layout(gtx, func() {
|
|
th.Body1(state).Layout(gtx)
|
|
})
|
|
}
|
|
periphname := func() {
|
|
layout.Align(layout.Center).Layout(gtx, func() {
|
|
th.Body1(periph.Name).Layout(gtx)
|
|
})
|
|
}
|
|
|
|
leftbar := func() {
|
|
f := &layout.Flex{Axis: layout.Vertical}
|
|
f.Layout(gtx,
|
|
f.Rigid(gtx, appname),
|
|
f.Rigid(gtx, func() { th.Body1("").Layout(gtx) }),
|
|
f.Rigid(gtx, appstate),
|
|
)
|
|
}
|
|
|
|
scanlist := func() {
|
|
lst := &layout.List{Axis: layout.Vertical}
|
|
lst.Layout(gtx, len(periphs), func(i int) {
|
|
gtx.Constraints.Width.Min = gtx.Constraints.Width.Max
|
|
layout.UniformInset(unit.Dp(2)).Layout(gtx, func() {
|
|
th.Button(periphs[i].Name).Layout(gtx, btns[i])
|
|
})
|
|
if btns[i].Clicked(gtx) {
|
|
b.StopScan()
|
|
periph = periphs[i]
|
|
b.Connect(periph)
|
|
state = "connecting"
|
|
page = connpage
|
|
w.Invalidate()
|
|
}
|
|
})
|
|
}
|
|
|
|
scanpage = func() {
|
|
if wide {
|
|
f2 := &layout.Flex{Axis: layout.Horizontal}
|
|
f2.Layout(gtx,
|
|
f2.Flex(gtx, 0.2, leftbar),
|
|
f2.Rigid(gtx, scanlist),
|
|
)
|
|
} else {
|
|
f.Layout(gtx,
|
|
f.Rigid(gtx, appname),
|
|
f.Rigid(gtx, appstate),
|
|
f.Rigid(gtx, scanlist),
|
|
)
|
|
}
|
|
}
|
|
|
|
connpage = func() {
|
|
f.Layout(gtx,
|
|
f.Rigid(gtx, appname),
|
|
f.Rigid(gtx, appstate),
|
|
f.Rigid(gtx, periphname),
|
|
f.Rigid(gtx, func() {
|
|
th.Button("Cancel").Layout(gtx, backBtn)
|
|
if backBtn.Clicked(gtx) {
|
|
ble.CancelConnection(periph)
|
|
periphs = periphs[:0]
|
|
Config.Autoconnect = ""
|
|
saveConfig()
|
|
b.Scan()
|
|
state = "scanning"
|
|
page = scanpage
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
|
|
hrcircle := func() int {
|
|
var w, h1 float32
|
|
layout.Align(layout.Center).Layout(gtx, func() {
|
|
blue := color.RGBA{0x3f, 0x51, 0xb5, 255}
|
|
white := color.RGBA{255, 255, 255, 255}
|
|
w, h1 = float32(gtx.Constraints.Width.Max), float32(gtx.Constraints.Height.Max)
|
|
if w < h1 {
|
|
h1 = w
|
|
}
|
|
s2 := h1 * 0.9
|
|
r1 := f32.Rectangle{f32.Point{0, 0}, f32.Point{h1, h1}}
|
|
p2a := f32.Point{(h1-s2)/2, (h1-s2)/2}
|
|
p2b := f32.Point{s2 + (h1-s2)/2, s2 + (h1-s2)/2}
|
|
r2 := f32.Rectangle{p2a, p2b}
|
|
clip.Rect{ Rect: r1, NE: h1/2, NW: h1/2, SE: h1/2, SW: h1/2}.Op(gtx.Ops).Add(gtx.Ops)
|
|
paint.ColorOp{Color: blue}.Add(gtx.Ops)
|
|
paint.PaintOp{Rect: r1}.Add(gtx.Ops)
|
|
clip.Rect{ Rect: r2, NE: s2/2, NW: s2/2, SE: s2/2, SW: s2/2}.Op(gtx.Ops).Add(gtx.Ops)
|
|
paint.ColorOp{Color: white}.Add(gtx.Ops)
|
|
paint.PaintOp{Rect: r2}.Add(gtx.Ops)
|
|
op.TransformOp{}.Offset(p2a).Add(gtx.Ops)
|
|
gtx.Constraints.Width.Max = int(s2)
|
|
gtx.Constraints.Height.Max = int(s2)
|
|
gtx.Constraints.Width.Min = int(s2)
|
|
gtx.Constraints.Height.Min = int(s2)
|
|
l := th.H1(fmt.Sprintf("%d", hr))
|
|
l.Alignment = text.Middle
|
|
layout.Align(layout.Center).Layout(gtx, func() {
|
|
l.Layout(gtx)
|
|
})
|
|
gtx.Dimensions.Size = image.Point{int(h1), int(h1)}
|
|
})
|
|
return int(h1)
|
|
}
|
|
|
|
sw := NewStopwatch()
|
|
|
|
stopwatch := func() {
|
|
startstoptxt := "start"
|
|
if sw.running {
|
|
sw.elapsed = sw.elapsed.Add(time.Since(sw.lasttime))
|
|
startstoptxt = "stop"
|
|
}
|
|
sw.lasttime = time.Now()
|
|
f := layout.Flex{Axis: layout.Vertical}
|
|
f.Layout(gtx,
|
|
f.Rigid(gtx, func() {
|
|
layout.Align(layout.Center).Layout(gtx, func() {
|
|
gtx.Constraints.Width.Max = 1e6
|
|
th.H4(sw.elapsed.Format("15:04:05.00")).Layout(gtx)
|
|
})
|
|
}),
|
|
f.Rigid(gtx, func() {
|
|
f2 := layout.Flex{Axis: layout.Horizontal}
|
|
gtx.Constraints.Height.Min = 100
|
|
f2.Layout(gtx,
|
|
f2.Flex(gtx, 0.5, func() {
|
|
layout.UniformInset(unit.Dp(2)).Layout(gtx, func() {
|
|
th.Button(startstoptxt).Layout(gtx, sw.startstopBtn)
|
|
})
|
|
}),
|
|
f2.Flex(gtx, 0.5, func() {
|
|
layout.UniformInset(unit.Dp(2)).Layout(gtx, func() {
|
|
th.Button("reset").Layout(gtx, sw.resetBtn)
|
|
})
|
|
}),
|
|
)
|
|
if sw.startstopBtn.Clicked(gtx) {
|
|
if sw.running {
|
|
sw.running = false
|
|
} else {
|
|
sw.running = true
|
|
}
|
|
}
|
|
if sw.resetBtn.Clicked(gtx) {
|
|
sw.elapsed = time.Time{}
|
|
sw.running = false
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
|
|
swidth := new(int)
|
|
hrpage = func() {
|
|
stopbtn := func() {
|
|
gtx.Constraints.Height.Min = 100
|
|
layout.UniformInset(unit.Dp(2)).Layout(gtx, func() {
|
|
th.Button("Disconnect").Layout(gtx, backBtn)
|
|
})
|
|
}
|
|
if wide {
|
|
f2 := &layout.Flex{Axis: layout.Horizontal}
|
|
f2.Layout(gtx,
|
|
f2.Flex(gtx, 0.4, func() {
|
|
f3 := &layout.Flex{Axis: layout.Vertical}
|
|
c1 := f3.Rigid(gtx, func() {
|
|
appname()
|
|
*swidth = gtx.Dimensions.Size.X
|
|
})
|
|
c2 := f3.Rigid(gtx, func() {
|
|
periphname()
|
|
s2 := gtx.Dimensions.Size.X
|
|
if s2 > *swidth {
|
|
*swidth = s2
|
|
}
|
|
})
|
|
var c3 layout.FlexChild
|
|
if gtx.Constraints.Width.Max > 370 {
|
|
c3 = f3.Rigid(gtx, stopwatch)
|
|
}
|
|
c4 := f3.Rigid(gtx, stopbtn)
|
|
f3.Layout(gtx,
|
|
c1,
|
|
f3.Rigid(gtx, appstate),
|
|
c2,
|
|
f3.Flex(gtx, 1.0, func() { th.Body1("").Layout(gtx) }),
|
|
c3,
|
|
c4,
|
|
)
|
|
}),
|
|
f2.Flex(gtx, 0.6, func() { hrcircle() }),
|
|
)
|
|
} else { // !wide
|
|
c1 := f.Rigid(gtx, func() {
|
|
layout.Align(layout.Center).Layout(gtx, func() {
|
|
gtx.Constraints.Width.Min = *swidth
|
|
gtx.Constraints.Width.Max = *swidth
|
|
stopwatch()
|
|
})
|
|
})
|
|
swheight := gtx.Dimensions.Size.Y
|
|
c2 := f.Rigid(gtx, func() {
|
|
layout.Align(layout.Center).Layout(gtx, func() {
|
|
gtx.Constraints.Width.Min = *swidth
|
|
gtx.Constraints.Width.Max = *swidth
|
|
stopbtn()
|
|
})
|
|
})
|
|
if gtx.Constraints.Height.Max < 775 {
|
|
gtx.Constraints.Height.Max += swheight
|
|
f.Layout(gtx,
|
|
f.Rigid(gtx, appname),
|
|
f.Rigid(gtx, appstate),
|
|
f.Rigid(gtx, periphname),
|
|
f.Flex(gtx, 1.0, func() {
|
|
*swidth = hrcircle()
|
|
}),
|
|
c2,
|
|
)
|
|
} else {
|
|
f.Layout(gtx,
|
|
f.Rigid(gtx, appname),
|
|
f.Rigid(gtx, appstate),
|
|
f.Rigid(gtx, periphname),
|
|
f.Flex(gtx, 1.0, func() {
|
|
*swidth = hrcircle()
|
|
}),
|
|
c1,
|
|
c2,
|
|
)
|
|
}
|
|
}
|
|
if backBtn.Clicked(gtx) {
|
|
ble.Disconnect(periph)
|
|
periphs = periphs[:0]
|
|
Config.Autoconnect = ""
|
|
saveConfig()
|
|
b.Scan()
|
|
state = "scanning"
|
|
page = scanpage
|
|
}
|
|
}
|
|
|
|
page = offpage
|
|
|
|
t := time.NewTicker(time.Second/30)
|
|
for {
|
|
select {
|
|
case <-t.C:
|
|
w.Invalidate()
|
|
case e := <-b.Events():
|
|
switch e := e.(type) {
|
|
case ble.UpdateStateEvent:
|
|
fmt.Printf("UpdateState: %s\n", e.State)
|
|
state = e.State
|
|
if state != "powered on" {
|
|
periphs = periphs[:0]
|
|
page = offpage
|
|
} else {
|
|
|
|
if Config.Autoconnect != "" && b.Connect(ble.Peripheral{Identifier: Config.Autoconnect}) {
|
|
state = "connecting"
|
|
page = connpage
|
|
} else {
|
|
periphs = periphs[:0]
|
|
state = "scanning"
|
|
page = scanpage
|
|
b.Scan()
|
|
}
|
|
}
|
|
case ble.DiscoverPeripheralEvent:
|
|
fmt.Printf("found %s (%s)\n", e.Peripheral.Name, e.Peripheral.Identifier)
|
|
periphs = append(periphs, e.Peripheral)
|
|
btns = append(btns, &widget.Button{})
|
|
if e.Peripheral.Identifier == Config.Autoconnect && b.Connect(e.Peripheral) {
|
|
state = "connecting"
|
|
page = connpage
|
|
}
|
|
case ble.ConnectEvent:
|
|
fmt.Printf("Connect event\n")
|
|
b.StopScan()
|
|
state = "connected"
|
|
periph = e.Peripheral
|
|
Config.Autoconnect = periph.Identifier
|
|
saveConfig()
|
|
e.Peripheral.DiscoverServices()
|
|
page = hrpage
|
|
case ble.ConnectTimeoutEvent:
|
|
fmt.Printf("Connect timeout\n")
|
|
state = "timeout"
|
|
periphs = periphs[:0]
|
|
page = scanpage
|
|
Config.Autoconnect = ""
|
|
saveConfig()
|
|
b.Scan()
|
|
case ble.DiscoverServiceEvent:
|
|
fmt.Printf("DiscoverService %s\n", e.Gatt.UUID)
|
|
//if e.Gatt.UUID == "180D" {
|
|
if e.Gatt.IsHRM() {
|
|
fmt.Printf("Found HRM Service\n")
|
|
e.Peripheral.DiscoverCharacteristics(e.Service)
|
|
}
|
|
case ble.DiscoverCharacteristicEvent:
|
|
fmt.Printf("DiscoverCharacteristic %s\n", e.Gatt.UUID)
|
|
//if e.Gatt.UUID == "2A37" {
|
|
if e.Gatt.IsHRV() {
|
|
fmt.Printf("Found heart rate value\n")
|
|
e.Peripheral.SetNotifyValue(e.Characteristic)
|
|
}
|
|
case ble.UpdateValueEvent:
|
|
hr = hrDecode(e.Data)
|
|
}
|
|
w.Invalidate() // refresh on any Bluetooth event
|
|
case e := <-w.Events():
|
|
switch e := e.(type) {
|
|
case system.DestroyEvent:
|
|
return
|
|
case system.FrameEvent:
|
|
gtx.Reset(e.Config, e.Size)
|
|
if e.Size.X > e.Size.Y {
|
|
wide = true
|
|
} else {
|
|
wide = false
|
|
}
|
|
resetSysinset(e.Insets)
|
|
sysinset.Layout(gtx, func() {
|
|
margin.Layout(gtx, page)
|
|
})
|
|
e.Frame(gtx.Ops)
|
|
}
|
|
}
|
|
}
|
|
}
|