From 9d58089c1d4352e9cdf7e06b61ab92ae44c691fa Mon Sep 17 00:00:00 2001 From: nasa9084 Date: Sat, 12 Nov 2022 01:17:39 +0900 Subject: [PATCH] support v1.1 API --- device.go | 430 ++++++++++++++++++++++++++++++++++++++---------- device_test.go | 180 ++++++++------------ example_test.go | 7 +- go.mod | 5 +- go.sum | 2 + scene.go | 9 +- scene_test.go | 28 +--- switchbot.go | 39 ++++- webhook.go | 106 ++++++++++-- webhook_test.go | 302 ++++++++++++++++++++++++++++++++-- 10 files changed, 867 insertions(+), 241 deletions(-) diff --git a/device.go b/device.go index 5df0f05..c8a283d 100644 --- a/device.go +++ b/device.go @@ -7,6 +7,7 @@ import ( "fmt" "strconv" "strings" + "time" ) // DeviceService handles API calls related to devices. @@ -48,8 +49,39 @@ type Device struct { IsGrouped bool `json:"group"` IsMaster bool `json:"master"` OpenDirection string `json:"openDirection"` + GroupName bool `json:"groupName"` // is this Boolean, right? + LockDeviceIDs []string `json:"lockDeviceIds"` + LockDeviceID string `json:"lockDeviceId"` + KeyList []KeyListItem `json:"keyList"` } +// KeyListItem is an item for keyList, which maintains a list of passcodes. +type KeyListItem struct { + ID int `json:"id"` + Name string `json:"name"` + Type PasscodeType `json:"type"` + Password string `json:"password"` + IV string `json:"iv"` + Status PasscodeStatus `json:"status"` + CreateTime int64 `json:"createTime"` +} + +type PasscodeType string + +const ( + PermanentPasscode PasscodeType = "permanent" + TimeLimitPasscode PasscodeType = "timeLimit" + DisposablePasscode PasscodeType = "disposable" + UrgentPasscode PasscodeType = "urgent" +) + +type PasscodeStatus string + +const ( + PasscodeStatusValid PasscodeStatus = "normal" + PasscodeStautsInvalid PasscodeStatus = "expired" +) + // InfraredDevice represents a virtual infrared remote device. type InfraredDevice struct { ID string `json:"deviceId"` @@ -65,7 +97,7 @@ type InfraredDevice struct { // air conditioner, TV, light, or so on. // See also https://github.com/OpenWonderLabs/SwitchBotAPI/blob/7a68353d84d07d439a11cb5503b634f24302f733/README.md#get-device-list func (svc *DeviceService) List(ctx context.Context) ([]Device, []InfraredDevice, error) { - const path = "/v1.0/devices" + const path = "/v1.1/devices" resp, err := svc.c.get(ctx, path) if err != nil { @@ -80,6 +112,8 @@ func (svc *DeviceService) List(ctx context.Context) ([]Device, []InfraredDevice, if response.StatusCode == 190 { return nil, nil, errors.New("device internal error due to device states not synchronized with server") + } else if response.StatusCode != 100 { + return nil, nil, fmt.Errorf("unknown error %d from device list API", response.StatusCode) } return response.Body.DeviceList, response.Body.InfraredRemoteList, nil @@ -92,40 +126,40 @@ type deviceStatusResponse struct { } type DeviceStatus struct { - ID string `json:"deviceId"` - Type PhysicalDeviceType `json:"deviceType"` - Hub string `json:"hubDeviceId"` - Power PowerState `json:"power"` - Humidity int `json:"humidity"` - Temperature float64 `json:"temperature"` - NebulizationEfficiency int `json:"nebulizationEfficiency"` - IsAuto bool `json:"auto"` - IsChildLock bool `json:"childLock"` - IsSound bool `json:"sound"` - IsCalibrated bool `json:"calibrate"` - IsGrouped bool `json:"group"` - IsMoving bool `json:"moving"` - SlidePosition int `json:"slidePosition"` - FanMode int `json:"mode"` - FanSpeed int `json:"speed"` - IsShaking bool `json:"shaking"` - ShakeCenter int `json:"shakeCenter"` - ShakeRange int `json:"shakeRange"` - MoveDetected bool `json:"moveDetected"` - Brightness BrightnessState `json:"brightness"` - OpenState OpenState `json:"openState"` - Color string `json:"color"` - ColorTemperature int `json:"colorTemperature"` - LackWater bool `json:"lackWater"` - Voltage int `json:"voltage"` - Weight int `json:"weight"` - ElectricityOfDay int `json:"electricityOfDay"` - ElectricCurrent int `json:"electricCurrent"` - LockState string `json:"lockState"` - DoorState string `json:"doorState"` - WorkingStatus string `json:"workingStatus"` - OnlineStatus string `json:"onlineStatus"` - Battery int `json:"battery"` + ID string `json:"deviceId"` + Type PhysicalDeviceType `json:"deviceType"` + Hub string `json:"hubDeviceId"` + Power PowerState `json:"power"` + Humidity int `json:"humidity"` + Temperature float64 `json:"temperature"` + NebulizationEfficiency int `json:"nebulizationEfficiency"` + IsAuto bool `json:"auto"` + IsChildLock bool `json:"childLock"` + IsSound bool `json:"sound"` + IsCalibrated bool `json:"calibrate"` + IsGrouped bool `json:"group"` + IsMoving bool `json:"moving"` + SlidePosition int `json:"slidePosition"` + FanMode int `json:"mode"` + FanSpeed int `json:"speed"` + IsShaking bool `json:"shaking"` + ShakeCenter int `json:"shakeCenter"` + ShakeRange int `json:"shakeRange"` + IsMoveDetected bool `json:"moveDetected"` + Brightness BrightnessState `json:"brightness"` + OpenState OpenState `json:"openState"` + Color string `json:"color"` + ColorTemperature int `json:"colorTemperature"` + IsLackWater bool `json:"lackWater"` + Voltage int `json:"voltage"` + Weight int `json:"weight"` + ElectricityOfDay int `json:"electricityOfDay"` + ElectricCurrent float64 `json:"electricCurrent"` + LockState string `json:"lockState"` + DoorState string `json:"doorState"` + WorkingStatus CleanerWorkingStatus `json:"workingStatus"` + OnlineStatus CleanerOnlineStatus `json:"onlineStatus"` + Battery int `json:"battery"` } type PowerState string @@ -195,13 +229,35 @@ const ( AmbientBrightnessDim AmbientBrightness = "dim" ) +type CleanerOnlineStatus string + +const ( + CleanerOnline CleanerOnlineStatus = "online" + CleanerOffline CleanerOnlineStatus = "offline" +) + +type CleanerWorkingStatus string + +const ( + CleanerStandBy CleanerWorkingStatus = "StandBy" + CleanerClearing CleanerWorkingStatus = "Clearing" + CleanerPaused CleanerWorkingStatus = "Paused" + CleanerGotoChargeBase CleanerWorkingStatus = "GotoChargeBase" + CleanerCharging CleanerWorkingStatus = "Charging" + CleanerChargeDone CleanerWorkingStatus = "ChargeDone" + CleanerDormant CleanerWorkingStatus = "Dormant" + CleanerInTrouble CleanerWorkingStatus = "InTrouble" + CleanerInRemoteControl CleanerWorkingStatus = "InRemoteControl" + CleanerInDustCollecting CleanerWorkingStatus = "InDustCollecting" +) + // Status get the status of a physical device that has been added to the current // user's account. Physical devices refer to the SwitchBot products. // The first given argument `id` is a device ID which can be retrieved by // (*Client).Device().List() function. // See also https://github.com/OpenWonderLabs/SwitchBotAPI/blob/7a68353d84d07d439a11cb5503b634f24302f733/README.md#get-device-status func (svc *DeviceService) Status(ctx context.Context, id string) (DeviceStatus, error) { - path := "/v1.0/devices/" + id + "/status" + path := "/v1.1/devices/" + id + "/status" resp, err := svc.c.get(ctx, path) if err != nil { @@ -216,6 +272,8 @@ func (svc *DeviceService) Status(ctx context.Context, id string) (DeviceStatus, if response.StatusCode == 190 { return DeviceStatus{}, errors.New("device internal error due to device states not synchronized with server") + } else if response.StatusCode != 100 { + return DeviceStatus{}, fmt.Errorf("unknown error %d from device list API", response.StatusCode) } return response.Body, nil @@ -238,7 +296,7 @@ type deviceCommandResponse struct { } func (svc *DeviceService) Command(ctx context.Context, id string, cmd Command) error { - path := "/v1.0/devices/" + id + "/commands" + path := "/v1.1/devices/" + id + "/commands" resp, err := svc.c.post(ctx, path, cmd.Request()) if err != nil { @@ -273,9 +331,9 @@ func (req DeviceCommandRequest) Request() DeviceCommandRequest { return req } -// TurnOn returns a new Command which turns on Bot, Plug, Curtain, Humidifier, or so on. +// TurnOnCommand returns a new Command which turns on Bot, Plug, Curtain, Humidifier, or so on. // For curtain devices, turn on is equivalent to set position to 0. -func TurnOn() Command { +func TurnOnCommand() Command { return DeviceCommandRequest{ Command: "turnOn", Parameter: "default", @@ -283,9 +341,9 @@ func TurnOn() Command { } } -// TurnOff returns a nw Command which turns off Bot, plug, Curtain, Humidifier, or so on. +// TurnOffCommand returns a nw Command which turns off Bot, plug, Curtain, Humidifier, or so on. // For curtain devices, turn off is equivalent to set position to 100. -func TurnOff() Command { +func TurnOffCommand() Command { return DeviceCommandRequest{ Command: "turnOff", Parameter: "default", @@ -295,8 +353,8 @@ func TurnOff() Command { type pressCommand struct{} -// Press returns a new command which trigger Bot's press command. -func Press() Command { +// PressCommand returns a new command which trigger Bot's press command. +func PressCommand() Command { return DeviceCommandRequest{ Command: "press", Parameter: "default", @@ -319,7 +377,7 @@ const ( SilentMode ) -// SetPosition returns a new Command which sets curtain devices' position. +// SetPositionCommand returns a new Command which sets curtain devices' position. // The third argument `position` can be take 0 - 100 value, 0 means opened // and 100 means closed. The position value will be treated as 0 if the given // value is less than 0, or treated as 100 if the given value is over 100. @@ -330,26 +388,18 @@ func SetPosition(index int, mode SetPositionMode, position int) Command { position = 100 } - return &setPositionCommand{ - index: index, - mode: mode, - position: position, - } -} - -func (cmd *setPositionCommand) Request() DeviceCommandRequest { var parameter string - parameter += strconv.Itoa(cmd.index) + "," + parameter += strconv.Itoa(index) + "," - switch cmd.mode { + switch mode { case PerformanceMode, SilentMode: - parameter += strconv.Itoa(int(cmd.mode)) + parameter += strconv.Itoa(int(mode)) default: parameter += "ff" } parameter += "," - parameter += strconv.Itoa(cmd.position) + parameter += strconv.Itoa(position) return DeviceCommandRequest{ Command: "setPosition", @@ -358,6 +408,24 @@ func (cmd *setPositionCommand) Request() DeviceCommandRequest { } } +// LockCommand returns a new Command which rotates the Lock device to locked position. +func LockCommand() Command { + return DeviceCommandRequest{ + Command: "lock", + Parameter: "default", + CommandType: "command", + } +} + +// LockCommand returns a new Command which rotates the Lock device to unlocked position. +func UnlockCommand() Command { + return DeviceCommandRequest{ + Command: "unlock", + Parameter: "default", + CommandType: "command", + } +} + type HumidifierMode int const ( @@ -367,10 +435,10 @@ const ( HighMode HumidifierMode = 103 ) -// SetMode sets a mode for Humidifier. mode can be take one of HumidifierMode +// SetModeCommand returns a new Command which sets a mode for Humidifier. mode can be take one of HumidifierMode // constants or 0 - 100 value. To use exact value 0 - 100, you need to pass like // HumidifierMode(38). -func SetMode(mode HumidifierMode) Command { +func SetModeCommand(mode HumidifierMode) Command { var parameter string if mode == AutoMode { @@ -393,8 +461,8 @@ const ( NaturalFanMode SmartFanMode = 2 ) -// SetAllStatus returns a commend which sets all status for smart fan. -func SetAllStatus(power PowerState, fanMode SmartFanMode, fanSpeed, shakeRange int) Command { +// SetAllStatusCommand returns a new Commend which sets all status for smart fan. +func SetAllStatusCommand(power PowerState, fanMode SmartFanMode, fanSpeed, shakeRange int) Command { return DeviceCommandRequest{ Command: "setAllStatus", Parameter: fmt.Sprintf("%s,%d,%d,%d", power.ToLower(), fanMode, fanSpeed, shakeRange), @@ -402,8 +470,8 @@ func SetAllStatus(power PowerState, fanMode SmartFanMode, fanSpeed, shakeRange i } } -// Toggle returns a command which toggle state of color bulb, strip light or plug mini. -func Toggle() Command { +// ToggleCommand returns a new Command which toggles state of color bulb, strip light or plug mini. +func ToggleCommand() Command { return DeviceCommandRequest{ Command: "toggle", Parameter: "default", @@ -411,8 +479,8 @@ func Toggle() Command { } } -// SetBrightness returns a command which set brightness of color bulb or strip light. -func SetBrightness(brightness int) Command { +// SetBrightnessCommand returns a new Command which set brightness of color bulb, strip light, or ceiling ligths. +func SetBrightnessCommand(brightness int) Command { return DeviceCommandRequest{ Command: "setBrightness", Parameter: strconv.Itoa(brightness), @@ -420,8 +488,8 @@ func SetBrightness(brightness int) Command { } } -// SetColor returns a command which set RGB color value of color bulb or strip light. -func SetColor(r, g, b int) Command { +// SetColorCommand returns a new Command which set RGB color value of color bulb or strip light. +func SetColorCommand(r, g, b int) Command { return DeviceCommandRequest{ Command: "setColor", Parameter: fmt.Sprintf("%d:%d:%d", r, g, b), @@ -429,8 +497,8 @@ func SetColor(r, g, b int) Command { } } -// SetColorTemperature returns a command which set color temperature of color bulb. -func SetColorTemperature(temperature int) Command { +// SetColorTemperatureCommand returns a new Command which set color temperature of color bulb or ceiling lights. +func SetColorTemperatureCommand(temperature int) Command { return DeviceCommandRequest{ Command: "setColorTemperature", Parameter: strconv.Itoa(temperature), @@ -438,8 +506,8 @@ func SetColorTemperature(temperature int) Command { } } -// Start returns a command which starts vacuuming. -func Start() Command { +// StartCommand returns a new Command which starts vacuuming. +func StartCommand() Command { return DeviceCommandRequest{ Command: "start", Parameter: "default", @@ -447,8 +515,8 @@ func Start() Command { } } -// Stop returns a command which stops vacuuming. -func Stop() Command { +// StopCommand returns a new Command which stops vacuuming. +func StopCommand() Command { return DeviceCommandRequest{ Command: "stop", Parameter: "default", @@ -456,8 +524,8 @@ func Stop() Command { } } -// Dock returns a command which returns robot vacuum cleaner to charging dock. -func Dock() Command { +// DockCommand returns a new Command which returns robot vacuum cleaner to charging dock. +func DockCommand() Command { return DeviceCommandRequest{ Command: "dock", Parameter: "default", @@ -474,8 +542,8 @@ const ( MaxVacuumPowerLevel VacuumPowerLevel = 3 ) -// PowLevel returns a command which sets suction power level of robot vacuum cleaner. -func PowLevel(level VacuumPowerLevel) Command { +// PowLevelCommand returns a new Command which sets suction power level of robot vacuum cleaner. +func PowLevelCommand(level VacuumPowerLevel) Command { return DeviceCommandRequest{ Command: "PowLevel", Parameter: strconv.Itoa(int(level)), @@ -483,8 +551,60 @@ func PowLevel(level VacuumPowerLevel) Command { } } -// ButtonPush returns a command which triggers button push. -func ButtonPush(name string) Command { +type createKeyCommandParameters struct { + Name string `json:"name"` + Type PasscodeType `json:"type"` + Password string `json:"password"` + Start int64 `json:"startTime"` + End int64 `json:"endTime"` +} + +// CreateKeyCommand returns a new Command which creates a new key for Lock devices. +// Due to security concerns, the created passcodes will be stored locally so you need +// to get the result through webhook. +// A name is a unique name for the passcode, duplicates under the same device are not allowed. +// A password must be a 6 to 12 digit passcode. +// Start time and end time are required for one-time passcode (DisposablePasscode) or temporary +// passcode (TimeLimitPasscode). +func CreateKeyCommand(name string, typ PasscodeType, password string, start, end time.Time) (Command, error) { + if len(password) < 6 || 12 < len(password) { + return nil, fmt.Errorf("the length of password must be 6 to 12 but %d", len(password)) + } + + if (typ == TimeLimitPasscode || typ == DisposablePasscode) && (start.IsZero() || end.IsZero()) { + return nil, fmt.Errorf("when passcode type is %s, startTime and endTime is required but either/both is zero value", typ) + } + + params := createKeyCommandParameters{ + Name: name, + Type: typ, + Password: password, + Start: start.Unix(), + End: end.Unix(), + } + data, err := json.Marshal(params) + if err != nil { + return nil, err + } + + return DeviceCommandRequest{ + Command: "createKey", + Parameter: string(data), + CommandType: "command", + }, nil +} + +// DeleteKeyCommand returns a new Command which deletes a key from Lock devices. +func DeleteKeyCommand(id int) Command { + return DeviceCommandRequest{ + Command: "deleteKey", + Parameter: fmt.Sprintf(`{"id": %d}`, id), + CommandType: "command", + } +} + +// ButtonPushCommand returns a new Command which triggers button push. +func ButtonPushCommand(name string) Command { return DeviceCommandRequest{ Command: name, Parameter: "default", @@ -513,8 +633,8 @@ const ( ACHigh ) -// ACSetAll returns a new command which set all state of air conditioner. -func ACSetAll(temperature int, mode ACMode, fanSpeed ACFanSpeed, power PowerState) Command { +// ACSetAllCommand returns a new Command which sets all state of air conditioner. +func ACSetAllCommand(temperature int, mode ACMode, fanSpeed ACFanSpeed, power PowerState) Command { return DeviceCommandRequest{ Command: "setAll", Parameter: fmt.Sprintf("%d,%d,%d,%s", temperature, mode, fanSpeed, power.ToLower()), @@ -522,7 +642,125 @@ func ACSetAll(temperature int, mode ACMode, fanSpeed ACFanSpeed, power PowerStat } } -func FanSwing() Command { +// SetChannelCommand returns a new Command which set the TV channel to given channel. +func SetChannelCommand(channelNumber int) Command { + return DeviceCommandRequest{ + Command: "SetChannel", + Parameter: strconv.Itoa(channelNumber), + CommandType: "command", + } +} + +// VolumeAddCommand returns a new Command which is for volume up. +func VolumeAddCommand() Command { + return DeviceCommandRequest{ + Command: "volumeAdd", + Parameter: "default", + CommandType: "command", + } +} + +// VolumeSubCommand returns a new Command which is for volume up. +func VolumeSubCommand() Command { + return DeviceCommandRequest{ + Command: "volumeSub", + Parameter: "default", + CommandType: "command", + } +} + +// ChannelAddCommand returns a new Command which is for switching to next channel. +func ChannelAddCommand() Command { + return DeviceCommandRequest{ + Command: "channelAdd", + Parameter: "default", + CommandType: "command", + } +} + +// ChannelSubCommand returns a new Command which is for switching to previous channel. +func ChannelSubCommand() Command { + return DeviceCommandRequest{ + Command: "channelSub", + Parameter: "default", + CommandType: "command", + } +} + +// SetMuteCommand returns a new Command to make DVD player or speaker mute/unmute. +func SetMuteCommand() Command { + return DeviceCommandRequest{ + Command: "setMute", + Parameter: "default", + CommandType: "command", + } +} + +// FastForwardCommand returns a new Command to make DVD player or speaker fastforward. +func FastForwardCommand() Command { + return DeviceCommandRequest{ + Command: "FastForward", + Parameter: "default", + CommandType: "command", + } +} + +// RewindCommand returns a new Command to make DVD player or speaker rewind. +func RewindCommand() Command { + return DeviceCommandRequest{ + Command: "Rewind", + Parameter: "default", + CommandType: "command", + } +} + +// NextCommand returns a new Command to switch DVD player or speaker to next track. +func NextCommand() Command { + return DeviceCommandRequest{ + Command: "Next", + Parameter: "default", + CommandType: "command", + } +} + +// PreviousCommand returns a new Command to switch DVD player or speaker to previous track. +func PreviousCommand() Command { + return DeviceCommandRequest{ + Command: "Previous", + Parameter: "default", + CommandType: "command", + } +} + +// PauseCommand returns a new Command to make DVD player or speaker pause. +func PauseCommand() Command { + return DeviceCommandRequest{ + Command: "Pause", + Parameter: "default", + CommandType: "command", + } +} + +// PlayCommand returns a new Command to make DVD player or speaker play. +func PlayCommand() Command { + return DeviceCommandRequest{ + Command: "Play", + Parameter: "default", + CommandType: "command", + } +} + +// PlayerStopCommand returns a new Command to make DVD player or speaker stop. +func StopPlayerCommand() Command { + return DeviceCommandRequest{ + Command: "Stop", + Parameter: "default", + CommandType: "command", + } +} + +// FanSwingCommand returns a new Command which makes a fan swing. +func FanSwingCommand() Command { return DeviceCommandRequest{ Command: "swing", Parameter: "default", @@ -530,7 +768,8 @@ func FanSwing() Command { } } -func FanTimer() Command { +// FanTimerCommand returns a new Command which sets timer for a fan. +func FanTimerCommand() Command { return DeviceCommandRequest{ Command: "timer", Parameter: "default", @@ -538,7 +777,8 @@ func FanTimer() Command { } } -func FanLowSpeed() Command { +// FanLowSpeedCommand returns a new Command which sets fan speed to low. +func FanLowSpeedCommand() Command { return DeviceCommandRequest{ Command: "lowSpeed", Parameter: "default", @@ -546,7 +786,8 @@ func FanLowSpeed() Command { } } -func FanMiddleSpeed() Command { +// FanMiddleSpeedCommand returns a new Command which sets fan speed to medium. +func FanMiddleSpeedCommand() Command { return DeviceCommandRequest{ Command: "middleSpeed", Parameter: "default", @@ -554,10 +795,29 @@ func FanMiddleSpeed() Command { } } -func FanHighSpeed() Command { +// FanHighSpeedCommand returns a new Command which sets fan speed to high. +func FanHighSpeedCommand() Command { return DeviceCommandRequest{ Command: "highSpeed", Parameter: "default", CommandType: "command", } } + +// LightBrightnessUpCommand returns a new Command which make light's brigtness up. +func LightBrightnessUpCommand() Command { + return DeviceCommandRequest{ + Command: "brightnessUp", + Parameter: "default", + CommandType: "command", + } +} + +// LightBrightnessDownCommand returns a new Command which make light's brigtness down. +func LightBrightnessDownCommand() Command { + return DeviceCommandRequest{ + Command: "brightnessDown", + Parameter: "default", + CommandType: "command", + } +} diff --git a/device_test.go b/device_test.go index 52d56ab..e0fa4bf 100644 --- a/device_test.go +++ b/device_test.go @@ -8,7 +8,9 @@ import ( "net/http" "net/http/httptest" "testing" + "time" + "github.com/google/go-cmp/cmp" "github.com/nasa9084/go-switchbot" ) @@ -44,7 +46,7 @@ func TestDevices(t *testing.T) { ) defer srv.Close() - c := switchbot.New("", switchbot.WithEndpoint(srv.URL)) + c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) devices, infrared, err := c.Device().List(context.Background()) if err != nil { t.Fatal(err) @@ -58,29 +60,16 @@ func TestDevices(t *testing.T) { got := devices[0] - if want := "500291B269BE"; got.ID != want { - t.Errorf("device ID is not match: %s != %s", got.ID, want) - return - } - - if want := "Living Room Humidifier"; got.Name != want { - t.Errorf("device name is not match: %s != %s", got.Name, want) - return - } - - if want := switchbot.Humidifier; got.Type != want { - t.Errorf("device type is not match: %s != %s", got.Type, want) - return - } - - if !got.IsEnableCloudService { - t.Errorf("device.enableCloudService should be true but false") - return + want := switchbot.Device{ + ID: "500291B269BE", + Name: "Living Room Humidifier", + Type: switchbot.Humidifier, + IsEnableCloudService: true, + Hub: "000000000000", } - if want := "000000000000"; got.Hub != want { - t.Errorf("device's parent hub id is not match: %s != %s", got.Hub, want) - return + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("device mismatch (-want +got):\n%s", diff) } }) @@ -92,24 +81,15 @@ func TestDevices(t *testing.T) { got := infrared[0] - if want := "02-202008110034-13"; got.ID != want { - t.Errorf("infrared device ID is not match: %s != %s", got.ID, want) - return - } - - if want := "Living Room TV"; got.Name != want { - t.Errorf("infrared device name is not match: %s != %s", got.Name, want) - return + want := switchbot.InfraredDevice{ + ID: "02-202008110034-13", + Name: "Living Room TV", + Type: switchbot.TV, + Hub: "FA7310762361", } - if want := switchbot.TV; got.Type != want { - t.Errorf("infrared device type is not match: %s != %s", got.Type, want) - return - } - - if want := "FA7310762361"; got.Hub != want { - t.Errorf("infrared device's parent hub id is not match: %s != %s", got.Hub, want) - return + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("infrared device mismatch (-want +got):\n%s", diff) } }) } @@ -119,7 +99,7 @@ func TestDeviceStatus(t *testing.T) { t.Run("meter", func(t *testing.T) { srv := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1.0/devices/C271111EC0AB/status" { + if r.URL.Path != "/v1.1/devices/C271111EC0AB/status" { t.Fatalf("unexpected request path: %s", r.URL.Path) } @@ -139,35 +119,22 @@ func TestDeviceStatus(t *testing.T) { ) defer srv.Close() - c := switchbot.New("", switchbot.WithEndpoint(srv.URL)) + c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) got, err := c.Device().Status(context.Background(), "C271111EC0AB") if err != nil { t.Fatal(err) } - if want := "C271111EC0AB"; got.ID != want { - t.Errorf("devicee id is not match: %s != %s", got.ID, want) - return - } - - if want := switchbot.Meter; got.Type != want { - t.Errorf("device type is not match: %s != %s", got.Type, want) - return + want := switchbot.DeviceStatus{ + ID: "C271111EC0AB", + Type: switchbot.Meter, + Hub: "FA7310762361", + Humidity: 52, + Temperature: 26.1, } - if want := "FA7310762361"; got.Hub != want { - t.Errorf("device's parent hub id is not match: %s != %s", got.Hub, want) - return - } - - if want := 52; got.Humidity != want { - t.Errorf("humidity is not match: %d != %d", got.Humidity, want) - return - } - - if want := 26.1; got.Temperature != want { - t.Errorf("temperature is not match: %f != %f", got.Temperature, want) - return + if diff := cmp.Diff(want, got, cmp.AllowUnexported(switchbot.BrightnessState{})); diff != "" { + t.Fatalf("status mismatch (-want +got):\n%s", diff) } }) @@ -175,7 +142,7 @@ func TestDeviceStatus(t *testing.T) { t.Run("curtain", func(t *testing.T) { srv := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1.0/devices/E2F6032048AB/status" { + if r.URL.Path != "/v1.1/devices/E2F6032048AB/status" { t.Fatalf("unexpected request path: %s", r.URL.Path) } @@ -197,45 +164,24 @@ func TestDeviceStatus(t *testing.T) { ) defer srv.Close() - c := switchbot.New("", switchbot.WithEndpoint(srv.URL)) + c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) got, err := c.Device().Status(context.Background(), "E2F6032048AB") if err != nil { t.Fatal(err) } - if want := "E2F6032048AB"; got.ID != want { - t.Errorf("devicee id is not match: %s != %s", got.ID, want) - return + want := switchbot.DeviceStatus{ + ID: "E2F6032048AB", + Type: switchbot.Curtain, + Hub: "FA7310762361", + IsCalibrated: true, + IsGrouped: false, + IsMoving: false, + SlidePosition: 0, } - if want := switchbot.Curtain; got.Type != want { - t.Errorf("device type is not match: %s != %s", got.Type, want) - return - } - - if want := "FA7310762361"; got.Hub != want { - t.Errorf("device's parent hub id is not match: %s != %s", got.Hub, want) - return - } - - if !got.IsCalibrated { - t.Error("device is calibrated but got false") - return - } - - if got.IsGrouped { - t.Error("device is not grouped but got true") - return - } - - if got.IsMoving { - t.Error("device is not moving but got true") - return - } - - if want := 0; got.SlidePosition != want { - t.Errorf("slide position is not match: %d != %d", got.Humidity, want) - return + if diff := cmp.Diff(want, got, cmp.AllowUnexported(switchbot.BrightnessState{})); diff != "" { + t.Fatalf("status mismatch (-want +got):\n%s", diff) } }) } @@ -309,7 +255,7 @@ func TestDeviceStatusBrightness(t *testing.T) { ) defer srv.Close() - c := switchbot.New("", switchbot.WithEndpoint(srv.URL)) + c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) got, err := c.Device().Status(context.Background(), "E2F6032048AB") if err != nil { t.Fatal(err) @@ -355,18 +301,38 @@ func testDeviceCommand(t *testing.T, wantPath string, wantBody string) http.Hand } func TestDeviceCommand(t *testing.T) { + t.Run("create a temporary passcode", func(t *testing.T) { + srv := httptest.NewServer(testDeviceCommand( + t, + "/v1.1/devices/F7538E1ABCEB/commands", + `{"command":"createKey","parameter":"{\"name\":\"Guest Code\",\"type\":\"timeLimit\",\"password\":\"12345678\",\"startTime\":1664640056,\"endTime\":1665331432}","commandType":"command"} +`, + )) + defer srv.Close() + + c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) + + cmd, err := switchbot.CreateKeyCommand("Guest Code", switchbot.TimeLimitPasscode, "12345678", time.Date(2022, time.October, 1, 16, 00, 56, 0, time.UTC), time.Date(2022, time.October, 9, 16, 3, 52, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + + if err := c.Device().Command(context.Background(), "F7538E1ABCEB", cmd); err != nil { + t.Fatal(err) + } + }) t.Run("turn a bot on", func(t *testing.T) { srv := httptest.NewServer(testDeviceCommand( t, - "/v1.0/devices/210/commands", + "/v1.1/devices/210/commands", `{"command":"turnOn","parameter":"default","commandType":"command"} `, )) defer srv.Close() - c := switchbot.New("", switchbot.WithEndpoint(srv.URL)) + c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) - if err := c.Device().Command(context.Background(), "210", switchbot.TurnOn()); err != nil { + if err := c.Device().Command(context.Background(), "210", switchbot.TurnOnCommand()); err != nil { t.Fatal(err) } }) @@ -374,15 +340,15 @@ func TestDeviceCommand(t *testing.T) { t.Run("set the color value of a Color Bulb Request", func(t *testing.T) { srv := httptest.NewServer(testDeviceCommand( t, - "/v1.0/devices/84F70353A411/commands", + "/v1.1/devices/84F70353A411/commands", `{"command":"setColor","parameter":"122:80:20","commandType":"command"} `, )) defer srv.Close() - c := switchbot.New("", switchbot.WithEndpoint(srv.URL)) + c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) - if err := c.Device().Command(context.Background(), "84F70353A411", switchbot.SetColor(122, 80, 20)); err != nil { + if err := c.Device().Command(context.Background(), "84F70353A411", switchbot.SetColorCommand(122, 80, 20)); err != nil { t.Fatal(err) } }) @@ -390,15 +356,15 @@ func TestDeviceCommand(t *testing.T) { t.Run("set an air conditioner", func(t *testing.T) { srv := httptest.NewServer(testDeviceCommand( t, - "/v1.0/devices/02-202007201626-70/commands", + "/v1.1/devices/02-202007201626-70/commands", `{"command":"setAll","parameter":"26,1,3,on","commandType":"command"} `, )) defer srv.Close() - c := switchbot.New("", switchbot.WithEndpoint(srv.URL)) + c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) - if err := c.Device().Command(context.Background(), "02-202007201626-70", switchbot.ACSetAll(26, switchbot.ACAuto, switchbot.ACMedium, switchbot.PowerOn)); err != nil { + if err := c.Device().Command(context.Background(), "02-202007201626-70", switchbot.ACSetAllCommand(26, switchbot.ACAuto, switchbot.ACMedium, switchbot.PowerOn)); err != nil { t.Fatal(err) } }) @@ -406,15 +372,15 @@ func TestDeviceCommand(t *testing.T) { t.Run("set trigger a customized button", func(t *testing.T) { srv := httptest.NewServer(testDeviceCommand( t, - "/v1.0/devices/02-202007201626-10/commands", + "/v1.1/devices/02-202007201626-10/commands", `{"command":"ボタン","parameter":"default","commandType":"customize"} `, )) defer srv.Close() - c := switchbot.New("", switchbot.WithEndpoint(srv.URL)) + c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) - if err := c.Device().Command(context.Background(), "02-202007201626-10", switchbot.ButtonPush("ボタン")); err != nil { + if err := c.Device().Command(context.Background(), "02-202007201626-10", switchbot.ButtonPushCommand("ボタン")); err != nil { t.Fatal(err) } }) diff --git a/example_test.go b/example_test.go index 528be67..1e1a00d 100644 --- a/example_test.go +++ b/example_test.go @@ -8,9 +8,12 @@ import ( ) func ExamplePrintPhysicalDevices() { - const openToken = "blahblahblah" + const ( + openToken = "blahblahblah" + secretKey = "blahblahblah" + ) - c := switchbot.New(openToken) + c := switchbot.New(openToken, secretKey) // get physical devices and show pdev, _, _ := c.Device().List(context.Background()) diff --git a/go.mod b/go.mod index 13f8d69..3e499e6 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module github.com/nasa9084/go-switchbot go 1.18 -require github.com/google/go-cmp v0.5.9 // indirect +require ( + github.com/google/go-cmp v0.5.9 // indirect + github.com/google/uuid v1.3.0 // indirect +) diff --git a/go.sum b/go.sum index 62841cd..c1f0f15 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,4 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/scene.go b/scene.go index 141557f..f143a97 100644 --- a/scene.go +++ b/scene.go @@ -36,7 +36,7 @@ type Scene struct { // List get a list of manual scenes created by the current user. // The first returned value is a list of scenes. func (svc *SceneService) List(ctx context.Context) ([]Scene, error) { - const path = "/v1.0/scenes" + const path = "/v1.1/scenes" resp, err := svc.c.get(ctx, path) if err != nil { @@ -57,15 +57,16 @@ func (svc *SceneService) List(ctx context.Context) ([]Scene, error) { } type sceneExecuteResponse struct { - StatusCode int `json:"statusCode"` - Message string `json:"message"` + StatusCode int `json:"statusCode"` + Message string `json:"message"` + Body interface{} `json:"body"` } // Execute sends a request to execute a manual scene. // The first given argument `id` is a scene ID which you want to execute, which can // be retrieved by (*Client).Scene().List() function. func (svc *SceneService) Execute(ctx context.Context, id string) error { - path := "/v1.0/scenes/" + id + "/execute" + path := "/v1.1/scenes/" + id + "/execute" resp, err := svc.c.post(ctx, path, nil) if err != nil { diff --git a/scene_test.go b/scene_test.go index 3ca0794..4d63d1a 100644 --- a/scene_test.go +++ b/scene_test.go @@ -4,9 +4,9 @@ import ( "context" "net/http" "net/http/httptest" - "strconv" "testing" + "github.com/google/go-cmp/cmp" "github.com/nasa9084/go-switchbot" ) @@ -45,17 +45,13 @@ func TestScenes(t *testing.T) { ) defer srv.Close() - c := switchbot.New("", switchbot.WithEndpoint(srv.URL)) + c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) - scenes, err := c.Scene().List(context.Background()) + got, err := c.Scene().List(context.Background()) if err != nil { t.Fatal(err) } - if len(scenes) != 5 { - t.Errorf("the number of scenes is expected to 5, but %d", len(scenes)) - return - } want := []switchbot.Scene{ { ID: "T02-20200804130110", @@ -79,18 +75,8 @@ func TestScenes(t *testing.T) { }, } - for i, got := range scenes { - t.Run(strconv.Itoa(i), func(t *testing.T) { - if got.ID != want[i].ID { - t.Errorf("scene ID is not match: %s != %s", got.ID, want[i].ID) - return - } - - if got.Name != want[i].Name { - t.Errorf("scene name is not match: %s != %s", got.Name, want[i].Name) - return - } - }) + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("status mismatch (-want +got):\n%s", diff) } } @@ -103,7 +89,7 @@ func TestSceneExecute(t *testing.T) { return } - if want := "/v1.0/scenes/T02-202009221414-48924101/execute"; r.URL.Path != want { + if want := "/v1.1/scenes/T02-202009221414-48924101/execute"; r.URL.Path != want { t.Fatalf("unexpected request path: %s", r.URL.Path) return } @@ -118,7 +104,7 @@ func TestSceneExecute(t *testing.T) { ) defer srv.Close() - c := switchbot.New("", switchbot.WithEndpoint(srv.URL)) + c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) if err := c.Scene().Execute(context.Background(), "T02-202009221414-48924101"); err != nil { t.Fatal(err) } diff --git a/switchbot.go b/switchbot.go index d516f31..d0f9170 100644 --- a/switchbot.go +++ b/switchbot.go @@ -3,12 +3,20 @@ package switchbot import ( "bytes" "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" "encoding/json" "errors" "fmt" "io" "io/ioutil" "net/http" + "strconv" + "strings" + "time" + + "github.com/google/uuid" ) const DefaultEndpoint = "https://api.switch-bot.com" @@ -17,6 +25,7 @@ type Client struct { httpClient *http.Client openToken string + secretKey string endpoint string deviceService *DeviceService @@ -71,6 +80,20 @@ const ( ColorBulb PhysicalDeviceType = "Color Bulb" // MeterPlusJP is SwitchBot Thermometer and Hygrometer Plus (JP) Model No. W2201500 / (US) Model No. W2301500 MeterPlus PhysicalDeviceType = "MeterPlus" + // KeyPad is SwitchBot Lock Model No. W2500010 + KeyPad PhysicalDeviceType = "KeyPad" + // KeyPadTouch is SwitchBot Lock Model No. W2500020 + KeyPadTouch PhysicalDeviceType = "KeyPad Touch" + // CeilingLight is SwitchBot Ceiling Light Model No. W2612230 and W2612240. + CeilingLight PhysicalDeviceType = "Ceiling Light" + // CeilingLightPro is SwitchBot Ceiling Light Pro Model No. W2612210 and W2612220. + CeilingLightPro PhysicalDeviceType = "Ceiling Light Pro" + // IndoorCam is SwitchBot Indoor Cam Model No. W1301200 + IndoorCam PhysicalDeviceType = "Indoor Cam" + // PanTiltCam is SwitchBot Pan/Tilt Cam Model No. W1801200 + PanTiltCam PhysicalDeviceType = "Pan/Tilt Cam" + //PanTiltCam2K is SwitchBot Pan/Tilt Cam 2K Model No. W3101100 + PanTiltCam2K PhysicalDeviceType = "Pan/Tilt Cam 2K" ) type VirtualDeviceType string @@ -95,11 +118,12 @@ const ( // New returns a new switchbot client associated with given openToken. // See https://github.com/OpenWonderLabs/SwitchBotAPI/blob/7a68353d84d07d439a11cb5503b634f24302f733/README.md#getting-started // for getting openToken for SwitchBot API. -func New(openToken string, opts ...Option) *Client { +func New(openToken, secretKey string, opts ...Option) *Client { c := &Client{ httpClient: http.DefaultClient, openToken: openToken, + secretKey: secretKey, endpoint: DefaultEndpoint, } @@ -147,6 +171,10 @@ func (resp *httpResponse) Close() { } func (c *Client) do(ctx context.Context, method, path string, body io.Reader) (*httpResponse, error) { + nonce := uuid.New().String() + t := strconv.FormatInt(time.Now().UnixMilli(), 10) + sign := hmacSHA256String(c.openToken+t+nonce, c.secretKey) + req, err := http.NewRequestWithContext(ctx, method, c.endpoint+path, body) if err != nil { @@ -154,6 +182,9 @@ func (c *Client) do(ctx context.Context, method, path string, body io.Reader) (* } req.Header.Add("Authorization", c.openToken) + req.Header.Add("sign", sign) + req.Header.Add("nonce", nonce) + req.Header.Add("t", t) req.Header.Add("Content-Type", "application/json; charset=utf8") resp, err := c.httpClient.Do(req) @@ -207,3 +238,9 @@ func (c *Client) del(ctx context.Context, path string, body interface{}) (*httpR return c.do(ctx, http.MethodDelete, path, &buf) } + +func hmacSHA256String(message, key string) string { + signer := hmac.New(sha256.New, []byte(key)) + signer.Write([]byte(message)) + return strings.ToUpper(base64.StdEncoding.EncodeToString(signer.Sum(nil))) +} diff --git a/webhook.go b/webhook.go index 7329a9d..a511a85 100644 --- a/webhook.go +++ b/webhook.go @@ -25,7 +25,7 @@ func (c *Client) Webhook() *WebhookService { type webhookSetupRequest struct { Action string `json:"action"` URL string `json:"url,omitempty"` - DeviceList string `json:"deviceList,omitempty"` + DeviceList string `json:"deviceList,omitempty"` // currently only ALL is supported } type webhookSetupResponse struct { @@ -34,8 +34,10 @@ type webhookSetupResponse struct { Message string `json:"message"` } +// Setup configures the url that all the webhook events will be sent to. +// Currently the deviceList is only supporting "ALL". func (svc *WebhookService) Setup(ctx context.Context, url, deviceList string) error { - const path = "/v1.0/webhook/setupWebhook" + const path = "/v1.1/webhook/setupWebhook" if deviceList != "ALL" { return errors.New(`deviceList value is only supporting "ALL" for now`) @@ -71,8 +73,10 @@ const ( type webhookQueryResponse struct { } +// Query retrieves the current configuration info of the webhook. +// The second argument `url` is required for QueryDetails action type. func (svc *WebhookService) Query(ctx context.Context, action WebhookQueryActionType, url string) error { - const path = "/v1.0/webhook/queryWebhook" + const path = "/v1.1/webhook/queryWebhook" req := webhookQueryRequest{ Action: action, @@ -106,8 +110,9 @@ type webhookConfig struct { Enable bool `json:"enable"` } +// Update do update the configuration of the webhook. func (svc *WebhookService) Update(ctx context.Context, url string, enable bool) error { - const path = "/v1.0/webhook/queryWebhook" + const path = "/v1.1/webhook/queryWebhook" req := webhookUpdateRequest{ Action: "updateWebhook", @@ -131,8 +136,9 @@ type webhookDeleteRequest struct { URL string `json:"url"` } +// Delete do delete the configuration of the webhook. func (svc *WebhookService) Delete(ctx context.Context, url string) error { - const path = "/v1.0/webhook/deleteWebhook" + const path = "/v1.1/webhook/deleteWebhook" req := webhookDeleteRequest{ Action: "deleteWebhook", @@ -198,7 +204,7 @@ type ContactSensorEventContext struct { // when the enter or exit mode gets triggered, "IN_DOOR" or "OUT_DOOR" is returned DoorMode string `json:"doorMode"` // the level of brightness, can be "bright" or "dim" - Brightness string `json:"brightness"` + Brightness AmbientBrightness `json:"brightness"` // the state of the contact sensor, can be "open" or "close" or "timeOutNotClose" OpenState string `json:"openState"` } @@ -294,7 +300,7 @@ type ColorBulbEventContext struct { TimeOfSample int64 `json:"timeOfSample"` // the current power state of the device, "ON" or "OFF" - PowerState string `json:"powerState"` + PowerState PowerState `json:"powerState"` // the brightness value, range from 1 to 100 Brightness int `json:"brightness"` // the color value, in the format of RGB value, "255:255:255" @@ -315,7 +321,7 @@ type StripLightEventContext struct { TimeOfSample int64 `json:"timeOfSample"` // the current power state of the device, "ON" or "OFF" - PowerState string `json:"powerState"` + PowerState PowerState `json:"powerState"` // the brightness value, range from 1 to 100 Brightness int `json:"brightness"` // the color value, in the format of RGB value, "255:255:255" @@ -334,7 +340,7 @@ type PlugMiniJPEventContext struct { TimeOfSample int64 `json:"timeOfSample"` // the current power state of the device, "ON" or "OFF" - PowerState string `json:"powerState"` + PowerState PowerState `json:"powerState"` } type PlugMiniUSEvent struct { @@ -349,7 +355,66 @@ type PlugMiniUSEventContext struct { TimeOfSample int64 `json:"timeOfSample"` // the current power state of the device, "ON" or "OFF" - PowerState string `json:"powerState"` + PowerState PowerState `json:"powerState"` +} + +type SweeperEvent struct { + EventType string `json:"eventType"` + EventVersion string `json:"eventVersion"` + Context SweeperEventContext `json:"context"` +} + +type SweeperEventContext struct { + DeviceType string `json:"deviceType"` + DeviceMac string `json:"deviceMac"` + TimeOfSample int64 `json:"timeOfSample"` + + // the working status of the device, "StandBy", "Clearing", + // "Paused", "GotoChargeBase", "Charging", "ChargeDone", + // "Dormant", "InTrouble", "InRemoteControl", or "InDustCollecting" + WorkingStatus CleanerWorkingStatus `json:"workingStatus"` + // the connection status of the device, "online" or "offline" + OnlineStatus CleanerOnlineStatus `json:"onlineStatus"` + // the battery level. + Battery int `json:"battery"` +} + +type CeilingEvent struct { + EventType string `json:"eventType"` + EventVersion string `json:"eventVersion"` + Context CeilingEventContext `json:"context"` +} + +type CeilingEventContext struct { + DeviceType string `json:"deviceType"` + DeviceMac string `json:"deviceMac"` + TimeOfSample int64 `json:"timeOfSample"` + + // ON/OFF state + PowerState PowerState `json:"powerState"` + // the brightness value, range from 1 to 100 + Brightness int `json:"brightness"` + // the color temperature value, range from 2700 to 6500 + ColorTemperature int `json:"colorTemperature"` +} + +type KeypadEvent struct { + EventType string `json:"eventType"` + EventVersion string `json:"eventVersion"` + Context KeypadEventContext `json:"context"` +} + +type KeypadEventContext struct { + DeviceType string `json:"deviceType"` + DeviceMac string `json:"deviceMac"` + TimeOfSample int64 `json:"timeOfSample"` + + // the name fo the command being sent + EventName string `json:"eventName"` + // the command ID + CommandID string `json:"commandId"` + // the result of the command, success, failed, or timeout + Result string `json:"result"` } func ParseWebhookRequest(r *http.Request) (interface{}, error) { @@ -436,6 +501,27 @@ func ParseWebhookRequest(r *http.Request) (interface{}, error) { return nil, err } return &event, nil + case "WoSweeper", "WoSweeperPlus": + // Cleaner + var event SweeperEvent + if err := json.NewDecoder(r.Body).Decode(&event); err != nil { + return nil, err + } + return &event, nil + case "WoCeiling", "WoCeilingPro": + // Ceiling lights + var event CeilingEvent + if err := json.NewDecoder(r.Body).Decode(&event); err != nil { + return nil, err + } + return &event, nil + case "WoKeypad", "WoKeypadTouch": + // keypad + var event KeypadEvent + if err := json.NewDecoder(r.Body).Decode(&event); err != nil { + return nil, err + } + return &event, nil default: return nil, fmt.Errorf("unknown device type: %s", deviceType) } diff --git a/webhook_test.go b/webhook_test.go index c798c4d..3c6d70c 100644 --- a/webhook_test.go +++ b/webhook_test.go @@ -39,7 +39,7 @@ func TestWebhookSetup(t *testing.T) { ) defer srv.Close() - c := switchbot.New("", switchbot.WithEndpoint(srv.URL)) + c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) if err := c.Webhook().Setup(context.Background(), "url1", "ALL"); err != nil { t.Fatal(err) @@ -73,7 +73,7 @@ func TestWebhookQuery(t *testing.T) { ) defer srv.Close() - c := switchbot.New("", switchbot.WithEndpoint(srv.URL)) + c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) if err := c.Webhook().Query(context.Background(), switchbot.QueryURL, ""); err != nil { t.Fatal(err) @@ -106,7 +106,7 @@ func TestWebhookQuery(t *testing.T) { ) defer srv.Close() - c := switchbot.New("", switchbot.WithEndpoint(srv.URL)) + c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) if err := c.Webhook().Query(context.Background(), switchbot.QueryDetails, "url1"); err != nil { t.Fatal(err) @@ -143,7 +143,7 @@ func TestWebhookUpdate(t *testing.T) { ) defer srv.Close() - c := switchbot.New("", switchbot.WithEndpoint(srv.URL)) + c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) if err := c.Webhook().Update(context.Background(), "url1", true); err != nil { t.Fatal(err) @@ -176,7 +176,7 @@ func TestWebhookDelete(t *testing.T) { ) defer srv.Close() - c := switchbot.New("", switchbot.WithEndpoint(srv.URL)) + c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) if err := c.Webhook().Delete(context.Background(), "url1"); err != nil { t.Fatal(err) @@ -238,7 +238,7 @@ func TestParseWebhook(t *testing.T) { DeviceMac: "01:00:5e:90:10:00", DetectionState: "NOT_DETECTED", DoorMode: "OUT_DOOR", - Brightness: "dim", + Brightness: switchbot.AmbientBrightnessDim, OpenState: "open", TimeOfSample: 123456789, }, @@ -443,7 +443,7 @@ func TestParseWebhook(t *testing.T) { Context: switchbot.ColorBulbEventContext{ DeviceType: "WoBulb", DeviceMac: "01:00:5e:90:10:00", - PowerState: "ON", + PowerState: switchbot.PowerOn, Brightness: 10, Color: "255:245:235", ColorTemperature: 3500, @@ -479,7 +479,7 @@ func TestParseWebhook(t *testing.T) { Context: switchbot.StripLightEventContext{ DeviceType: "WoStrip", DeviceMac: "01:00:5e:90:10:00", - PowerState: "ON", + PowerState: switchbot.PowerOn, Brightness: 10, Color: "255:245:235", TimeOfSample: 123456789, @@ -514,7 +514,7 @@ func TestParseWebhook(t *testing.T) { Context: switchbot.PlugMiniUSEventContext{ DeviceType: "WoPlugUS", DeviceMac: "01:00:5e:90:10:00", - PowerState: "ON", + PowerState: switchbot.PowerOn, TimeOfSample: 123456789, }, } @@ -547,7 +547,7 @@ func TestParseWebhook(t *testing.T) { Context: switchbot.PlugMiniJPEventContext{ DeviceType: "WoPlugJP", DeviceMac: "01:00:5e:90:10:00", - PowerState: "ON", + PowerState: switchbot.PowerOn, TimeOfSample: 123456789, }, } @@ -564,4 +564,286 @@ func TestParseWebhook(t *testing.T) { sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoPlugJP","deviceMac":"01:00:5e:90:10:00","powerState":"ON","timeOfSample":123456789}}`) }) + + t.Run("Robot Vacuum Cleaner S1", func(t *testing.T) { + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + event, err := switchbot.ParseWebhookRequest(r) + if err != nil { + t.Fatal(err) + } + + if got, ok := event.(*switchbot.SweeperEvent); ok { + want := switchbot.SweeperEvent{ + EventType: "changeReport", + EventVersion: "1", + Context: switchbot.SweeperEventContext{ + DeviceType: "WoSweeper", + DeviceMac: "01:00:5e:90:10:00", + WorkingStatus: switchbot.CleanerStandBy, + OnlineStatus: switchbot.CleanerOnline, + Battery: 100, + TimeOfSample: 123456789, + }, + } + + if diff := cmp.Diff(want, *got); diff != "" { + t.Fatalf("event mismatch (-want +got):\n%s", diff) + } + } else { + t.Fatalf("given webhook event must be a sweeper event but %T", event) + } + }), + ) + defer srv.Close() + + sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoSweeper","deviceMac":"01:00:5e:90:10:00","workingStatus":"StandBy","onlineStatus":"online","battery":100,"timeOfSample":123456789}}`) + }) + + t.Run("Robot Vacuum Cleaner S1 Plus", func(t *testing.T) { + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + event, err := switchbot.ParseWebhookRequest(r) + if err != nil { + t.Fatal(err) + } + + if got, ok := event.(*switchbot.SweeperEvent); ok { + want := switchbot.SweeperEvent{ + EventType: "changeReport", + EventVersion: "1", + Context: switchbot.SweeperEventContext{ + DeviceType: "WoSweeperPlus", + DeviceMac: "01:00:5e:90:10:00", + WorkingStatus: switchbot.CleanerStandBy, + OnlineStatus: switchbot.CleanerOnline, + Battery: 100, + TimeOfSample: 123456789, + }, + } + + if diff := cmp.Diff(want, *got); diff != "" { + t.Fatalf("event mismatch (-want +got):\n%s", diff) + } + } else { + t.Fatalf("given webhook event must be a sweeper plus event but %T", event) + } + }), + ) + defer srv.Close() + + sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoSweeperPlus","deviceMac":"01:00:5e:90:10:00","workingStatus":"StandBy","onlineStatus":"online","battery":100,"timeOfSample":123456789}}`) + }) + + t.Run("Ceiling Light", func(t *testing.T) { + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + event, err := switchbot.ParseWebhookRequest(r) + if err != nil { + t.Fatal(err) + } + + if got, ok := event.(*switchbot.CeilingEvent); ok { + want := switchbot.CeilingEvent{ + EventType: "changeReport", + EventVersion: "1", + Context: switchbot.CeilingEventContext{ + DeviceType: "WoCeiling", + DeviceMac: "01:00:5e:90:10:00", + PowerState: switchbot.PowerOn, + Brightness: 10, + ColorTemperature: 3500, + TimeOfSample: 123456789, + }, + } + + if diff := cmp.Diff(want, *got); diff != "" { + t.Fatalf("event mismatch (-want +got):\n%s", diff) + } + } else { + t.Fatalf("given webhook event must be a ceiling event but %T", event) + } + }), + ) + defer srv.Close() + + sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoCeiling","deviceMac":"01:00:5e:90:10:00","powerState":"ON","brightness":10,"colorTemperature":3500,"timeOfSample":123456789}}`) + }) + + t.Run("Ceiling Light Pro", func(t *testing.T) { + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + event, err := switchbot.ParseWebhookRequest(r) + if err != nil { + t.Fatal(err) + } + + if got, ok := event.(*switchbot.CeilingEvent); ok { + want := switchbot.CeilingEvent{ + EventType: "changeReport", + EventVersion: "1", + Context: switchbot.CeilingEventContext{ + DeviceType: "WoCeilingPro", + DeviceMac: "01:00:5e:90:10:00", + PowerState: switchbot.PowerOn, + Brightness: 10, + ColorTemperature: 3500, + TimeOfSample: 123456789, + }, + } + + if diff := cmp.Diff(want, *got); diff != "" { + t.Fatalf("event mismatch (-want +got):\n%s", diff) + } + } else { + t.Fatalf("given webhook event must be a ceiling event but %T", event) + } + }), + ) + defer srv.Close() + + sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoCeilingPro","deviceMac":"01:00:5e:90:10:00","powerState":"ON","brightness":10,"colorTemperature":3500,"timeOfSample":123456789}}`) + }) + + t.Run("Keypad", func(t *testing.T) { + t.Run("create a passcode", func(t *testing.T) { + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + event, err := switchbot.ParseWebhookRequest(r) + if err != nil { + t.Fatal(err) + } + + if got, ok := event.(*switchbot.KeypadEvent); ok { + want := switchbot.KeypadEvent{ + EventType: "changeReport", + EventVersion: "1", + Context: switchbot.KeypadEventContext{ + DeviceType: "WoKeypad", + DeviceMac: "01:00:5e:90:10:00", + EventName: "createKey", + CommandID: "CMD-1663558451952-01", + Result: "success", + TimeOfSample: 123456789, + }, + } + + if diff := cmp.Diff(want, *got); diff != "" { + t.Fatalf("event mismatch (-want +got):\n%s", diff) + } + } else { + t.Fatalf("given webhook event must be a keypad event but %T", event) + } + }), + ) + defer srv.Close() + + sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoKeypad","deviceMac":"01:00:5e:90:10:00","eventName":"createKey","commandId":"CMD-1663558451952-01","result":"success","timeOfSample":123456789}}`) + }) + t.Run("delete a passcode", func(t *testing.T) { + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + event, err := switchbot.ParseWebhookRequest(r) + if err != nil { + t.Fatal(err) + } + + if got, ok := event.(*switchbot.KeypadEvent); ok { + want := switchbot.KeypadEvent{ + EventType: "changeReport", + EventVersion: "1", + Context: switchbot.KeypadEventContext{ + DeviceType: "WoKeypad", + DeviceMac: "01:00:5e:90:10:00", + EventName: "deleteKey", + CommandID: "CMD-1663558451952-01", + Result: "success", + TimeOfSample: 123456789, + }, + } + + if diff := cmp.Diff(want, *got); diff != "" { + t.Fatalf("event mismatch (-want +got):\n%s", diff) + } + } else { + t.Fatalf("given webhook event must be a keypad event but %T", event) + } + }), + ) + defer srv.Close() + + sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoKeypad","deviceMac":"01:00:5e:90:10:00","eventName":"deleteKey","commandId":"CMD-1663558451952-01","result":"success","timeOfSample":123456789}}`) + }) + }) + + t.Run("Keypad Touch", func(t *testing.T) { + t.Run("create a passcode", func(t *testing.T) { + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + event, err := switchbot.ParseWebhookRequest(r) + if err != nil { + t.Fatal(err) + } + + if got, ok := event.(*switchbot.KeypadEvent); ok { + want := switchbot.KeypadEvent{ + EventType: "changeReport", + EventVersion: "1", + Context: switchbot.KeypadEventContext{ + DeviceType: "WoKeypadTouch", + DeviceMac: "01:00:5e:90:10:00", + EventName: "createKey", + CommandID: "CMD-1663558451952-01", + Result: "success", + TimeOfSample: 123456789, + }, + } + + if diff := cmp.Diff(want, *got); diff != "" { + t.Fatalf("event mismatch (-want +got):\n%s", diff) + } + } else { + t.Fatalf("given webhook event must be a keypad touch event but %T", event) + } + }), + ) + defer srv.Close() + + sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoKeypadTouch","deviceMac":"01:00:5e:90:10:00","eventName":"createKey","commandId":"CMD-1663558451952-01","result":"success","timeOfSample":123456789}}`) + }) + t.Run("delete a passcode", func(t *testing.T) { + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + event, err := switchbot.ParseWebhookRequest(r) + if err != nil { + t.Fatal(err) + } + + if got, ok := event.(*switchbot.KeypadEvent); ok { + want := switchbot.KeypadEvent{ + EventType: "changeReport", + EventVersion: "1", + Context: switchbot.KeypadEventContext{ + DeviceType: "WoKeypadTouch", + DeviceMac: "01:00:5e:90:10:00", + EventName: "deleteKey", + CommandID: "CMD-1663558451952-01", + Result: "success", + TimeOfSample: 123456789, + }, + } + + if diff := cmp.Diff(want, *got); diff != "" { + t.Fatalf("event mismatch (-want +got):\n%s", diff) + } + } else { + t.Fatalf("given webhook event must be a keypad touch event but %T", event) + } + }), + ) + defer srv.Close() + + sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoKeypadTouch","deviceMac":"01:00:5e:90:10:00","eventName":"deleteKey","commandId":"CMD-1663558451952-01","result":"success","timeOfSample":123456789}}`) + }) + }) }