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"
)