Skip to content
This repository has been archived by the owner on May 7, 2023. It is now read-only.

Commit

Permalink
Rework the whole CLI setup
Browse files Browse the repository at this point in the history
* Delete cmd/airmatters
* Get rid of main.go -> cmd/klimat/main.go
* Implement publishing and discovery as subcommands
  • Loading branch information
daenney committed May 17, 2020
1 parent 8de1fd1 commit e725303
Show file tree
Hide file tree
Showing 6 changed files with 163 additions and 107 deletions.
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ it access to the outside world. Everything will keep working, including the
AirMatters app, except for it no longer being able to show you historical
values. Doing so also breaks the notifications feature.

## CLI

A command line client is provided, implementing two subcommands:

* `discover`: uses multicast CoAP to find compatible devices on your network
* `publish`: publishes the data to MQTT

## `philips`

The `philips` package contains all the logic to handle communication with
Expand All @@ -24,8 +31,3 @@ in both the protocol and its custom payload encryption scheme.

This package is usable without needing to be invested in the rest of the
Hemtjänst ecosystem.

## Discovery

You can discover devices on your network using `cmd/airmatters`. It implements
the same discovery mechanism as the AirMatters app, using multicast CoAP.
54 changes: 25 additions & 29 deletions cmd/airmatters/main.go → cmd/klimat/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,53 +7,49 @@ import (
"fmt"
"io"
"log"
"os"
"os/signal"
"time"

"github.com/go-ocf/go-coap"
"github.com/go-ocf/go-coap/codes"
"github.com/peterbourgon/ff/v3/ffcli"
"hemtjan.st/klimat/philips"
)

var (
hostPort = flag.String("address", "224.0.1.187:5683", "host:port for multicast discovery")
)
var discoverFlagset = flag.NewFlagSet("klimat discover", flag.ExitOnError)

func main() {
flag.Parse()
type discoverConfig struct {
out io.Writer
host string
}

ctx, cancel := context.WithCancel(context.Background())
func newDiscoverCmd(out io.Writer) *ffcli.Command {
config := &discoverConfig{
out: out,
host: "",
}

c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, os.Kill)
defer func() {
signal.Stop(c)
cancel()
}()
go func() {
select {
case <-c:
log.Print("Received cancellation signal, shutting down...")
cancel()
case <-ctx.Done():
}
}()
discoverFlagset.StringVar(&config.host, "address", "224.0.1.187:5683", "host:port for multicast discovery")

if err := run(ctx, os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "error: %v", err)
os.Exit(1)
return &ffcli.Command{
Name: "discover",
ShortUsage: "discover [flags]",
FlagSet: discoverFlagset,
ShortHelp: "Discover compatible devices on the network",
LongHelp: "The discover command uses multicat CoAP to discover devices " +
"on the network. It implements the same discovery procedure as the " +
"AirMatters app. The devices can be a bit finicky and may not always " +
"respond, so you might have to run this a few times to ensure you get " +
"a reply.",
Exec: config.Exec,
}
}

func run(ctx context.Context, out io.Writer) error {
log.SetOutput(out)

func (c discoverConfig) Exec(ctx context.Context, args []string) error {
client := &coap.MulticastClient{
DialTimeout: 5 * time.Second,
}

conn, err := client.DialWithContext(ctx, *hostPort)
conn, err := client.DialWithContext(ctx, c.host)
if err != nil {
return fmt.Errorf("failed to dial: %w", err)
}
Expand Down
53 changes: 53 additions & 0 deletions cmd/klimat/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package main

import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"

"github.com/peterbourgon/ff/v3/ffcli"
)

var (
rootFlagset = flag.NewFlagSet("klimat", flag.ExitOnError)
)

func main() {
log.SetOutput(os.Stdout)

ctx, cancel := context.WithCancel(context.Background())
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, os.Kill)
defer func() {
signal.Stop(c)
cancel()
}()
go func() {
select {
case <-c:
log.Print("Received cancellation signal, shutting down...")
cancel()
case <-ctx.Done():
}
}()

root := &ffcli.Command{
ShortUsage: "klimat [flags] <subcommand>",
LongHelp: "This CLI can be used to interact with climate devices. " +
"Right now it only supports interafcing with Philips AirCombi " +
"devices.",
FlagSet: rootFlagset,
Subcommands: []*ffcli.Command{newDiscoverCmd(os.Stdout), newPublishCmd(os.Stdout)},
Exec: func(context.Context, []string) error {
return flag.ErrHelp
},
}

if err := root.ParseAndRun(ctx, os.Args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
145 changes: 72 additions & 73 deletions main.go → cmd/klimat/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,12 @@ import (
"log"
"math"
"os"
"os/signal"
"strconv"
"time"

"github.com/go-ocf/go-coap"
"github.com/go-ocf/go-coap/codes"

"github.com/peterbourgon/ff/v3/ffcli"
"hemtjan.st/klimat/philips"
"lib.hemtjan.st/client"
"lib.hemtjan.st/device"
Expand All @@ -29,63 +28,48 @@ const (
)

var (
hostPort = flag.String("address", "127.0.0.1:5683", "host:port for the purifier")
debug = flag.Bool("debug", false, "Debug, prints lots of the raw payloads")
publishFlagset = flag.NewFlagSet("klimat publish", flag.ExitOnError)
)

func connectToDevice(ctx context.Context, address string) *coap.ClientConn {
cl := coap.Client{
Net: "udp",
DialTimeout: 5 * time.Second,
// Internally the time is divided by 6, so this results in a ping/pong every 5s
// which is what the Air Matters app does
KeepAlive: coap.MustMakeKeepAlive(30 * time.Second),
}

conn, err := cl.DialWithContext(ctx, address)
if err != nil {
log.Fatalf("Error dialing: %v", err)
}
return conn
type publishConfig struct {
out io.Writer
host string
mqttcfg func() *mqtt.Config
debug bool
}

func connectMqtt(ctx context.Context, config *mqtt.Config) mqtt.MQTT {
tr, err := mqtt.New(ctx, config)
if err != nil {
log.Fatalf("Error creating MQTT client: %v", err)
}

go func() {
for {
ok, err := tr.Start()
if !ok {
break
}
log.Printf("Error, retrying in 5 seconds: %v", err)
time.Sleep(5 * time.Second)
}
os.Exit(1)
}()
func newPublishCmd(out io.Writer) *ffcli.Command {
mqCfg := mqtt.MustFlags(publishFlagset.String, publishFlagset.Bool)

return tr
}
config := &publishConfig{
out: out,
host: "",
mqttcfg: mqCfg,
}

func getWithTimeout(ctx context.Context, cl *coap.ClientConn, path string) (coap.Message, error) {
timeout, tcancel := context.WithTimeout(ctx, 5*time.Second)
defer tcancel()
return cl.GetWithContext(timeout, path)
publishFlagset.StringVar(&config.host, "address", "localhost:5683", "host:port to connect to")
publishFlagset.BoolVar(&config.debug, "debug", false, "enable debug output")

return &ffcli.Command{
Name: "publish",
ShortUsage: "publish [flags]",
ShortHelp: "Publish sensor data to MQTT",
LongHelp: "The publish command connects to a device over CoAP and " +
"starts to observe it. As it receives updates the device state and " +
"sensor data is extracted and published to MQTT.",
FlagSet: publishFlagset,
Exec: config.Exec,
}
}

func run(ctx context.Context, address string, mqttConfig *mqtt.Config, out io.Writer) error {
log.SetOutput(out)

cl := connectToDevice(ctx, address)
func (c publishConfig) Exec(ctx context.Context, args []string) error {
cl := connectToDevice(ctx, c.host)
devInfo, err := getWithTimeout(ctx, cl, "/sys/dev/info")
if err != nil {
return fmt.Errorf("could not get device info: %w", err)
}
log.Print("Received device info")
if *debug {
if c.debug {
log.Printf("raw info: %s", devInfo.Payload())
}

Expand All @@ -94,11 +78,11 @@ func run(ctx context.Context, address string, mqttConfig *mqtt.Config, out io.Wr
return fmt.Errorf("could not decode info: %w", err)
}

if *debug {
if c.debug {
log.Printf("info: %+v", info)
}

mq := connectMqtt(ctx, mqttConfig)
mq := connectMqtt(ctx, c.mqttcfg())
dev, err := client.NewDevice(&device.Info{
Topic: fmt.Sprintf("climate/%s", info.DeviceID),
Name: info.Name,
Expand Down Expand Up @@ -136,7 +120,7 @@ func run(ctx context.Context, address string, mqttConfig *mqtt.Config, out io.Wr
return fmt.Errorf("failed to post to /sys/dev/sync and get session: %w", err)
}

obs, err := cl.ObserveWithContext(ctx, "/sys/dev/status", handleObserve(dev))
obs, err := cl.ObserveWithContext(ctx, "/sys/dev/status", handleObserve(dev, c.debug))
if err != nil {
return fmt.Errorf("failed to start observe on /sys/dev/status: %w", err)
}
Expand All @@ -149,41 +133,56 @@ func run(ctx context.Context, address string, mqttConfig *mqtt.Config, out io.Wr
return nil
}

func main() {
mqCfg := mqtt.MustFlags(flag.String, flag.Bool)
flag.Parse()
func connectToDevice(ctx context.Context, address string) *coap.ClientConn {
cl := coap.Client{
Net: "udp",
DialTimeout: 5 * time.Second,
// Internally the time is divided by 6, so this results in a ping/pong every 5s
// which is what the Air Matters app does
KeepAlive: coap.MustMakeKeepAlive(30 * time.Second),
}

ctx, cancel := context.WithCancel(context.Background())
conn, err := cl.DialWithContext(ctx, address)
if err != nil {
log.Fatalf("Error dialing: %v", err)
}
return conn
}

func connectMqtt(ctx context.Context, config *mqtt.Config) mqtt.MQTT {
tr, err := mqtt.New(ctx, config)
if err != nil {
log.Fatalf("Error creating MQTT client: %v", err)
}

c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, os.Kill)
defer func() {
signal.Stop(c)
cancel()
}()
go func() {
select {
case <-c:
log.Print("Received cancellation signal, shutting down...")
cancel()
case <-ctx.Done():
for {
ok, err := tr.Start()
if !ok {
break
}
log.Printf("Error, retrying in 5 seconds: %v", err)
time.Sleep(5 * time.Second)
}
os.Exit(1)
}()

err := run(ctx, *hostPort, mqCfg(), os.Stdout)
if err != nil {
fmt.Fprintf(os.Stderr, "%v", err)
os.Exit(1)
}
return tr
}

func getWithTimeout(ctx context.Context, cl *coap.ClientConn, path string) (coap.Message, error) {
timeout, tcancel := context.WithTimeout(ctx, 5*time.Second)
defer tcancel()
return cl.GetWithContext(timeout, path)
}

func handleObserve(dev client.Device) func(req *coap.Request) {
func handleObserve(dev client.Device, debug bool) func(req *coap.Request) {
// If the message was confirmable, confirm it before
// proceeding with decoding it. This ensures that even
// if we hit decoding issues, we always confirm the
// message so the device continues sending new messages
return func(req *coap.Request) {
if *debug {
if debug {
log.Printf("payload: %s", req.Msg.Payload())
}

Expand All @@ -205,7 +204,7 @@ func handleObserve(dev client.Device) func(req *coap.Request) {
log.Println(err)
return
}
if *debug {
if debug {
log.Printf("decoded message: %s", resp)
}

Expand All @@ -215,7 +214,7 @@ func handleObserve(dev client.Device) func(req *coap.Request) {
log.Println(err)
return
}
if *debug {
if debug {
log.Printf("status: %+v", data)
}

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ go 1.14

require (
github.com/go-ocf/go-coap v0.0.0-20200511140640-db6048acfdd3
github.com/peterbourgon/ff/v3 v3.0.0
lib.hemtjan.st v0.7.0
)
Loading

0 comments on commit e725303

Please sign in to comment.