diff --git a/README.md b/README.md index 334d337..ff51a68 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/cmd/airmatters/main.go b/cmd/klimat/discover.go similarity index 59% rename from cmd/airmatters/main.go rename to cmd/klimat/discover.go index bda605f..7b17f17 100644 --- a/cmd/airmatters/main.go +++ b/cmd/klimat/discover.go @@ -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) } diff --git a/cmd/klimat/main.go b/cmd/klimat/main.go new file mode 100644 index 0000000..9b14139 --- /dev/null +++ b/cmd/klimat/main.go @@ -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] ", + 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) + } +} diff --git a/main.go b/cmd/klimat/publish.go similarity index 84% rename from main.go rename to cmd/klimat/publish.go index 1c24968..ef50bfa 100644 --- a/main.go +++ b/cmd/klimat/publish.go @@ -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" @@ -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()) } @@ -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, @@ -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) } @@ -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()) } @@ -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) } @@ -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) } diff --git a/go.mod b/go.mod index 37f350e..972c515 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 96f4a35..69ff412 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,5 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= @@ -40,6 +41,10 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5 github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys= +github.com/peterbourgon/ff v1.7.0 h1:hknvTgsh90jNBIjPq7xeq32Y9AmSbpXvjrFW4sJwW+A= +github.com/peterbourgon/ff/v3 v3.0.0 h1:eQzEmNahuOjQXfuegsKQTSTDbf4dNvr/eNLrmJhiH7M= +github.com/peterbourgon/ff/v3 v3.0.0/go.mod h1:UILIFjRH5a/ar8TjXYLTkIvSvekZqPm5Eb/qbGk6CT0= github.com/pion/dtls/v2 v2.0.0 h1:Fk+MBhLZ/U1bImzAhmzwbO/pP2rKhtTw8iA934H3ybE= github.com/pion/dtls/v2 v2.0.0/go.mod h1:VkY5VL2wtsQQOG60xQ4lkV5pdn0wwBBTzCfRJqXhp3A= github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=