Reorganized code, added google home intergrations and added zigbee2mqtt kettle
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2022-11-15 01:03:30 +01:00
parent dace0eba29
commit dd03ae56ee
23 changed files with 2050 additions and 214 deletions

View File

@@ -0,0 +1,91 @@
package google
import (
"encoding/json"
"fmt"
)
type CommandName string
type Execution struct {
Name CommandName
OnOff *CommandOnOffData
StartStop *CommandStartStopData
GetCameraStream *CommandGetCameraStreamData
ActivateScene *CommandActivateSceneData
}
func (c *Execution) UnmarshalJSON(data []byte) error {
var tmp struct {
Name CommandName `json:"command"`
Params json.RawMessage `json:"params,omitempty"`
}
err := json.Unmarshal(data, &tmp)
if err != nil {
return err
}
c.Name = tmp.Name
var details interface{}
switch c.Name {
case CommandOnOff:
c.OnOff = &CommandOnOffData{}
details = c.OnOff
case CommandStartStop:
c.StartStop = &CommandStartStopData{}
details = c.StartStop
case CommandGetCameraStream:
c.GetCameraStream = &CommandGetCameraStreamData{}
details = c.GetCameraStream
case CommandActivateScene:
c.ActivateScene = &CommandActivateSceneData{}
details = c.ActivateScene
default:
return fmt.Errorf("Command (%s) is not implemented", c.Name)
}
err = json.Unmarshal(tmp.Params, details)
if err != nil {
return err
}
return nil
}
// https://developers.google.com/assistant/smarthome/traits/onoff
const CommandOnOff CommandName = "action.devices.commands.OnOff"
type CommandOnOffData struct {
On bool `json:"on"`
}
// https://developers.google.com/assistant/smarthome/traits/startstop
const CommandStartStop CommandName = "action.devices.commands.StartStop"
type CommandStartStopData struct {
Start bool `json:"start"`
Zone string `json:"zone,omitempty"`
MultipleZones []string `json:"multipleZones,omitempty"`
}
// https://developers.google.com/assistant/smarthome/traits/camerastream
const CommandGetCameraStream CommandName = "action.devices.commands.GetCameraStream"
type CommandGetCameraStreamData struct {
StreamToChromecast bool `json:"StreamToChromecast"`
SupportedStreamProtocols []string `json:"SupportedStreamProtocols"`
}
// https://developers.google.com/assistant/smarthome/traits/scene
const CommandActivateScene CommandName = "action.devices.commands.ActivateScene"
type CommandActivateSceneData struct {
Deactivate bool `json:"deactivate"`
}

View File

@@ -0,0 +1,51 @@
package google
type DeviceName struct {
DefaultNames []string `json:"defaultNames,omitempty"`
Name string `json:"name"`
Nicknames []string `json:"nicknames,omitempty"`
}
type DeviceInfo struct {
Manufacturer string `json:"manufacturer,omitempty"`
Model string `json:"model,omitempty"`
HwVersion string `json:"hwVersion,omitempty"`
SwVersion string `json:"swVersion,omitempty"`
}
type OtherDeviceID struct {
AgentID string `json:"agentId,omitempty"`
DeviceID string `json:"deviceId,omitempty"`
}
type Device struct {
ID string `json:"id"`
Type Type `json:"type"`
Traits []Trait `json:"traits"`
Name DeviceName `json:"name"`
WillReportState bool `json:"willReportState"`
NotificationSupportedByAgent bool `json:"notificationSupportedByAgent,omitempty"`
RoomHint string `json:"roomHint,omitempty"`
DeviceInfo DeviceInfo `json:"deviceInfo,omitempty"`
Attributes map[string]interface{} `json:"attributes,omitempty"`
CustomData map[string]interface{} `json:"customDate,omitempty"`
OtherDeviceIDs []OtherDeviceID `json:"otherDeviceIds,omitempty"`
}
func NewDevice(id string, typ Type) *Device {
return &Device{
ID: id,
Type: typ,
Attributes: make(map[string]interface{}),
CustomData: make(map[string]interface{}),
}
}

View File

@@ -0,0 +1,198 @@
package google
import (
"encoding/json"
"log"
"net/http"
)
type DeviceInterface interface {
Sync() *Device
Query() DeviceState
Execute(execution Execution, updatedState *DeviceState) (errCode string, online bool)
}
// https://developers.google.com/assistant/smarthome/reference/intent/sync
type syncResponse struct {
RequestID string `json:"requestId"`
Payload struct {
UserID string `json:"agentUserId"`
ErrorCode string `json:"errorCode,omitempty"`
DebugString string `json:"debugString,omitempty"`
Devices []*Device `json:"devices"`
} `json:"payload"`
}
// https://developers.google.com/assistant/smarthome/reference/intent/query
type queryResponse struct {
RequestID string `json:"requestId"`
Payload struct {
ErrorCode string `json:"errorCode,omitempty"`
DebugString string `json:"debugString,omitempty"`
Devices map[string]DeviceState `json:"devices"`
} `json:"payload"`
}
type executeRespPayload struct {
IDs []string `json:"ids"`
Status Status `json:"status"`
ErrorCode string `json:"errorCode,omitempty"`
States DeviceState `json:"states,omitempty"`
}
type executeResponse struct {
RequestID string `json:"requestId"`
Payload struct {
ErrorCode string `json:"errorCode,omitempty"`
DebugString string `json:"debugString,omitempty"`
Commands []executeRespPayload `json:"commands,omitempty"`
} `json:"payload"`
}
func (s *Service) FullfillmentHandler(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
// Check if we are logged in
{
req, err := http.NewRequest("GET", "https://login.huizinga.dev/api/oidc/userinfo", nil)
if err != nil {
log.Println("Failed to make request to to login server")
w.WriteHeader(http.StatusInternalServerError)
return
}
if len(r.Header["Authorization"]) > 0 {
req.Header.Set("Authorization", r.Header["Authorization"][0])
}
client := &http.Client{}
resp, err := client.Do(req)
// If we get something other than 200, error out
if resp.StatusCode != http.StatusOK {
log.Println("Not logged in...")
w.WriteHeader(resp.StatusCode)
return
}
}
// @TODO Make sure we receive content type json
// @TODO Get this from userinfo, currently the scope is not set up properly to actually receive the username
userID := "Dreaded_X"
fullfimentReq := &FullfillmentRequest{}
err := json.NewDecoder(r.Body).Decode(&fullfimentReq)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("JSON Deserialization failed"))
return
}
if len(fullfimentReq.Inputs) != 1 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Unsupported number of inputs"))
return
}
switch fullfimentReq.Inputs[0].Intent {
case IntentSync:
devices, err := s.provider.Sync(r.Context(), userID)
if err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("Failed to sync"))
}
syncResp := &syncResponse{
RequestID: fullfimentReq.RequestID,
}
syncResp.Payload.UserID = userID
syncResp.Payload.Devices = devices
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(syncResp)
if err != nil {
log.Println("Error serializing", err)
}
case IntentQuery:
states, err := s.provider.Query(r.Context(), userID, fullfimentReq.Inputs[0].Query.Devices)
if err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("Failed to sync"))
}
queryResp := &queryResponse{
RequestID: fullfimentReq.RequestID,
}
queryResp.Payload.Devices = states
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(queryResp)
if err != nil {
log.Println("Error serializing", err)
}
case IntentExecute:
response, err := s.provider.Execute(r.Context(), userID, fullfimentReq.Inputs[0].Execute.Commands)
if err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("Failed to sync"))
}
executeResp := &executeResponse{
RequestID: fullfimentReq.RequestID,
}
if len(response.UpdatedDevices) > 0 {
c := executeRespPayload{
Status: StatusSuccess,
States: response.UpdatedState,
}
for _, id := range response.UpdatedDevices {
c.IDs = append(c.IDs, id)
}
executeResp.Payload.Commands = append(executeResp.Payload.Commands, c)
}
if len(response.OfflineDevices) > 0 {
c := executeRespPayload{
Status: StatusOffline,
}
for _, id := range response.UpdatedDevices {
c.IDs = append(c.IDs, id)
}
executeResp.Payload.Commands = append(executeResp.Payload.Commands, c)
}
for errCode, details := range response.FailedDevices {
c := executeRespPayload{
Status: StatusError,
ErrorCode: errCode,
}
for _, id := range details.Devices {
c.IDs = append(c.IDs, id)
}
executeResp.Payload.Commands = append(executeResp.Payload.Commands, c)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(executeResp)
if err != nil {
log.Println("Error serializing", err)
}
default:
log.Println("Intent is not implemented")
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Not implemented for now"))
}
}

View File

@@ -0,0 +1,77 @@
package google
import (
"encoding/json"
)
type Intent string
const (
IntentSync Intent = "action.devices.SYNC"
IntentQuery = "action.devices.QUERY"
IntentExecute = "action.devices.EXECUTE"
)
type DeviceHandle struct {
ID string `json:"id"`
CustomData map[string]interface{} `json:"customData,omitempty"`
}
type queryPayload struct {
Devices []DeviceHandle `json:"devices"`
}
type Command struct {
Devices []DeviceHandle `json:"devices"`
Execution []Execution `json:"execution"`
}
type executePayload struct {
Commands []Command `json:"commands"`
}
type fullfilmentInput struct {
Intent Intent
Query *queryPayload
Execute *executePayload
}
type FullfillmentRequest struct {
RequestID string `json:"requestId"`
Inputs []fullfilmentInput `json:"inputs"`
}
func (i *fullfilmentInput) UnmarshalJSON(data []byte) error {
var tmp struct {
Intent Intent `json:"intent"`
Payload json.RawMessage `json:"payload"`
}
err := json.Unmarshal(data, &tmp)
if err != nil {
return err
}
i.Intent = tmp.Intent
switch i.Intent {
case IntentQuery:
payload := &queryPayload{}
err = json.Unmarshal(tmp.Payload, payload)
if err != nil {
return err
}
i.Query = payload
case IntentExecute:
payload := &executePayload{}
err = json.Unmarshal(tmp.Payload, payload)
if err != nil {
return err
}
i.Execute = payload
}
return nil
}

View File

@@ -0,0 +1,88 @@
package google
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/google/uuid"
"google.golang.org/api/homegraph/v1"
)
type ExecuteResponse struct {
UpdatedState DeviceState
UpdatedDevices []string
OfflineDevices []string
// The key is the errorCode that is associated with the devices
FailedDevices map[string]struct {
Devices []string
}
}
type Provider interface {
Sync(context.Context, string) ([]*Device, error)
Query(context.Context, string, []DeviceHandle) (map[string]DeviceState, error)
Execute(context.Context, string, []Command) (*ExecuteResponse, error)
}
type Service struct {
provider Provider
deviceService *homegraph.DevicesService
}
func NewService(provider Provider, service *homegraph.Service) *Service {
return &Service{
provider: provider,
deviceService: homegraph.NewDevicesService(service),
}
}
func (s *Service) RequestSync(ctx context.Context, userID string) error {
call := s.deviceService.RequestSync(&homegraph.RequestSyncDevicesRequest{
AgentUserId: userID,
})
call.Context(ctx)
resp, err := call.Do()
if err != nil {
return err
}
if resp.ServerResponse.HTTPStatusCode != http.StatusOK {
return errors.New(fmt.Sprintf("sync failed: %d", resp.ServerResponse.HTTPStatusCode))
}
return nil
}
func (s *Service) ReportState(ctx context.Context, userID string, states map[string]DeviceState) error {
j, err := json.Marshal(states)
if err != nil {
return err
}
call := s.deviceService.ReportStateAndNotification(&homegraph.ReportStateAndNotificationRequest{
AgentUserId: userID,
EventId: uuid.New().String(),
RequestId: uuid.New().String(),
Payload: &homegraph.StateAndNotificationPayload{
Devices: &homegraph.ReportStateAndNotificationDevice{
States: j,
},
},
})
call.Context(ctx)
resp, err := call.Do()
if err != nil {
return err
}
if resp.ServerResponse.HTTPStatusCode != http.StatusOK {
return errors.New(fmt.Sprintf("report failed: %d", resp.ServerResponse.HTTPStatusCode))
}
return nil
}

View File

@@ -0,0 +1,79 @@
package google
import (
"encoding/json"
)
type DeviceState struct {
Online bool
Status Status
state map[string]interface{}
}
func (ds DeviceState) MarshalJSON() ([]byte, error) {
payload := make(map[string]interface{})
payload["online"] = ds.Online
if len(ds.Status) > 0 {
payload["status"] = ds.Status
}
for k, v := range ds.state {
payload[k] = v
}
return json.Marshal(payload)
}
func NewDeviceState(online bool) DeviceState {
return DeviceState{
Online: online,
state: make(map[string]interface{}),
}
}
// https://developers.google.com/assistant/smarthome/traits/onoff
func (ds DeviceState) RecordOnOff(on bool) DeviceState {
ds.state["on"] = on
return ds
}
// https://developers.google.com/assistant/smarthome/traits/runcycle
func (ds DeviceState) RecordRunCycle(state int) DeviceState {
if state == 0 {
} else if state == 1 {
ds.state["currentRunCycle"] = []struct{
CurrentCycle string `json:"currentCycle"`
Lang string `json:"lang"`
}{
{
CurrentCycle: "Wash",
Lang: "en",
},
}
} else if state == 2 {
ds.state["currentTotalRemainingTime"] = 0
}
return ds
}
// https://developers.google.com/assistant/smarthome/traits/startstop
func (ds DeviceState) RecordStartStop(running bool, paused ...bool) DeviceState {
ds.state["isRunning"] = running
if len(paused) > 0 {
ds.state["isPaused"] = paused[0]
}
return ds
}
// https://developers.google.com/assistant/smarthome/traits/camerastream
func (ds DeviceState) RecordCameraStream(url string) DeviceState {
ds.state["cameraStreamProtocol"] = "progressive_mp4"
ds.state["cameraStreamAccessUrl"] = url
return ds
}

View File

@@ -0,0 +1,10 @@
package google
type Status string
const (
StatusSuccess Status = "SUCCESS"
StatusOffline = "OFFLINE"
StatusException = "EXCEPTIONS"
StatusError = "ERROR"
)

View File

@@ -0,0 +1,72 @@
package google
import "github.com/kr/pretty"
type Trait string
// https://developers.google.com/assistant/smarthome/traits/onoff
const TraitOnOff Trait = "action.devices.traits.OnOff"
func (d *Device) AddOnOffTrait(onlyCommand bool, onlyQuery bool) *Device {
d.Traits = append(d.Traits, TraitOnOff)
if onlyCommand {
d.Attributes["commandOnlyOnOff"] = true
}
if onlyQuery {
d.Attributes["queryOnlyOnOff"] = true
}
return d
}
// https://developers.google.com/assistant/smarthome/traits/startstop
const TraitStartStop = "action.devices.traits.StartStop"
func (d *Device) AddStartStopTrait(pausable bool) *Device {
d.Traits = append(d.Traits, TraitStartStop)
if pausable {
d.Attributes["pausable"] = true
}
return d
}
// https://developers.google.com/assistant/smarthome/traits/onoff
const TraitRunCycle = "action.devices.traits.RunCycle"
func (d *Device) AddRunCycleTrait() *Device {
d.Traits = append(d.Traits, TraitRunCycle)
return d
}
// https://developers.google.com/assistant/smarthome/traits/camerastream
const TraitCameraStream = "action.devices.traits.CameraStream"
func (d *Device) AddCameraStreamTrait(authTokenNeeded bool, supportedProtocols ...string) *Device {
d.Traits = append(d.Traits, TraitCameraStream)
if len(supportedProtocols) > 0 {
d.Attributes["cameraStreamSupportedProtocols"] = supportedProtocols
}
d.Attributes["cameraStreamNeedAuthToken"] = authTokenNeeded
pretty.Logln(d)
return d
}
// https://developers.google.com/assistant/smarthome/traits/scene
const TraitScene = "action.devices.traits.Scene"
func (d *Device) AddSceneTrait(reversible bool) *Device {
d.Traits = append(d.Traits, TraitScene)
if reversible {
d.Attributes["sceneReversible"] = true
}
return d
}

View File

@@ -0,0 +1,8 @@
package google
type Type string
// https://developers.google.com/assistant/smarthome/guides
const (
TypeKettle = "action.devices.types.KETTLE"
)

60
integration/hue/events.go Normal file
View File

@@ -0,0 +1,60 @@
package hue
import (
"time"
)
type EventType string
const (
Update EventType = "update"
)
type DeviceType string
const (
Light DeviceType = "light"
GroupedLight = "grouped_light"
Button = "button"
)
type LastEvent string
const (
InitialPress LastEvent = "initial_press"
ShortPress = "short_press"
)
type device struct {
ID string `json:"id"`
IDv1 string `json:"id_v1"`
Owner struct {
Rid string `json:"rid"`
Rtype string `json:"rtype"`
} `json:"owner"`
Type DeviceType `json:"type"`
On *struct {
On bool `json:"on"`
} `json:"on"`
Dimming *struct {
Brightness float32 `json:"brightness"`
} `json:"dimming"`
ColorTemperature *struct {
Mirek int `json:"mirek"`
MirekValid bool `json:"mirek_valid"`
} `json:"color_temperature"`
Button *struct {
LastEvent LastEvent `json:"last_event"`
}
}
type Event struct {
CreationTime time.Time `json:"creationtime"`
Data []device `json:"data"`
ID string `json:"id"`
Type EventType `json:"type"`
}

60
integration/hue/hue.go Normal file
View File

@@ -0,0 +1,60 @@
package hue
import (
"bytes"
"crypto/tls"
"fmt"
"net/http"
"os"
"github.com/r3labs/sse/v2"
)
type Hue struct {
ip string
login string
Events chan *sse.Event
}
func (hue *Hue) SetFlag(id int, value bool) {
url := fmt.Sprintf("http://%s/api/%s/sensors/%d/state", hue.ip, hue.login, id)
var data []byte
if value {
data = []byte(`{ "flag": true }`)
} else {
data = []byte(`{ "flag": false }`)
}
client := &http.Client{}
req, err := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(data))
if err != nil {
panic(err)
}
_, err = client.Do(req)
if err != nil {
panic(err)
}
}
func Connect() Hue {
login, _ := os.LookupEnv("HUE_BRIDGE")
ip, _ := os.LookupEnv("HUE_IP")
hue := Hue{ip: ip, login: login, Events: make(chan *sse.Event)}
// Subscribe to eventstream
client := sse.NewClient(fmt.Sprintf("https://%s/eventstream/clip/v2", hue.ip))
client.Headers["hue-application-key"] = hue.login
client.Connection.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
err := client.SubscribeChanRaw(hue.Events)
if err != nil {
panic(err)
}
return hue
}

88
integration/kasa/kasa.go Normal file
View File

@@ -0,0 +1,88 @@
package kasa
import (
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"net"
)
func encrypt(data []byte) []byte {
var key byte = 171
buf := new(bytes.Buffer)
binary.Write(buf, binary.BigEndian, uint32(len(data)))
for _, c := range []byte(data) {
a := key ^ c
key = a
buf.WriteByte(a)
}
return buf.Bytes()
}
func decrypt(data []byte) string {
var key byte = 171
buf := new(bytes.Buffer)
binary.Write(buf, binary.BigEndian, uint32(len(data)))
for _, c := range data {
a := key ^ c
key = c
buf.WriteByte(a)
}
return string(buf.Bytes())
}
type Kasa struct {
ip string
}
func New(ip string) Kasa {
return Kasa{ip}
}
func (kasa *Kasa) sendCmd(cmd cmd) {
con, err := net.Dial("tcp", fmt.Sprintf("%s:9999", kasa.ip))
if err != nil {
panic(err)
}
defer con.Close()
b, err := json.Marshal(cmd)
if err != nil {
panic(err)
}
_, err = con.Write(encrypt(b))
if err != nil {
panic(err)
}
resp := make([]byte, 2048)
_, err = con.Read(resp)
if err != nil {
panic(err)
}
var reply reply
json.Unmarshal(resp, &reply)
if reply.System.SetRelayState.ErrCode != 0 {
fmt.Println(reply)
fmt.Println(resp)
}
}
func (kasa *Kasa) SetState(on bool) {
var cmd cmd
if on {
cmd.System.SetRelayState.State = 1
}
kasa.sendCmd(cmd)
}

19
integration/kasa/reply.go Normal file
View File

@@ -0,0 +1,19 @@
package kasa
type errCode struct {
ErrCode int
}
type reply struct {
System struct {
SetRelayState errCode `json:"set_relay_state"`
} `json:"system"`
}
type cmd struct {
System struct {
SetRelayState struct {
State int `json:"state"`
} `json:"set_relay_state"`
} `json:"system"`
}

80
integration/mqtt/mqtt.go Normal file
View File

@@ -0,0 +1,80 @@
package mqtt
import (
"fmt"
"os"
"github.com/eclipse/paho.mqtt.golang"
)
type MQTT struct {
client mqtt.Client
}
// This is the default message handler, it just prints out the topic and message
var defaultHandler mqtt.MessageHandler = func(client mqtt.Client, msg mqtt.Message) {
fmt.Printf("TOPIC: %s\n", msg.Topic())
fmt.Printf("MSG: %s\n", msg.Payload())
}
func Connect() MQTT {
host, ok := os.LookupEnv("MQTT_HOST")
if !ok {
host = "localhost"
}
port, ok := os.LookupEnv("MQTT_PORT")
if !ok {
port = "1883"
}
user, ok := os.LookupEnv("MQTT_USER")
if !ok {
user = "test"
}
pass, ok := os.LookupEnv("MQTT_PASS")
if !ok {
pass = "test"
}
clientID, ok := os.LookupEnv("MQTT_CLIENT_ID")
if !ok {
clientID = "automation"
}
opts := mqtt.NewClientOptions().AddBroker(fmt.Sprintf("%s:%s", host, port))
opts.SetClientID(clientID)
opts.SetDefaultPublishHandler(defaultHandler)
opts.SetUsername(user)
opts.SetPassword(pass)
client := mqtt.NewClient(opts)
if token := client.Connect(); token.Wait() && token.Error() != nil {
panic(token.Error())
}
m := MQTT{client: client}
return m
}
func (m *MQTT) Disconnect() {
if token := m.client.Unsubscribe("automation/presence/+"); token.Wait() && token.Error() != nil {
fmt.Println(token.Error())
os.Exit(1)
}
m.client.Disconnect(250)
}
func (m *MQTT) AddHandler(topic string, handler func(client mqtt.Client, msg mqtt.Message)) {
if token := m.client.Subscribe(topic, 0, handler); token.Wait() && token.Error() != nil {
fmt.Println(token.Error())
os.Exit(1)
}
}
func (m *MQTT) Publish(topic string, qos byte, retained bool, payload interface{}) {
if token := m.client.Publish(topic, qos, retained, payload); token.Wait() && token.Error() != nil {
fmt.Println(token.Error())
// Do not exit here as it might break during production, just log the error
// os.Exit(1)
}
}

46
integration/ntfy/ntfy.go Normal file
View File

@@ -0,0 +1,46 @@
package ntfy
import (
"fmt"
"net/http"
"os"
"strings"
)
type ntfy struct {
topic string
}
func (ntfy *ntfy) Presence(home bool) {
// @TODO Maybe add list the devices that are home currently?
var description string
var actions string
if home {
description = "Home"
actions = "broadcast, Set as away, extras.cmd=presence, extras.state=0, clear=true"
} else {
description = "Away"
actions = "broadcast, Set as home, extras.cmd=presence, extras.state=1, clear=true"
}
req, err := http.NewRequest("POST", fmt.Sprintf("https://ntfy.sh/%s", ntfy.topic), strings.NewReader(description))
if err != nil {
panic(err)
}
req.Header.Set("Title", "Presence")
req.Header.Set("Tags", "house")
req.Header.Set("Actions", actions)
req.Header.Set("Priority", "1")
http.DefaultClient.Do(req)
}
func Connect() ntfy {
topic, _ := os.LookupEnv("NTFY_TOPIC")
ntfy := ntfy{topic}
// @TODO Make sure the topic is valid?
return ntfy
}