Reorganized code, added google home intergrations and added zigbee2mqtt kettle
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
91
integration/google/command.go
Normal file
91
integration/google/command.go
Normal 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"`
|
||||
}
|
||||
51
integration/google/device.go
Normal file
51
integration/google/device.go
Normal 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{}),
|
||||
}
|
||||
}
|
||||
198
integration/google/handler.go
Normal file
198
integration/google/handler.go
Normal 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"))
|
||||
}
|
||||
}
|
||||
77
integration/google/intent.go
Normal file
77
integration/google/intent.go
Normal 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
|
||||
}
|
||||
88
integration/google/service.go
Normal file
88
integration/google/service.go
Normal 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
|
||||
}
|
||||
79
integration/google/state.go
Normal file
79
integration/google/state.go
Normal 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
|
||||
}
|
||||
10
integration/google/status.go
Normal file
10
integration/google/status.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package google
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusSuccess Status = "SUCCESS"
|
||||
StatusOffline = "OFFLINE"
|
||||
StatusException = "EXCEPTIONS"
|
||||
StatusError = "ERROR"
|
||||
)
|
||||
72
integration/google/trait.go
Normal file
72
integration/google/trait.go
Normal 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
|
||||
}
|
||||
8
integration/google/type.go
Normal file
8
integration/google/type.go
Normal 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
60
integration/hue/events.go
Normal 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
60
integration/hue/hue.go
Normal 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
88
integration/kasa/kasa.go
Normal 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
19
integration/kasa/reply.go
Normal 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
80
integration/mqtt/mqtt.go
Normal 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
46
integration/ntfy/ntfy.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user