You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
509 lines
12 KiB
509 lines
12 KiB
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 ( |
|
D = layout.Dimensions |
|
C = layout.Context |
|
) |
|
|
|
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.Clickable |
|
resetBtn *widget.Clickable |
|
h, m, s int |
|
} |
|
|
|
func NewStopwatch() Stopwatch { |
|
return Stopwatch{ |
|
startstopBtn: &widget.Clickable{}, |
|
resetBtn: &widget.Clickable{}, |
|
} |
|
} |
|
|
|
func eventloop() { |
|
w := app.NewWindow( |
|
app.Size(unit.Dp(350), unit.Dp(600)), |
|
app.Title("HRM"), |
|
) |
|
th := material.NewTheme(gofont.Collection()) |
|
th.TextSize = unit.Sp(fontSize) |
|
var ops op.Ops |
|
|
|
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() |
|
|
|
state := "starting" |
|
var hr int |
|
var periph ble.Peripheral |
|
var wide bool |
|
periphs := make([]ble.Peripheral, 0) |
|
btns := make([]*widget.Clickable, 0) |
|
backBtn := &widget.Clickable{} |
|
|
|
var page, offpage, scanpage, connpage, hrpage func(gtx C) D |
|
|
|
f := &layout.Flex{Axis: layout.Vertical} |
|
offpage = func(gtx C) D { |
|
return f.Layout(gtx, |
|
layout.Rigid(func(gtx C) D { |
|
return material.Body1(th, "Heart Rate Monitor").Layout(gtx) |
|
}), |
|
layout.Rigid(func(gtx C) D { |
|
return material.Body1(th, "Bluetooth is Powered Off").Layout(gtx) |
|
}), |
|
) |
|
} |
|
|
|
appname := func(gtx C) D { |
|
return layout.Center.Layout(gtx, func(gtx C) D { |
|
return material.Body1(th, "Heart Rate Monitor").Layout(gtx) |
|
}) |
|
} |
|
appstate := func(gtx C) D { |
|
return layout.Center.Layout(gtx, func(gtx C) D { |
|
return material.Body1(th, state).Layout(gtx) |
|
}) |
|
} |
|
periphname := func(gtx C) D { |
|
return layout.Center.Layout(gtx, func(gtx C) D { |
|
return material.Body1(th, periph.Name).Layout(gtx) |
|
}) |
|
} |
|
|
|
leftbar := func(gtx C) D { |
|
f := &layout.Flex{Axis: layout.Vertical} |
|
return f.Layout(gtx, |
|
layout.Rigid(appname), |
|
layout.Rigid(func(gtx C) D { |
|
return material.Body1(th, "").Layout(gtx) |
|
}), |
|
layout.Rigid(appstate), |
|
) |
|
} |
|
|
|
scanlist := func(gtx C) D { |
|
lst := &layout.List{Axis: layout.Vertical} |
|
return lst.Layout(gtx, len(periphs), func(gtx C, i int) D { |
|
gtx.Constraints.Min.X = gtx.Constraints.Max.X |
|
ret := layout.UniformInset(unit.Dp(2)).Layout(gtx, func(gtx C) D { |
|
return material.Button(th, btns[i], periphs[i].Name).Layout(gtx) |
|
}) |
|
if btns[i].Clicked() { |
|
b.StopScan() |
|
periph = periphs[i] |
|
b.Connect(periph) |
|
state = "connecting" |
|
page = connpage |
|
w.Invalidate() |
|
} |
|
return ret |
|
}) |
|
} |
|
|
|
scanpage = func(gtx C) D { |
|
if wide { |
|
f2 := &layout.Flex{Axis: layout.Horizontal} |
|
return f2.Layout(gtx, |
|
layout.Rigid(leftbar), |
|
layout.Flexed(1, scanlist), |
|
) |
|
} else { |
|
return f.Layout(gtx, |
|
layout.Rigid(appname), |
|
layout.Rigid(appstate), |
|
layout.Rigid(scanlist), |
|
) |
|
} |
|
} |
|
|
|
connpage = func(gtx C) D { |
|
return f.Layout(gtx, |
|
layout.Rigid(appname), |
|
layout.Rigid(appstate), |
|
layout.Rigid(periphname), |
|
layout.Rigid(func(gtx C) D { |
|
ret := material.Button(th, backBtn, "Cancel").Layout(gtx) |
|
if backBtn.Clicked() { |
|
ble.CancelConnection(periph) |
|
periphs = periphs[:0] |
|
Config.Autoconnect = "" |
|
saveConfig() |
|
b.Scan() |
|
state = "scanning" |
|
page = scanpage |
|
} |
|
return ret |
|
}), |
|
) |
|
} |
|
|
|
hrcircle := func(gtx C) (int, D) { |
|
var w, h1 float32 |
|
ret := layout.Center.Layout(gtx, func(gtx C) D { |
|
blue := color.NRGBA{0x3f, 0x51, 0xb5, 255} |
|
white := color.NRGBA{255, 255, 255, 255} |
|
w, h1 = float32(gtx.Constraints.Max.X), float32(gtx.Constraints.Max.Y) |
|
if w < h1 { |
|
h1 = w |
|
} |
|
s2 := h1 * 0.9 |
|
p2a := f32.Point{(h1-s2)/2, (h1-s2)/2} |
|
circle := clip.Circle{ |
|
Center: f32.Point{X: h1/2, Y: h1/2}, |
|
Radius: h1 / 2}.Op(gtx.Ops) |
|
paint.FillShape(gtx.Ops, blue, circle) |
|
circle = clip.Circle{ |
|
Center: f32.Point{X: h1/2, Y: h1/2}, |
|
Radius: h1 * 0.9 / 2}.Op(gtx.Ops) |
|
paint.FillShape(gtx.Ops, white, circle) |
|
op.Offset(p2a).Add(gtx.Ops) |
|
gtx.Constraints.Max.X = int(s2) |
|
gtx.Constraints.Max.Y = int(s2) |
|
gtx.Constraints.Min.X = int(s2) |
|
gtx.Constraints.Min.Y = int(s2) |
|
l := material.H1(th, fmt.Sprintf("%d", hr)) |
|
l.Alignment = text.Middle |
|
layout.Center.Layout(gtx, func(gtx C) D { |
|
return l.Layout(gtx) |
|
}) |
|
var ret D |
|
ret.Size = image.Point{int(h1), int(h1)} |
|
return ret |
|
}) |
|
return int(h1), ret |
|
} |
|
|
|
sw := NewStopwatch() |
|
|
|
stopwatch := func(gtx C) D { |
|
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} |
|
return f.Layout(gtx, |
|
layout.Rigid(func(gtx C) D { |
|
return layout.Center.Layout(gtx, func(gtx C) D { |
|
gtx.Constraints.Max.X = 1e6 |
|
return material.H4(th, sw.elapsed.Format("15:04:05.00")).Layout(gtx) |
|
}) |
|
}), |
|
layout.Rigid(func(gtx C) D { |
|
f2 := layout.Flex{Axis: layout.Horizontal} |
|
gtx.Constraints.Min.Y = 100 |
|
ret := f2.Layout(gtx, |
|
layout.Flexed(0.5, func(gtx C) D { |
|
return layout.UniformInset(unit.Dp(2)).Layout(gtx, func(gtx C) D { |
|
return material.Button(th, sw.startstopBtn, startstoptxt).Layout(gtx) |
|
}) |
|
}), |
|
layout.Flexed(0.5, func(gtx C) D { |
|
return layout.UniformInset(unit.Dp(2)).Layout(gtx, func(gtx C) D { |
|
return material.Button(th, sw.resetBtn, "reset").Layout(gtx) |
|
}) |
|
}), |
|
) |
|
if sw.startstopBtn.Clicked() { |
|
if sw.running { |
|
sw.running = false |
|
} else { |
|
sw.running = true |
|
} |
|
} |
|
if sw.resetBtn.Clicked() { |
|
sw.elapsed = time.Time{} |
|
sw.running = false |
|
} |
|
return ret |
|
}), |
|
) |
|
} |
|
|
|
swidth := new(int) |
|
hrpage = func(gtx C) D { |
|
var ret, ret2 D |
|
stopbtn := func(gtx C) D { |
|
gtx.Constraints.Min.Y = 100 |
|
return layout.UniformInset(unit.Dp(2)).Layout(gtx, func(gtx C) D { |
|
return material.Button(th, backBtn, "Disconnect").Layout(gtx) |
|
}) |
|
} |
|
if wide { |
|
f2 := &layout.Flex{Axis: layout.Horizontal} |
|
ret = f2.Layout(gtx, |
|
layout.Flexed(0.4, func(gtx C) D { |
|
f3 := &layout.Flex{Axis: layout.Vertical} |
|
c1 := layout.Rigid(func(gtx C) D { |
|
ret2 = appname(gtx) |
|
*swidth = ret2.Size.X |
|
return ret2 |
|
}) |
|
c2 := layout.Rigid(func(gtx C) D { |
|
ret2 = periphname(gtx) |
|
s2 := ret2.Size.X |
|
if s2 > *swidth { |
|
*swidth = s2 |
|
} |
|
return ret2 |
|
}) |
|
var c3 layout.FlexChild |
|
if gtx.Constraints.Max.X > 370 { |
|
c3 = layout.Rigid(stopwatch) |
|
} else { |
|
c3 = layout.Rigid(func(gtx C) D { |
|
return D{} |
|
}) |
|
} |
|
c4 := layout.Rigid(stopbtn) |
|
return f3.Layout(gtx, |
|
c1, |
|
layout.Rigid(appstate), |
|
c2, |
|
layout.Flexed(1.0, func(gtx C) D { |
|
return material.Body1(th, "").Layout(gtx) |
|
}), |
|
c3, |
|
c4, |
|
) |
|
}), |
|
layout.Flexed(0.6, func(gtx C) D { |
|
_, ret := hrcircle(gtx) |
|
return ret |
|
}), |
|
) |
|
} else { // !wide |
|
c1 := layout.Rigid(func(gtx C) D { |
|
return layout.Center.Layout(gtx, func(gtx C) D { |
|
gtx.Constraints.Min.X = *swidth |
|
gtx.Constraints.Max.X = *swidth |
|
return stopwatch(gtx) |
|
}) |
|
}) |
|
//swheight := gtx.Dimensions.Size.Y |
|
c2 := layout.Rigid(func(gtx C) D { |
|
return layout.Center.Layout(gtx, func(gtx C) D { |
|
gtx.Constraints.Min.X = *swidth |
|
gtx.Constraints.Max.X = *swidth |
|
return stopbtn(gtx) |
|
}) |
|
}) |
|
if gtx.Constraints.Max.Y < 775 { |
|
//gtx.Constraints.Max.Y += swheight |
|
ret = f.Layout(gtx, |
|
layout.Rigid(appname), |
|
layout.Rigid(appstate), |
|
layout.Rigid(periphname), |
|
layout.Flexed(1.0, func(gtx C) D { |
|
*swidth, ret2 = hrcircle(gtx) |
|
return ret2 |
|
}), |
|
c2, |
|
) |
|
} else { |
|
ret = f.Layout(gtx, |
|
layout.Rigid(appname), |
|
layout.Rigid(appstate), |
|
layout.Rigid(periphname), |
|
layout.Flexed(1.0, func(gtx C) D { |
|
*swidth, ret2 = hrcircle(gtx) |
|
return ret2 |
|
}), |
|
c1, |
|
c2, |
|
) |
|
} |
|
} |
|
if backBtn.Clicked() { |
|
ble.Disconnect(periph) |
|
periphs = periphs[:0] |
|
Config.Autoconnect = "" |
|
saveConfig() |
|
b.Scan() |
|
state = "scanning" |
|
page = scanpage |
|
} |
|
return ret |
|
} |
|
|
|
page = offpage |
|
|
|
t := time.NewTicker(time.Second/30) |
|
for { |
|
select { |
|
case <-t.C: |
|
w.Invalidate() |
|
case e := <-b.Events(): |
|
log.Print("<-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.Clickable{}) |
|
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: |
|
os.Exit(0) |
|
case system.FrameEvent: |
|
gtx := layout.NewContext(&ops, e) |
|
if e.Size.X > e.Size.Y { |
|
wide = true |
|
} else { |
|
wide = false |
|
} |
|
resetSysinset(e.Insets) |
|
sysinset.Layout(gtx, func(gtx C) D { |
|
return margin.Layout(gtx, page) |
|
}) |
|
e.Frame(gtx.Ops) |
|
default: |
|
handleEvent(e, b) |
|
} |
|
} |
|
} |
|
}
|
|
|