├── renovate.json ├── go.mod ├── go.sum ├── .github └── workflows │ └── test.yml ├── DeviceVersion.go ├── example_test.go ├── DeviceVersion_test.go ├── LICENSE ├── README.md ├── scene.go ├── scene_test.go ├── switchbot.go ├── device_test.go ├── webhook.go ├── device.go └── webhook_test.go /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nasa9084/go-switchbot/v5 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/google/go-cmp v0.5.9 // indirect 7 | github.com/google/uuid v1.3.0 // indirect 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 2 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 3 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 4 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 5 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [ push, pull_request ] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Setup Golang 9 | uses: actions/setup-go@v6 10 | with: 11 | go-version: 1.25 12 | 13 | - name: checkout 14 | uses: actions/checkout@v6 15 | 16 | - name: test 17 | run: go test -v 18 | -------------------------------------------------------------------------------- /DeviceVersion.go: -------------------------------------------------------------------------------- 1 | package switchbot 2 | 3 | import ( 4 | "encoding/json" 5 | "strconv" 6 | ) 7 | 8 | type DeviceVersion string 9 | 10 | func (is *DeviceVersion) UnmarshalJSON(b []byte) error { 11 | var i int 12 | if err := json.Unmarshal(b, &i); err != nil { 13 | var s string 14 | if err := json.Unmarshal(b, &s); err != nil { 15 | return err 16 | } 17 | *is = DeviceVersion(s) 18 | return nil 19 | } 20 | *is = DeviceVersion(strconv.Itoa(i)) 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package switchbot_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/nasa9084/go-switchbot/v5" 8 | ) 9 | 10 | func ExampleDeviceService_List() { 11 | const ( 12 | openToken = "blahblahblah" 13 | secretKey = "blahblahblah" 14 | ) 15 | 16 | c := switchbot.New(openToken, secretKey) 17 | 18 | // get physical devices and show 19 | pdev, _, _ := c.Device().List(context.Background()) 20 | 21 | for _, d := range pdev { 22 | fmt.Printf("%s\t%s\n", d.Type, d.Name) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /DeviceVersion_test.go: -------------------------------------------------------------------------------- 1 | package switchbot_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nasa9084/go-switchbot/v5" 7 | ) 8 | 9 | func TestDeviceVersion(t *testing.T) { 10 | t.Run("UnmarshalJSON", func(t *testing.T) { 11 | type args struct { 12 | json string 13 | } 14 | tests := []struct { 15 | name string 16 | args args 17 | wantErr bool 18 | }{ 19 | {"string", args{json: `"string"`}, false}, 20 | {"42", args{json: `42`}, false}, 21 | {"error", args{json: `{"key": "value"}`}, true}, 22 | } 23 | for _, tt := range tests { 24 | t.Run(tt.name, func(t *testing.T) { 25 | var is switchbot.DeviceVersion 26 | if err := is.UnmarshalJSON([]byte(tt.args.json)); (err != nil) != tt.wantErr { 27 | t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) 28 | } 29 | }) 30 | } 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 nasa9084 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | go-switchbot 2 | === 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/nasa9084/go-switchbot.svg)](https://pkg.go.dev/github.com/nasa9084/go-switchbot) 4 | [![test](https://github.com/nasa9084/go-switchbot/actions/workflows/test.yml/badge.svg?event=push)](https://github.com/nasa9084/go-switchbot/actions/workflows/test.yml) 5 | 6 | A [SwitchBot API](https://github.com/OpenWonderLabs/SwitchBotAPI/blob/main/README.md) client for Golang 7 | 8 | ## SYNOPSIS 9 | 10 | ``` go 11 | const ( 12 | openToken = "blahblahblah" 13 | secretKey = "blahblahblah" 14 | ) 15 | 16 | c := switchbot.New(openToken, secretKey) 17 | 18 | // get physical devices and show 19 | pdev, _, _ := c.Device().List(context.Background()) 20 | 21 | for _, d := range pdev { 22 | fmt.Printf("%s\t%s\n", d.Type, d.Name) 23 | } 24 | ``` 25 | 26 | ## Get Open Token 27 | 28 | To use [SwitchBot API](https://github.com/OpenWonderLabs/SwitchBotAPI/blob/main/README.md), you need to get Open Token for auth. [Follow steps](https://github.com/OpenWonderLabs/SwitchBotAPI/blob/e236be6a613c1d2a9c18965fd502a951608a8765/README.md#getting-started) below: 29 | 30 | > 1. Download the SwitchBot app on App Store or Google Play Store 31 | > 2. Register a SwitchBot account and log in into your account 32 | > 3. Generate an Open Token within the app For app version ≥ V9.0, a) Go to Profile > Preferences > About b) Tap App Version 10 times. Developer Options will show up c) Tap Developer Options d) Tap Get Token 33 | > For app version < V9.0, a) Go to Profile > Preferences b) Tap App Version 10 times. Developer Options will show up c) Tap Developer Options d) Tap Get Token 34 | > 4. Roll up your sleeves and get your hands dirty with SwitchBot OpenAPI! 35 | -------------------------------------------------------------------------------- /scene.go: -------------------------------------------------------------------------------- 1 | package switchbot 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | ) 7 | 8 | // SceneService handles API calls related to scenes. 9 | // The scene API is used to access the smart scenes created 10 | // by a user and to execute manual scenes. 11 | type SceneService struct { 12 | c *Client 13 | } 14 | 15 | func newSceneService(c *Client) *SceneService { 16 | return &SceneService{c: c} 17 | } 18 | 19 | // Scene returns the Service Object for scene APIs. 20 | func (c *Client) Scene() *SceneService { 21 | return c.sceneService 22 | } 23 | 24 | type scenesResponse struct { 25 | StatusCode int `json:"statusCode"` 26 | Mesasge string `json:"message"` 27 | Body []Scene `json:"body"` 28 | } 29 | 30 | // Scene represents a manual scene created by the current user. 31 | type Scene struct { 32 | ID string `json:"sceneId"` 33 | Name string `json:"sceneName"` 34 | } 35 | 36 | // List get a list of manual scenes created by the current user. 37 | // The first returned value is a list of scenes. 38 | func (svc *SceneService) List(ctx context.Context) ([]Scene, error) { 39 | const path = "/v1.1/scenes" 40 | 41 | resp, err := svc.c.get(ctx, path) 42 | if err != nil { 43 | return nil, err 44 | } 45 | defer resp.Close() 46 | 47 | var response scenesResponse 48 | if err := resp.DecodeJSON(&response); err != nil { 49 | return nil, err 50 | } 51 | 52 | if response.StatusCode == 190 { 53 | return nil, errors.New("device internal error due to device states not synchronized with server") 54 | } 55 | 56 | return response.Body, nil 57 | } 58 | 59 | type sceneExecuteResponse struct { 60 | StatusCode int `json:"statusCode"` 61 | Message string `json:"message"` 62 | Body any `json:"body"` 63 | } 64 | 65 | // Execute sends a request to execute a manual scene. 66 | // The first given argument `id` is a scene ID which you want to execute, which can 67 | // be retrieved by (*Client).Scene().List() function. 68 | func (svc *SceneService) Execute(ctx context.Context, id string) error { 69 | path := "/v1.1/scenes/" + id + "/execute" 70 | 71 | resp, err := svc.c.post(ctx, path, nil) 72 | if err != nil { 73 | return err 74 | } 75 | defer resp.Close() 76 | 77 | var response sceneExecuteResponse 78 | if err := resp.DecodeJSON(&response); err != nil { 79 | return err 80 | } 81 | 82 | if response.StatusCode == 190 { 83 | return errors.New("device internal error due to device states not synchronized with server") 84 | } 85 | 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /scene_test.go: -------------------------------------------------------------------------------- 1 | package switchbot_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/nasa9084/go-switchbot/v5" 11 | ) 12 | 13 | // https://github.com/OpenWonderLabs/SwitchBotAPI/blob/7a68353d84d07d439a11cb5503b634f24302f733/README.md#get-all-scenes 14 | func TestScenes(t *testing.T) { 15 | srv := httptest.NewServer( 16 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | w.WriteHeader(http.StatusOK) 18 | w.Write([]byte(`{ 19 | "statusCode": 100, 20 | "body": [ 21 | { 22 | "sceneId": "T02-20200804130110", 23 | "sceneName": "Close Office Devices" 24 | }, 25 | { 26 | "sceneId": "T02-202009221414-48924101", 27 | "sceneName": "Set Office AC to 25" 28 | }, 29 | { 30 | "sceneId": "T02-202011051830-39363561", 31 | "sceneName": "Set Bedroom to 24" 32 | }, 33 | { 34 | "sceneId": "T02-202011051831-82928991", 35 | "sceneName": "Turn off home devices" 36 | }, 37 | { 38 | "sceneId": "T02-202011062059-26364981", 39 | "sceneName": "Set Bedroom to 26 degree" 40 | } 41 | ], 42 | "message": "success" 43 | }`)) 44 | }), 45 | ) 46 | defer srv.Close() 47 | 48 | c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) 49 | 50 | got, err := c.Scene().List(context.Background()) 51 | if err != nil { 52 | t.Fatal(err) 53 | } 54 | 55 | want := []switchbot.Scene{ 56 | { 57 | ID: "T02-20200804130110", 58 | Name: "Close Office Devices", 59 | }, 60 | { 61 | ID: "T02-202009221414-48924101", 62 | Name: "Set Office AC to 25", 63 | }, 64 | { 65 | ID: "T02-202011051830-39363561", 66 | Name: "Set Bedroom to 24", 67 | }, 68 | { 69 | ID: "T02-202011051831-82928991", 70 | Name: "Turn off home devices", 71 | }, 72 | { 73 | ID: "T02-202011062059-26364981", 74 | Name: "Set Bedroom to 26 degree", 75 | }, 76 | } 77 | 78 | if diff := cmp.Diff(want, got); diff != "" { 79 | t.Fatalf("status mismatch (-want +got):\n%s", diff) 80 | } 81 | } 82 | 83 | // https://github.com/OpenWonderLabs/SwitchBotAPI/blob/7a68353d84d07d439a11cb5503b634f24302f733/README.md#execute-a-scene 84 | func TestSceneExecute(t *testing.T) { 85 | srv := httptest.NewServer( 86 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 87 | if r.Method != http.MethodPost { 88 | t.Fatalf("POST method is expected but %s", r.Method) 89 | return 90 | } 91 | 92 | if want := "/v1.1/scenes/T02-202009221414-48924101/execute"; r.URL.Path != want { 93 | t.Fatalf("unexpected request path: %s", r.URL.Path) 94 | return 95 | } 96 | 97 | w.WriteHeader(http.StatusOK) 98 | w.Write([]byte(`{ 99 | "statusCode": 100, 100 | "body": {}, 101 | "message": "success" 102 | }`)) 103 | }), 104 | ) 105 | defer srv.Close() 106 | 107 | c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) 108 | if err := c.Scene().Execute(context.Background(), "T02-202009221414-48924101"); err != nil { 109 | t.Fatal(err) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /switchbot.go: -------------------------------------------------------------------------------- 1 | package switchbot 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/hmac" 7 | "crypto/sha256" 8 | "encoding/base64" 9 | "encoding/json" 10 | "errors" 11 | "fmt" 12 | "io" 13 | "log" 14 | "net/http" 15 | "net/http/httputil" 16 | "strconv" 17 | "strings" 18 | "time" 19 | 20 | "github.com/google/uuid" 21 | ) 22 | 23 | const DefaultEndpoint = "https://api.switch-bot.com" 24 | 25 | type Client struct { 26 | httpClient *http.Client 27 | 28 | openToken string 29 | secretKey string 30 | endpoint string 31 | 32 | debug bool 33 | 34 | deviceService *DeviceService 35 | sceneService *SceneService 36 | webhookService *WebhookService 37 | } 38 | 39 | type Option func(*Client) 40 | 41 | type PhysicalDeviceType string 42 | 43 | const ( 44 | // Hub is generally referred to these devices, SwitchBot Hub Model No. SwitchBot Hub S1/SwitchBot Hub Mini Model No. W0202200/SwitchBot Hub Plus Model No. SwitchBot Hub S1 45 | Hub PhysicalDeviceType = "Hub" 46 | // HubPlus is SwitchBot Hub Plus Model No. SwitchBot Hub S1 47 | HubPlus PhysicalDeviceType = "Hub Plus" 48 | // HubMini is SwitchBot Hub Mini Model No. W0202200 49 | HubMini PhysicalDeviceType = "Hub Mini" 50 | // Hub2 is SwitchBot Hub 2 Model No. W3202100 51 | Hub2 PhysicalDeviceType = "Hub 2" 52 | // Hub3 is SwitchBot Hub 3 Model No. W7202100 53 | Hub3 PhysicalDeviceType = "Hub 3" 54 | // Bot is SwitchBot Bot Model No. SwitchBot S1 55 | Bot PhysicalDeviceType = "Bot" 56 | // Curtain is SwitchBot Curtain Model No. W0701600 57 | Curtain PhysicalDeviceType = "Curtain" 58 | // Curtain3 is SwitchBot Curtain Model No. W2400000 59 | Curtain3 PhysicalDeviceType = "Curtain3" 60 | // Plug is SwitchBot Plug Model No. SP11 61 | Plug PhysicalDeviceType = "Plug" 62 | // Meter is SwitchBot Thermometer and Hygrometer Model No. SwitchBot MeterTH S1 63 | Meter PhysicalDeviceType = "Meter" 64 | // MeterPlusJP is SwitchBot Thermometer and Hygrometer Plus (JP) Model No. W2201500 65 | MeterPlusJP PhysicalDeviceType = "Meter Plus (JP)" 66 | // MeterPlusUS is SwitchBot Thermometer and Hygrometer Plus (US) Model No. W2301500 67 | MeterPlusUS PhysicalDeviceType = "Meter Plus (US)" 68 | // WoIOSensor is SwitchBot Indoor/Outdoor Thermo-Hygrometer Model No. W3400010 69 | WoIOSensor PhysicalDeviceType = "WoIOSensor" 70 | OutdoorMeter PhysicalDeviceType = WoIOSensor 71 | // Humidifier is SwitchBot Humidifier Model No. W0801801 72 | Humidifier PhysicalDeviceType = "Humidifier" 73 | // SmartFan is SwitchBot Smart Fan Model No. W0601100 74 | SmartFan PhysicalDeviceType = "Smart Fan" 75 | // StripLight is SwitchBot LED Strip Light Model No. W1701100 76 | StripLight PhysicalDeviceType = "Strip Light" 77 | // PlugMiniUS is SwitchBot Plug Mini (US) Model No. W1901400 78 | PlugMiniUS PhysicalDeviceType = "Plug Mini (US)" 79 | // PlugMiniJP is SwitchBot Plug Mini (JP) Model No. W2001400 80 | PlugMiniJP PhysicalDeviceType = "Plug Mini (JP)" 81 | // PlugMiniEU is Switchbot Plug Mini (EU) Model No. W7732300 82 | PlugMiniEU PhysicalDeviceType = "Plug Mini (EU)" 83 | // Lock is SwitchBot Lock Model No. W1601700 84 | Lock PhysicalDeviceType = "Smart Lock" 85 | // LockPro is SwitchBot Lock Pro Model No. W3500000 86 | LockPro PhysicalDeviceType = "Smart Lock Pro" 87 | // RobotVacuumCleanerS1 is SwitchBot Robot Vacuum Cleaner S1 Model No. W3011000; currently only available in Japan 88 | RobotVacuumCleanerS1 PhysicalDeviceType = "Robot Vacuum Cleaner S1" 89 | // RobotVacuumCleanerS1Plus is SwitchBot Robot Vacuum Cleaner S1 Plus Model No. W3011010; currently only available in Japan 90 | RobotVacuumCleanerS1Plus PhysicalDeviceType = "Robot Vacuum Cleaner S1 Plus" 91 | // FloorCleaningRobotS10 is SwitchBot Floor Cleaning Robot S10 Model No. W3211800 92 | FloorCleaningRobotS10 PhysicalDeviceType = "Robot Vacuum Cleaner S10" 93 | // FloorCleaningRobotS20 is SwitchBot Floor Cleaning Robot S20 Model No. W6602310 94 | S20 PhysicalDeviceType = "Floor Cleaning Robot S20" 95 | // WoSweeperMini is SwitchBot Robot Vacuum Cleaner K10+ Model No. W3011020 96 | WoSweeperMini PhysicalDeviceType = "WoSweeperMini" 97 | // WoSpeeperMiniPro is SwitchBot Robot Vacuum K10+ Pro Model No. W3011026 98 | WoSpeeperMiniPro PhysicalDeviceType = "WoSpeeperMiniPro" 99 | // K20PlusPro is SwitchBot Multitasking Household Robot K20+ Pro Model No. W3002520 100 | K20PlusPro PhysicalDeviceType = "K20+ Pro" 101 | // K11Plus is Robot Vacuum K11+ Model No. W3003100 102 | // MotionSensor is SwitchBot Motion Sensor Model No. W1101500 103 | MotionSensor PhysicalDeviceType = "Motion Sensor" 104 | // ContactSensor is SwitchBot Contact Sensor Model No. W1201500 105 | ContactSensor PhysicalDeviceType = "Contact Sensor" 106 | // WaterLeakDetector is SwitchBot Water Leak Detector Model No. W4402000 107 | WaterLeakDetector PhysicalDeviceType = "Water Detector" 108 | // ColorBulb is SwitchBot Color Bulb Model No. W1401400 109 | ColorBulb PhysicalDeviceType = "Color Bulb" 110 | // MeterPlus is SwitchBot Thermometer and Hygrometer Plus (JP) Model No. W2201500 / (US) Model No. W2301500 111 | MeterPlus PhysicalDeviceType = "MeterPlus" 112 | // KeyPad is SwitchBot Lock Model No. W2500010 113 | KeyPad PhysicalDeviceType = "Keypad" 114 | // KeyPadTouch is SwitchBot Lock Model No. W2500020 115 | KeyPadTouch PhysicalDeviceType = "Keypad Touch" 116 | // CeilingLight is SwitchBot Ceiling Light Model No. W2612230 and W2612240. 117 | CeilingLight PhysicalDeviceType = "Ceiling Light" 118 | // CeilingLightPro is SwitchBot Ceiling Light Pro Model No. W2612210 and W2612220. 119 | CeilingLightPro PhysicalDeviceType = "Ceiling Light Pro" 120 | // IndoorCam is SwitchBot Indoor Cam Model No. W1301200 121 | IndoorCam PhysicalDeviceType = "Indoor Cam" 122 | // PanTiltCam is SwitchBot Pan/Tilt Cam Model No. W1801200 123 | PanTiltCam PhysicalDeviceType = "Pan/Tilt Cam" 124 | // PanTiltCam2K is SwitchBot Pan/Tilt Cam 2K Model No. W3101100 125 | PanTiltCam2K PhysicalDeviceType = "Pan/Tilt Cam 2K" 126 | // BlindTilt is SwitchBot Blind Tilt Model No. W2701600 127 | BlindTilt PhysicalDeviceType = "Blind Tilt" 128 | // MeterPro is SwitchBot Thermometer and Hygrometer Pro Model No. W4900000 129 | MeterPro PhysicalDeviceType = "MeterPro" 130 | // MeterProCO2 is SwitchBot CO2 Sensor Model No. W4900010 131 | MeterProCO2 PhysicalDeviceType = "MeterPro(CO2)" 132 | // CirculatorFan is SwitchBot Circulator Fan Model No. W3800511 133 | CirculatorFan PhysicalDeviceType = "Circulator Fan" 134 | // BatteryCirculatorFan is SwitchBot Battery Circulator Fan Model No. W3800510 135 | BatteryCirculatorFan PhysicalDeviceType = "Battery Circulator Fan" 136 | // EvaporativeHumidifier is SwitchBot Evaporative Humidifier Model No. W3902300 137 | EvaporativeHumidifier PhysicalDeviceType = "Humidifier2" 138 | // EvaporativeHumidifierAutoRefill is SwitchBot Evaporative Humidifier (Auto-refill) Model No. W3902310 139 | EvaporativeHumidifierAutoRefill PhysicalDeviceType = "Humidifier2" 140 | ) 141 | 142 | type VirtualDeviceType string 143 | 144 | const ( 145 | AirConditioner VirtualDeviceType = "Air Conditioner" 146 | TV VirtualDeviceType = "TV" 147 | Light VirtualDeviceType = "Light" 148 | IPTVStreamer VirtualDeviceType = "IPTV/Streamer" 149 | SetTopBox VirtualDeviceType = "Set Top Box" 150 | DVD VirtualDeviceType = "DVD" 151 | Fan VirtualDeviceType = "Fan" 152 | Projector VirtualDeviceType = "Projector" 153 | Camera VirtualDeviceType = "Camera" 154 | AirPurifier VirtualDeviceType = "Air Purifier" 155 | Speaker VirtualDeviceType = "Speaker" 156 | WaterHeater VirtualDeviceType = "Water Heater" 157 | VacuumCleaner VirtualDeviceType = "Vacuum Cleaner" 158 | Others VirtualDeviceType = "Others" 159 | ) 160 | 161 | // New returns a new switchbot client associated with given openToken. 162 | // See https://github.com/OpenWonderLabs/SwitchBotAPI/blob/7a68353d84d07d439a11cb5503b634f24302f733/README.md#getting-started 163 | // for getting openToken for SwitchBot API. 164 | func New(openToken, secretKey string, opts ...Option) *Client { 165 | c := &Client{ 166 | httpClient: http.DefaultClient, 167 | 168 | openToken: openToken, 169 | secretKey: secretKey, 170 | endpoint: DefaultEndpoint, 171 | } 172 | 173 | c.deviceService = newDeviceService(c) 174 | c.sceneService = newSceneService(c) 175 | c.webhookService = newWebhookService(c) 176 | 177 | for _, opt := range opts { 178 | opt(c) 179 | } 180 | 181 | return c 182 | } 183 | 184 | // WithHTTPClient allows you to pass your http client for a SwitchBot API client. 185 | func WithHTTPClient(httpClient *http.Client) Option { 186 | return func(c *Client) { 187 | c.httpClient = httpClient 188 | } 189 | } 190 | 191 | // WithEndpoint allows you to set an endpoint of SwitchBot API. 192 | func WithEndpoint(endpoint string) Option { 193 | return func(c *Client) { 194 | c.endpoint = endpoint 195 | } 196 | } 197 | 198 | // WithDebug configures the client to print debug logs. 199 | func WithDebug() Option { 200 | return func(c *Client) { 201 | c.debug = true 202 | } 203 | } 204 | 205 | // httpResponse wraps a http.Response object to easily decode and close its response body. 206 | type httpResponse struct { 207 | *http.Response 208 | } 209 | 210 | func (resp *httpResponse) DecodeJSON(data any) error { 211 | if err := json.NewDecoder(resp.Response.Body).Decode(data); err != nil { 212 | return fmt.Errorf("decoding JSON data: %w", err) 213 | } 214 | 215 | return nil 216 | } 217 | 218 | func (resp *httpResponse) Close() { 219 | _, _ = io.Copy(io.Discard, resp.Body) 220 | _ = resp.Body.Close() 221 | } 222 | 223 | func (c *Client) do(ctx context.Context, method, path string, body io.Reader) (*httpResponse, error) { 224 | nonce := uuid.New().String() 225 | t := strconv.FormatInt(time.Now().UnixMilli(), 10) 226 | sign := hmacSHA256String(c.openToken+t+nonce, c.secretKey) 227 | 228 | req, err := http.NewRequestWithContext(ctx, method, c.endpoint+path, body) 229 | 230 | if err != nil { 231 | return nil, err 232 | } 233 | 234 | req.Header.Add("Authorization", c.openToken) 235 | req.Header.Add("sign", sign) 236 | req.Header.Add("nonce", nonce) 237 | req.Header.Add("t", t) 238 | req.Header.Add("Content-Type", "application/json; charset=utf8") 239 | 240 | if c.debug { 241 | dump, err := httputil.DumpRequestOut(req, true) 242 | if err != nil { 243 | return nil, err 244 | } 245 | log.Printf("Request:\n%s\n", dump) 246 | } 247 | 248 | resp, err := c.httpClient.Do(req) 249 | if err != nil { 250 | return nil, err 251 | } 252 | 253 | if c.debug { 254 | dump, err := httputil.DumpResponse(resp, true) 255 | if err != nil { 256 | return nil, err 257 | } 258 | log.Printf("Response:\n%s\n", dump) 259 | } 260 | 261 | // based on https://github.com/OpenWonderLabs/SwitchBotAPI/blob/7a68353d84d07d439a11cb5503b634f24302f733/README.md#standard-http-error-codes 262 | switch resp.StatusCode { 263 | case http.StatusBadRequest: 264 | return nil, errors.New("client has issues an invalid request") 265 | case http.StatusUnauthorized: 266 | return nil, errors.New("authorization for the API is required but the request has not been authenticated") 267 | case http.StatusForbidden: 268 | return nil, errors.New("the request has been authenticated but does not have permission or the resource is not found") 269 | case http.StatusNotAcceptable: 270 | return nil, errors.New("the client has requestd a MIM typ via the Accept header for a value not supported by the server") 271 | case http.StatusUnsupportedMediaType: 272 | return nil, errors.New("the client has defined a Content-Type header that is not supported by the server") 273 | case http.StatusUnprocessableEntity: 274 | return nil, errors.New("the client has made a valid request but the server cannot process it") 275 | case http.StatusTooManyRequests: 276 | return nil, errors.New("the client has exceeded the number of requests allowed for a givn time window") 277 | case http.StatusInternalServerError: 278 | return nil, errors.New("an unexpected error on the server has occurred") 279 | } 280 | 281 | return &httpResponse{Response: resp}, nil 282 | } 283 | 284 | func (c *Client) get(ctx context.Context, path string) (*httpResponse, error) { 285 | return c.do(ctx, http.MethodGet, path, nil) 286 | } 287 | 288 | func (c *Client) post(ctx context.Context, path string, body any) (*httpResponse, error) { 289 | var buf bytes.Buffer 290 | 291 | if err := json.NewEncoder(&buf).Encode(body); err != nil { 292 | return nil, err 293 | } 294 | 295 | return c.do(ctx, http.MethodPost, path, &buf) 296 | } 297 | 298 | func (c *Client) del(ctx context.Context, path string, body any) (*httpResponse, error) { 299 | var buf bytes.Buffer 300 | 301 | if err := json.NewEncoder(&buf).Encode(body); err != nil { 302 | return nil, err 303 | } 304 | 305 | return c.do(ctx, http.MethodDelete, path, &buf) 306 | } 307 | 308 | func hmacSHA256String(message, key string) string { 309 | signer := hmac.New(sha256.New, []byte(key)) 310 | signer.Write([]byte(message)) 311 | return strings.ToUpper(base64.StdEncoding.EncodeToString(signer.Sum(nil))) 312 | } 313 | -------------------------------------------------------------------------------- /device_test.go: -------------------------------------------------------------------------------- 1 | package switchbot_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | "time" 12 | 13 | "github.com/google/go-cmp/cmp" 14 | "github.com/nasa9084/go-switchbot/v5" 15 | ) 16 | 17 | var allowUnexported = cmp.AllowUnexported(switchbot.BrightnessState{}, switchbot.Mode{}) 18 | 19 | // https://github.com/OpenWonderLabs/SwitchBotAPI/blob/7a68353d84d07d439a11cb5503b634f24302f733/README.md#get-all-devices 20 | func TestDevices(t *testing.T) { 21 | srv := httptest.NewServer( 22 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 | w.WriteHeader(http.StatusOK) 24 | w.Write([]byte(`{ 25 | "statusCode": 100, 26 | "body": { 27 | "deviceList": [ 28 | { 29 | "deviceId": "500291B269BE", 30 | "deviceName": "Living Room Humidifier", 31 | "deviceType": "Humidifier", 32 | "enableCloudService": true, 33 | "hubDeviceId": "000000000000" 34 | } 35 | ], 36 | "infraredRemoteList": [ 37 | { 38 | "deviceId": "02-202008110034-13", 39 | "deviceName": "Living Room TV", 40 | "remoteType": "TV", 41 | "hubDeviceId": "FA7310762361" 42 | } 43 | ] 44 | }, 45 | "message": "success" 46 | }`)) 47 | }), 48 | ) 49 | defer srv.Close() 50 | 51 | c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) 52 | devices, infrared, err := c.Device().List(context.Background()) 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | 57 | t.Run("devices", func(t *testing.T) { 58 | if len(devices) != 1 { 59 | t.Errorf("the number of devices is expected to 1, but %d", len(devices)) 60 | return 61 | } 62 | 63 | got := devices[0] 64 | 65 | want := switchbot.Device{ 66 | ID: "500291B269BE", 67 | Name: "Living Room Humidifier", 68 | Type: switchbot.Humidifier, 69 | IsEnableCloudService: true, 70 | Hub: "000000000000", 71 | } 72 | 73 | if diff := cmp.Diff(want, got); diff != "" { 74 | t.Fatalf("device mismatch (-want +got):\n%s", diff) 75 | } 76 | }) 77 | 78 | t.Run("infrared devices", func(t *testing.T) { 79 | if len(infrared) != 1 { 80 | t.Errorf("the number of infrared devices is expected to 1, but %d", len(infrared)) 81 | return 82 | } 83 | 84 | got := infrared[0] 85 | 86 | want := switchbot.InfraredDevice{ 87 | ID: "02-202008110034-13", 88 | Name: "Living Room TV", 89 | Type: switchbot.TV, 90 | Hub: "FA7310762361", 91 | } 92 | 93 | if diff := cmp.Diff(want, got); diff != "" { 94 | t.Fatalf("infrared device mismatch (-want +got):\n%s", diff) 95 | } 96 | }) 97 | } 98 | 99 | func TestDeviceStatus(t *testing.T) { 100 | // https://github.com/OpenWonderLabs/SwitchBotAPI/blob/7a68353d84d07d439a11cb5503b634f24302f733/README.md#switchbot-meter-example 101 | t.Run("meter", func(t *testing.T) { 102 | srv := httptest.NewServer( 103 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 104 | if r.URL.Path != "/v1.1/devices/C271111EC0AB/status" { 105 | t.Fatalf("unexpected request path: %s", r.URL.Path) 106 | } 107 | 108 | w.WriteHeader(http.StatusOK) 109 | w.Write([]byte(`{ 110 | "statusCode": 100, 111 | "body": { 112 | "deviceId": "C271111EC0AB", 113 | "deviceType": "Meter", 114 | "hubDeviceId": "FA7310762361", 115 | "humidity": 52, 116 | "temperature": 26.1 117 | }, 118 | "message": "success" 119 | }`)) 120 | }), 121 | ) 122 | defer srv.Close() 123 | 124 | c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) 125 | got, err := c.Device().Status(context.Background(), "C271111EC0AB") 126 | if err != nil { 127 | t.Fatal(err) 128 | } 129 | 130 | want := switchbot.DeviceStatus{ 131 | ID: "C271111EC0AB", 132 | Type: switchbot.Meter, 133 | Hub: "FA7310762361", 134 | Humidity: 52, 135 | Temperature: 26.1, 136 | } 137 | 138 | if diff := cmp.Diff(want, got, allowUnexported); diff != "" { 139 | t.Fatalf("status mismatch (-want +got):\n%s", diff) 140 | } 141 | }) 142 | 143 | // https://github.com/OpenWonderLabs/SwitchBotAPI/blob/7a68353d84d07d439a11cb5503b634f24302f733/README.md#switchbot-curtain-example 144 | t.Run("curtain", func(t *testing.T) { 145 | srv := httptest.NewServer( 146 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 147 | if r.URL.Path != "/v1.1/devices/E2F6032048AB/status" { 148 | t.Fatalf("unexpected request path: %s", r.URL.Path) 149 | } 150 | 151 | w.WriteHeader(http.StatusOK) 152 | w.Write([]byte(`{ 153 | "statusCode": 100, 154 | "body": { 155 | "deviceId": "E2F6032048AB", 156 | "deviceType": "Curtain", 157 | "hubDeviceId": "FA7310762361", 158 | "calibrate": true, 159 | "group": false, 160 | "moving": false, 161 | "slidePosition": 0 162 | }, 163 | "message": "success" 164 | }`)) 165 | }), 166 | ) 167 | defer srv.Close() 168 | 169 | c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) 170 | got, err := c.Device().Status(context.Background(), "E2F6032048AB") 171 | if err != nil { 172 | t.Fatal(err) 173 | } 174 | 175 | want := switchbot.DeviceStatus{ 176 | ID: "E2F6032048AB", 177 | Type: switchbot.Curtain, 178 | Hub: "FA7310762361", 179 | IsCalibrated: true, 180 | IsGrouped: false, 181 | IsMoving: false, 182 | SlidePosition: 0, 183 | } 184 | 185 | if diff := cmp.Diff(want, got, allowUnexported); diff != "" { 186 | t.Fatalf("status mismatch (-want +got):\n%s", diff) 187 | } 188 | }) 189 | } 190 | 191 | func isSameStringErr(err1, err2 error) bool { 192 | if err1 == nil && err2 == nil { 193 | return true 194 | } 195 | 196 | if (err1 == nil && err2 != nil) || (err1 != nil && err2 == nil) { 197 | return false 198 | } 199 | 200 | return err1.Error() == err2.Error() 201 | } 202 | 203 | func TestDeviceStatusBrightness(t *testing.T) { 204 | type wants struct { 205 | IntValue int 206 | IntErr error 207 | 208 | AmbientValue switchbot.AmbientBrightness 209 | AmbientErr error 210 | } 211 | tests := []struct { 212 | label string 213 | body string 214 | want wants 215 | }{ 216 | { 217 | label: "color bulb", 218 | body: `{ "deviceType": "Color Bulb", "brightness": 100 }`, 219 | want: wants{ 220 | IntValue: 100, 221 | AmbientErr: errors.New("ambient brightness value is only available for motion sensor, contact sensor devices"), 222 | }, 223 | }, 224 | { 225 | label: "motion sensor", 226 | body: `{ "deviceType": "Motion Sensor", "brightness": "bright" }`, 227 | want: wants{ 228 | IntValue: -1, 229 | IntErr: errors.New("integer brightness value is only available for color bulb devices"), 230 | 231 | AmbientValue: switchbot.AmbientBrightnessBright, 232 | }, 233 | }, 234 | { 235 | label: "contact sensor", 236 | body: `{ "devcieType": "Contact Sensor", "brightness": "dim" }`, 237 | want: wants{ 238 | IntValue: -1, 239 | IntErr: errors.New("integer brightness value is only available for color bulb devices"), 240 | 241 | AmbientValue: switchbot.AmbientBrightnessDim, 242 | }, 243 | }, 244 | } 245 | 246 | for _, tt := range tests { 247 | t.Run(tt.label, func(t *testing.T) { 248 | srv := httptest.NewServer( 249 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 250 | w.WriteHeader(http.StatusOK) 251 | w.Write([]byte(fmt.Sprintf(`{ 252 | "statusCode": 100, 253 | "body": %s, 254 | "message": "success" 255 | }`, tt.body))) 256 | }), 257 | ) 258 | defer srv.Close() 259 | 260 | c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) 261 | got, err := c.Device().Status(context.Background(), "E2F6032048AB") 262 | if err != nil { 263 | t.Fatal(err) 264 | } 265 | 266 | if gotint, err := got.Brightness.Int(); gotint != tt.want.IntValue || !isSameStringErr(err, tt.want.IntErr) { 267 | t.Errorf("unexpected result for int brightness\n int value: %d != %d\n error: %v != %v", gotint, tt.want.IntValue, err, tt.want.IntErr) 268 | return 269 | } 270 | 271 | if gotAmbient, err := got.Brightness.AmbientBrightness(); gotAmbient != tt.want.AmbientValue || !isSameStringErr(err, tt.want.AmbientErr) { 272 | t.Errorf("unexpected result for ambient brightness\n ambient brightness value: %s != %s\n error: %v != %v", gotAmbient, tt.want.AmbientValue, err, tt.want.AmbientErr) 273 | return 274 | } 275 | }) 276 | } 277 | } 278 | 279 | func testDeviceCommand(t *testing.T, wantPath string, wantBody string) http.Handler { 280 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 281 | if r.URL.Path != wantPath { 282 | t.Fatalf("unexpected request path: %s != %s", r.URL.Path, wantPath) 283 | } 284 | 285 | b, err := io.ReadAll(r.Body) 286 | if err != nil { 287 | t.Fatal(err) 288 | } 289 | 290 | if got := string(b); got != wantBody { 291 | t.Fatalf("unexpected request body:\n got: %s\n want: %s", 292 | got, wantBody, 293 | ) 294 | } 295 | 296 | w.WriteHeader(http.StatusOK) 297 | w.Write([]byte(`{ 298 | "statusCode": 100, 299 | "body": {}, 300 | "message": "success" 301 | }`)) 302 | }) 303 | } 304 | 305 | func TestDeviceCommand(t *testing.T) { 306 | t.Run("clean with vacuum mode", func(t *testing.T) { 307 | srv := httptest.NewServer(testDeviceCommand( 308 | t, 309 | "/v1.1/devices/F7538E1ABC23/commands", 310 | `{"command":"startClean","parameter":"{\"action\":\"sweep\",\"param\":{\"fanLevel\":1,\"waterLevel\":1,\"times\":1}}","commandType":"command"} 311 | `, 312 | )) 313 | defer srv.Close() 314 | 315 | c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) 316 | 317 | cmd, err := switchbot.StartCleanCommand(switchbot.CleanModeSweep, 1, 1, 1) 318 | if err != nil { 319 | t.Fatal(err) 320 | } 321 | 322 | if err := c.Device().Command(context.Background(), "F7538E1ABC23", cmd); err != nil { 323 | t.Fatal(err) 324 | } 325 | }) 326 | 327 | t.Run("change the cleaning settings", func(t *testing.T) { 328 | srv := httptest.NewServer(testDeviceCommand( 329 | t, 330 | "/v1.1/devices/F7538E1ABC23/commands", 331 | `{"command":"changeParam","parameter":"{\"fanLevel\":2,\"waterLevel\":1,\"times\":1}","commandType":"command"} 332 | `, 333 | )) 334 | defer srv.Close() 335 | 336 | c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) 337 | 338 | cmd, err := switchbot.ChangeParamCommand(2, 1, 1) 339 | if err != nil { 340 | t.Fatal(err) 341 | } 342 | 343 | if err := c.Device().Command(context.Background(), "F7538E1ABC23", cmd); err != nil { 344 | t.Fatal(err) 345 | } 346 | }) 347 | 348 | t.Run("create a temporary passcode", func(t *testing.T) { 349 | srv := httptest.NewServer(testDeviceCommand( 350 | t, 351 | "/v1.1/devices/F7538E1ABCEB/commands", 352 | `{"command":"createKey","parameter":"{\"name\":\"Guest Code\",\"type\":\"timeLimit\",\"password\":\"12345678\",\"startTime\":1664640056,\"endTime\":1665331432}","commandType":"command"} 353 | `, 354 | )) 355 | defer srv.Close() 356 | 357 | c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) 358 | 359 | 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)) 360 | if err != nil { 361 | t.Fatal(err) 362 | } 363 | 364 | if err := c.Device().Command(context.Background(), "F7538E1ABCEB", cmd); err != nil { 365 | t.Fatal(err) 366 | } 367 | }) 368 | t.Run("turn a bot on", func(t *testing.T) { 369 | srv := httptest.NewServer(testDeviceCommand( 370 | t, 371 | "/v1.1/devices/210/commands", 372 | `{"command":"turnOn","parameter":"default","commandType":"command"} 373 | `, 374 | )) 375 | defer srv.Close() 376 | 377 | c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) 378 | 379 | if err := c.Device().Command(context.Background(), "210", switchbot.TurnOnCommand()); err != nil { 380 | t.Fatal(err) 381 | } 382 | }) 383 | 384 | t.Run("set the color value of a Color Bulb Request", func(t *testing.T) { 385 | srv := httptest.NewServer(testDeviceCommand( 386 | t, 387 | "/v1.1/devices/84F70353A411/commands", 388 | `{"command":"setColor","parameter":"122:80:20","commandType":"command"} 389 | `, 390 | )) 391 | defer srv.Close() 392 | 393 | c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) 394 | 395 | if err := c.Device().Command(context.Background(), "84F70353A411", switchbot.SetColorCommand(122, 80, 20)); err != nil { 396 | t.Fatal(err) 397 | } 398 | }) 399 | 400 | t.Run("set an air conditioner", func(t *testing.T) { 401 | srv := httptest.NewServer(testDeviceCommand( 402 | t, 403 | "/v1.1/devices/02-202007201626-70/commands", 404 | `{"command":"setAll","parameter":"26,1,3,on","commandType":"command"} 405 | `, 406 | )) 407 | defer srv.Close() 408 | 409 | c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) 410 | 411 | if err := c.Device().Command(context.Background(), "02-202007201626-70", switchbot.ACSetAllCommand(26, switchbot.ACAuto, switchbot.ACMedium, switchbot.PowerOn)); err != nil { 412 | t.Fatal(err) 413 | } 414 | }) 415 | 416 | t.Run("set trigger a customized button", func(t *testing.T) { 417 | srv := httptest.NewServer(testDeviceCommand( 418 | t, 419 | "/v1.1/devices/02-202007201626-10/commands", 420 | `{"command":"ボタン","parameter":"default","commandType":"customize"} 421 | `, 422 | )) 423 | defer srv.Close() 424 | 425 | c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) 426 | 427 | if err := c.Device().Command(context.Background(), "02-202007201626-10", switchbot.ButtonPushCommand("ボタン")); err != nil { 428 | t.Fatal(err) 429 | } 430 | }) 431 | } 432 | -------------------------------------------------------------------------------- /webhook.go: -------------------------------------------------------------------------------- 1 | package switchbot 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | ) 12 | 13 | type WebhookService struct { 14 | c *Client 15 | } 16 | 17 | func newWebhookService(c *Client) *WebhookService { 18 | return &WebhookService{c: c} 19 | } 20 | 21 | func (c *Client) Webhook() *WebhookService { 22 | return c.webhookService 23 | } 24 | 25 | type webhookSetupRequest struct { 26 | Action string `json:"action"` 27 | URL string `json:"url,omitempty"` 28 | DeviceList string `json:"deviceList,omitempty"` // currently only ALL is supported 29 | } 30 | 31 | type webhookSetupResponse struct { 32 | StatusCode int `json:"statusCode"` 33 | Body any `json:"body"` 34 | Message string `json:"message"` 35 | } 36 | 37 | // Setup configures the url that all the webhook events will be sent to. 38 | // Currently the deviceList is only supporting "ALL". 39 | func (svc *WebhookService) Setup(ctx context.Context, url, deviceList string) error { 40 | const path = "/v1.1/webhook/setupWebhook" 41 | 42 | if deviceList != "ALL" { 43 | return errors.New(`deviceList value is only supporting "ALL" for now`) 44 | } 45 | 46 | req := webhookSetupRequest{ 47 | Action: "setupWebhook", 48 | URL: url, 49 | DeviceList: deviceList, 50 | } 51 | 52 | resp, err := svc.c.post(ctx, path, req) 53 | if err != nil { 54 | return err 55 | } 56 | defer resp.Close() 57 | 58 | return nil 59 | } 60 | 61 | type webhookQueryRequest struct { 62 | Action WebhookQueryActionType `json:"action"` 63 | URLs []string `json:"urls"` 64 | } 65 | 66 | type WebhookQueryActionType string 67 | 68 | const ( 69 | QueryURL WebhookQueryActionType = "queryUrl" 70 | QueryDetails WebhookQueryActionType = "queryDetails" 71 | ) 72 | 73 | type webhookQueryResponse struct { 74 | } 75 | 76 | type webhookQueryUrlResponse struct { 77 | StatusCode int `json:"statusCode"` 78 | Message string `json:"message"` 79 | Body webhookQueryUrlResponseBody `json:"body"` 80 | } 81 | 82 | type webhookQueryUrlResponseBody struct { 83 | URLs []string `json:"urls"` 84 | } 85 | 86 | type webhookQueryDetailsResponse struct { 87 | StatusCode int `json:"statusCode"` 88 | Message string `json:"message"` 89 | Body []WebhookQueryDetails `json:"body"` 90 | } 91 | 92 | type WebhookQueryDetails struct { 93 | URL string `json:"url"` 94 | CreateTime int64 `json:"createTime"` 95 | LastUpdate int64 `json:"lastUpdateTime"` 96 | DeviceList string `json:"deviceList"` 97 | Enable bool `json:"enable"` 98 | } 99 | 100 | // Query retrieves the current configuration info of the webhook. 101 | // The second argument `url` is required for QueryDetails action type. 102 | func (svc *WebhookService) Query(ctx context.Context, action WebhookQueryActionType, url string) error { 103 | const path = "/v1.1/webhook/queryWebhook" 104 | 105 | req := webhookQueryRequest{ 106 | Action: action, 107 | } 108 | 109 | switch action { 110 | case QueryDetails: 111 | if url == "" { 112 | return errors.New("URL need to be specified when the action is queryDetails") 113 | } 114 | 115 | req.URLs = []string{url} 116 | } 117 | 118 | resp, err := svc.c.post(ctx, path, req) 119 | if err != nil { 120 | return err 121 | } 122 | defer resp.Close() 123 | 124 | return nil 125 | } 126 | 127 | // QueryUrl retrieves the current url configuration info of the webhook. 128 | func (svc *WebhookService) QueryUrl(ctx context.Context) (string, error) { 129 | const path = "/v1.1/webhook/queryWebhook" 130 | 131 | req := webhookQueryRequest{ 132 | Action: QueryURL, 133 | } 134 | 135 | resp, err := svc.c.post(ctx, path, req) 136 | if err != nil { 137 | return "", err 138 | } 139 | defer resp.Close() 140 | 141 | var response webhookQueryUrlResponse 142 | if err := resp.DecodeJSON(&response); err != nil { 143 | return "", err 144 | } 145 | 146 | if response.StatusCode == 190 { 147 | return "", fmt.Errorf("undocumented error %d occurred for queryWebhook API: %s", response.StatusCode, response.Message) 148 | } else if response.StatusCode != 100 { 149 | return "", fmt.Errorf("unknown error %d from queryWebhook API: %s", response.StatusCode, response.Message) 150 | } 151 | 152 | if len(response.Body.URLs) < 1 { 153 | return "", errors.New("queryWebhook API response urls is empty") 154 | } 155 | 156 | return response.Body.URLs[0], nil 157 | } 158 | 159 | // QueryDetails retrieves the current details configuration info of the webhook. 160 | func (svc *WebhookService) QueryDetails(ctx context.Context, url string) (*WebhookQueryDetails, error) { 161 | const path = "/v1.1/webhook/queryWebhook" 162 | 163 | req := webhookQueryRequest{ 164 | Action: QueryDetails, 165 | } 166 | req.URLs = []string{url} 167 | 168 | resp, err := svc.c.post(ctx, path, req) 169 | if err != nil { 170 | return nil, err 171 | } 172 | defer resp.Close() 173 | 174 | var response webhookQueryDetailsResponse 175 | if err := resp.DecodeJSON(&response); err != nil { 176 | return nil, err 177 | } 178 | 179 | if response.StatusCode == 190 { 180 | return nil, fmt.Errorf("undocumented error %d occurred for queryWebhook API: %s", response.StatusCode, response.Message) 181 | } else if response.StatusCode != 100 { 182 | return nil, fmt.Errorf("unknown error %d from queryWebhook API: %s", response.StatusCode, response.Message) 183 | } 184 | 185 | if len(response.Body) < 1 { 186 | return nil, errors.New("queryWebhook API response body is empty") 187 | } 188 | 189 | return &response.Body[0], nil 190 | } 191 | 192 | type webhookUpdateRequest struct { 193 | Action string `json:"action"` // the type of actions but for now this must be updateWebhook 194 | Config webhookConfig `json:"config"` 195 | } 196 | 197 | type webhookConfig struct { 198 | URL string `json:"url"` 199 | Enable bool `json:"enable"` 200 | } 201 | 202 | // Update do update the configuration of the webhook. 203 | func (svc *WebhookService) Update(ctx context.Context, url string, enable bool) error { 204 | const path = "/v1.1/webhook/updateWebhook" 205 | 206 | req := webhookUpdateRequest{ 207 | Action: "updateWebhook", 208 | Config: webhookConfig{ 209 | URL: url, 210 | Enable: enable, 211 | }, 212 | } 213 | 214 | resp, err := svc.c.post(ctx, path, req) 215 | if err != nil { 216 | return err 217 | } 218 | defer resp.Close() 219 | 220 | return nil 221 | } 222 | 223 | type webhookDeleteRequest struct { 224 | Action string `json:"action"` // the type of actions but for now this must be deleteWebhook 225 | URL string `json:"url"` 226 | } 227 | 228 | // Delete do delete the configuration of the webhook. 229 | func (svc *WebhookService) Delete(ctx context.Context, url string) error { 230 | const path = "/v1.1/webhook/deleteWebhook" 231 | 232 | req := webhookDeleteRequest{ 233 | Action: "deleteWebhook", 234 | URL: url, 235 | } 236 | 237 | resp, err := svc.c.post(ctx, path, req) 238 | if err != nil { 239 | return err 240 | } 241 | defer resp.Close() 242 | 243 | return nil 244 | } 245 | 246 | func deviceTypeFromWebhookRequest(r *http.Request) (string, error) { 247 | var rawBody bytes.Buffer 248 | var deviceTypeBody struct { 249 | Context struct { 250 | DeviceType string `json:"deviceType"` 251 | } `json:"context"` 252 | } 253 | 254 | if err := json.NewDecoder(io.TeeReader(r.Body, &rawBody)).Decode(&deviceTypeBody); err != nil { 255 | return "", err 256 | } 257 | 258 | r.Body = io.NopCloser(&rawBody) 259 | 260 | return deviceTypeBody.Context.DeviceType, nil 261 | } 262 | 263 | type BotEvent struct { 264 | EventType string `json:"eventType"` 265 | EventVersion string `json:"eventVersion"` 266 | Context BotEventContext `json:"context"` 267 | } 268 | 269 | type BotEventContext struct { 270 | DeviceType string `json:"deviceType"` 271 | DeviceMac string `json:"deviceMac"` 272 | TimeOfSample int64 `json:"timeOfSample"` 273 | 274 | // the current state of the device. This state is only valid for Switch Mode, 275 | // where "on" stands for on and "off" stands for off. It will return "on" or 276 | // "off" in Press Mode or Customize Mode, but the returned value can be neglected. 277 | Power string `json:"power"` 278 | } 279 | 280 | type CurtainEvent struct { 281 | EventType string `json:"eventType"` 282 | EventVersion string `json:"eventVersion"` 283 | Context CurtainEventContext `json:"context"` 284 | } 285 | 286 | type CurtainEventContext struct { 287 | DeviceType string `json:"deviceType"` 288 | DeviceMac string `json:"deviceMac"` 289 | TimeOfSample int64 `json:"timeOfSample"` 290 | 291 | // determines if the open position and the close position of 292 | // a device have been properly calibrated or not 293 | Calibrate bool `json:"calibrate"` 294 | // determines if a Curtain is paired with or grouped with another Curtain or not 295 | Group bool `json:"group"` 296 | // the percentage of the distance between the calibrated open position 297 | // and closed position that Curtain has traversed 298 | SlidePosition int `json:"slidePosition"` 299 | // the battery level of a Curtain 300 | Battery int `json:"battery"` 301 | } 302 | 303 | type Curtain3Event struct { 304 | EventType string `json:"eventType"` 305 | EventVersion string `json:"eventVersion"` 306 | Context Curtain3EventContext `json:"context"` 307 | } 308 | 309 | type Curtain3EventContext struct { 310 | DeviceType string `json:"deviceType"` 311 | DeviceMac string `json:"deviceMac"` 312 | TimeOfSample int64 `json:"timeOfSample"` 313 | 314 | // determines if the open position and the close position of a device have been properly calibrated or not 315 | IsCalibrated bool `json:"calibrate"` 316 | // determines if a Curtain is paired with or grouped with another Curtain or not 317 | IsGrouped bool `json:"group"` 318 | // the percentage of the distance between the calibrated open position and closed position that Curtain has traversed 319 | SlidePosition int `json:"slidePosition"` 320 | // the battery level of a Curtain 321 | Battery int `json:"battery"` 322 | } 323 | 324 | type MotionSensorEvent struct { 325 | EventType string `json:"eventType"` 326 | EventVersion string `json:"eventVersion"` 327 | Context MotionSensorEventContext `json:"context"` 328 | } 329 | 330 | type MotionSensorEventContext struct { 331 | DeviceType string `json:"deviceType"` 332 | DeviceMac string `json:"deviceMac"` 333 | TimeOfSample int64 `json:"timeOfSample"` 334 | 335 | // the motion state of the device, "DETECTED" stands for motion is detected; 336 | // "NOT_DETECTED" stands for motion has not been detected for some time 337 | DetectionState string `json:"detectionState"` 338 | Battery int `json:"battery"` 339 | } 340 | 341 | type ContactSensorEvent struct { 342 | EventType string `json:"eventType"` 343 | EventVersion string `json:"eventVersion"` 344 | Context ContactSensorEventContext `json:"context"` 345 | } 346 | 347 | type ContactSensorEventContext struct { 348 | DeviceType string `json:"deviceType"` 349 | DeviceMac string `json:"deviceMac"` 350 | TimeOfSample int64 `json:"timeOfSample"` 351 | 352 | // the motion state of the device, "DETECTED" stands for motion is detected; 353 | // "NOT_DETECTED" stands for motion has not been detected for some time 354 | DetectionState string `json:"detectionState"` 355 | // when the enter or exit mode gets triggered, "IN_DOOR" or "OUT_DOOR" is returned 356 | DoorMode string `json:"doorMode"` 357 | // the level of brightness, can be "bright" or "dim" 358 | Brightness AmbientBrightness `json:"brightness"` 359 | // the state of the contact sensor, can be "open" or "close" or "timeOutNotClose" 360 | OpenState string `json:"openState"` 361 | // the current battery level, 0-100 362 | Battery int `json:"battery"` 363 | } 364 | 365 | type WaterLeakDetectorEvent struct { 366 | EventType string `json:"eventType"` 367 | EventVersion string `json:"eventVersion"` 368 | Context WaterLeakDetectorEventContext `json:"context"` 369 | } 370 | 371 | type WaterLeakDetectorEventContext struct { 372 | DeviceType string `json:"deviceType"` 373 | DeviceMac string `json:"deviceMac"` 374 | TimeOfSample int64 `json:"timeOfSample"` 375 | 376 | DetectionState WaterLeakStatus `json:"detectionState"` 377 | Battery int `json:"battery"` 378 | } 379 | 380 | type MeterEvent struct { 381 | EventType string `json:"eventType"` 382 | EventVersion string `json:"eventVersion"` 383 | Context MeterEventContext `json:"context"` 384 | } 385 | 386 | type MeterEventContext struct { 387 | DeviceType string `json:"deviceType"` 388 | DeviceMac string `json:"deviceMac"` 389 | TimeOfSample int64 `json:"timeOfSample"` 390 | 391 | Temperature float64 `json:"temperature"` 392 | Scale string `json:"scale"` 393 | Humidity int `json:"humidity"` 394 | Battery int `json:"battery"` 395 | } 396 | 397 | type MeterPlusEvent struct { 398 | EventType string `json:"eventType"` 399 | EventVersion string `json:"eventVersion"` 400 | Context MeterPlusEventContext `json:"context"` 401 | } 402 | 403 | type MeterPlusEventContext struct { 404 | DeviceType string `json:"deviceType"` 405 | DeviceMac string `json:"deviceMac"` 406 | TimeOfSample int64 `json:"timeOfSample"` 407 | 408 | Temperature float64 `json:"temperature"` 409 | Scale string `json:"scale"` 410 | Humidity int `json:"humidity"` 411 | Battery int `json:"battery"` 412 | } 413 | 414 | type OutdoorMeterEvent struct { 415 | EventType string `json:"eventType"` 416 | EventVersion string `json:"eventVersion"` 417 | Context OutdoorMeterEventContext `json:"context"` 418 | } 419 | 420 | type OutdoorMeterEventContext struct { 421 | DeviceType string `json:"deviceType"` 422 | DeviceMac string `json:"deviceMac"` 423 | TimeOfSample int64 `json:"timeOfSample"` 424 | 425 | // the current temperature reading 426 | Temperature float64 `json:"temperature"` 427 | // the current temperature unit being used 428 | Scale string `json:"scale"` 429 | // the current humidity reading in percentage 430 | Humidity int `json:"humidity"` 431 | Battery int `json:"battery"` 432 | } 433 | 434 | type MeterProEvent struct { 435 | EventType string `json:"eventType"` 436 | EventVersion string `json:"eventVersion"` 437 | Context MeterProEventContext `json:"context"` 438 | } 439 | 440 | type MeterProEventContext struct { 441 | DeviceType string `json:"deviceType"` 442 | DeviceMac string `json:"deviceMac"` 443 | TimeOfSample int64 `json:"timeOfSample"` 444 | 445 | Temperature float64 `json:"temperature"` 446 | Scale string `json:"scale"` 447 | Humidity int `json:"humidity"` 448 | Battery int `json:"battery"` 449 | } 450 | 451 | type MeterProCO2Event struct { 452 | EventType string `json:"eventType"` 453 | EventVersion string `json:"eventVersion"` 454 | Context MeterProCO2EventContext `json:"context"` 455 | } 456 | 457 | type MeterProCO2EventContext struct { 458 | DeviceType string `json:"deviceType"` 459 | DeviceMac string `json:"deviceMac"` 460 | TimeOfSample int64 `json:"timeOfSample"` 461 | 462 | Temperature float64 `json:"temperature"` 463 | Scale string `json:"scale"` 464 | Humidity int `json:"humidity"` 465 | CO2 int `json:"CO2"` 466 | Battery int `json:"battery"` 467 | } 468 | 469 | type LockEvent struct { 470 | EventType string `json:"eventType"` 471 | EventVersion string `json:"eventVersion"` 472 | Context LockEventContext `json:"context"` 473 | } 474 | 475 | type LockEventContext struct { 476 | DeviceType string `json:"deviceType"` 477 | DeviceMac string `json:"deviceMac"` 478 | TimeOfSample int64 `json:"timeOfSample"` 479 | 480 | // the state of the device, "LOCKED" stands for the motor is rotated to locking position; 481 | // "UNLOCKED" stands for the motor is rotated to unlocking position; "JAMMED" stands for 482 | // the motor is jammed while rotating 483 | LockState string `json:"lockState"` 484 | } 485 | 486 | type LockProEvent struct { 487 | EventType string `json:"eventType"` 488 | EventVersion string `json:"eventVersion"` 489 | Context LockProEventContext `json:"context"` 490 | } 491 | 492 | type LockProEventContext struct { 493 | DeviceType string `json:"deviceType"` 494 | DeviceMac string `json:"deviceMac"` 495 | TimeOfSample int64 `json:"timeOfSample"` 496 | 497 | // the state of the device, "LOCKED" stands for the motor is rotated to locking position; 498 | // "UNLOCKED" stands for the motor is rotated to unlocking position; "JAMMED" stands for 499 | // the motor is jammed while rotating 500 | LockState string `json:"lockState"` 501 | } 502 | 503 | type IndoorCamEvent struct { 504 | EventType string `json:"eventType"` 505 | EventVersion string `json:"eventVersion"` 506 | Context IndoorCamEventContext `json:"context"` 507 | } 508 | 509 | type IndoorCamEventContext struct { 510 | DeviceType string `json:"deviceType"` 511 | DeviceMac string `json:"deviceMac"` 512 | TimeOfSample int64 `json:"timeOfSample"` 513 | 514 | // the detection state of the device, "DETECTED" stands for motion is detected 515 | DetectionState string `json:"detectionState"` 516 | } 517 | 518 | type PanTiltCamEvent struct { 519 | EventType string `json:"eventType"` 520 | EventVersion string `json:"eventVersion"` 521 | Context PanTiltCamEventContext `json:"context"` 522 | } 523 | 524 | type PanTiltCamEventContext struct { 525 | DeviceType string `json:"deviceType"` 526 | DeviceMac string `json:"deviceMac"` 527 | TimeOfSample int64 `json:"timeOfSample"` 528 | 529 | // the detection state of the device, "DETECTED" stands for motion is detected 530 | DetectionState string `json:"detectionState"` 531 | } 532 | 533 | type ColorBulbEvent struct { 534 | EventType string `json:"eventType"` 535 | EventVersion string `json:"eventVersion"` 536 | Context ColorBulbEventContext `json:"context"` 537 | } 538 | 539 | type ColorBulbEventContext struct { 540 | DeviceType string `json:"deviceType"` 541 | DeviceMac string `json:"deviceMac"` 542 | TimeOfSample int64 `json:"timeOfSample"` 543 | 544 | // the current power state of the device, "ON" or "OFF" 545 | PowerState PowerState `json:"powerState"` 546 | // the brightness value, range from 1 to 100 547 | Brightness int `json:"brightness"` 548 | // the color value, in the format of RGB value, "255:255:255" 549 | Color string `json:"color"` 550 | // the color temperature value, range from 2700 to 6500 551 | ColorTemperature int `json:"colorTemperature"` 552 | } 553 | 554 | type StripLightEvent struct { 555 | EventType string `json:"eventType"` 556 | EventVersion string `json:"eventVersion"` 557 | Context StripLightEventContext `json:"context"` 558 | } 559 | 560 | type StripLightEventContext struct { 561 | DeviceType string `json:"deviceType"` 562 | DeviceMac string `json:"deviceMac"` 563 | TimeOfSample int64 `json:"timeOfSample"` 564 | 565 | // the current power state of the device, "ON" or "OFF" 566 | PowerState PowerState `json:"powerState"` 567 | // the brightness value, range from 1 to 100 568 | Brightness int `json:"brightness"` 569 | // the color value, in the format of RGB value, "255:255:255" 570 | Color string `json:"color"` 571 | } 572 | 573 | type PlugMiniJPEvent struct { 574 | EventType string `json:"eventType"` 575 | EventVersion string `json:"eventVersion"` 576 | Context PlugMiniJPEventContext `json:"context"` 577 | } 578 | 579 | type PlugMiniJPEventContext struct { 580 | DeviceType string `json:"deviceType"` 581 | DeviceMac string `json:"deviceMac"` 582 | TimeOfSample int64 `json:"timeOfSample"` 583 | 584 | // the current power state of the device, "ON" or "OFF" 585 | PowerState PowerState `json:"powerState"` 586 | } 587 | 588 | type PlugMiniUSEvent struct { 589 | EventType string `json:"eventType"` 590 | EventVersion string `json:"eventVersion"` 591 | Context PlugMiniUSEventContext `json:"context"` 592 | } 593 | 594 | type PlugMiniUSEventContext struct { 595 | DeviceType string `json:"deviceType"` 596 | DeviceMac string `json:"deviceMac"` 597 | TimeOfSample int64 `json:"timeOfSample"` 598 | 599 | // the current power state of the device, "ON" or "OFF" 600 | PowerState PowerState `json:"powerState"` 601 | } 602 | 603 | type PlugMiniEUEvent struct { 604 | EventType string `json:"eventType"` 605 | EventVersion string `json:"eventVersion"` 606 | Context PlugMiniEUEventContext `json:"context"` 607 | } 608 | 609 | type PlugMiniEUEventContext struct { 610 | DeviceType string `json:"deviceType"` 611 | DeviceMac string `json:"deviceMac"` 612 | 613 | Online bool `json:"online"` 614 | OverTemperature bool `json:"overTemperature"` 615 | Overload bool `json:"overload"` 616 | // the switch state of the device. 1, on; 0, off 617 | SwitchStatus int `json:"switchStatus"` 618 | } 619 | 620 | type SweeperEvent struct { 621 | EventType string `json:"eventType"` 622 | EventVersion string `json:"eventVersion"` 623 | Context SweeperEventContext `json:"context"` 624 | } 625 | 626 | type SweeperEventContext struct { 627 | DeviceType string `json:"deviceType"` 628 | DeviceMac string `json:"deviceMac"` 629 | TimeOfSample int64 `json:"timeOfSample"` 630 | 631 | // the working status of the device, "StandBy", "Clearing", 632 | // "Paused", "GotoChargeBase", "Charging", "ChargeDone", 633 | // "Dormant", "InTrouble", "InRemoteControl", or "InDustCollecting" 634 | WorkingStatus CleanerWorkingStatus `json:"workingStatus"` 635 | // the connection status of the device, "online" or "offline" 636 | OnlineStatus CleanerOnlineStatus `json:"onlineStatus"` 637 | // the battery level. 638 | Battery int `json:"battery"` 639 | } 640 | 641 | type FloorCleaningRobotS10Event struct { 642 | EventType string `json:"eventType"` 643 | EventVersion string `json:"eventVersion"` 644 | Context FloorCleaningRobotS10EventContext `json:"context"` 645 | } 646 | 647 | type FloorCleaningRobotS10EventContext struct { 648 | DeviceType string `json:"deviceType"` 649 | DeviceMac string `json:"deviceMac"` 650 | TimeOfSample int64 `json:"timeOfSample"` 651 | 652 | WorkingStatus CleanerWorkingStatus `json:"workingStatus"` 653 | OnlineStatus CleanerOnlineStatus `json:"onlineStatus"` 654 | Battery int `json:"battery"` 655 | WaterBaseBattery int `json:"waterBaseBattery"` 656 | TaskType CleanerTaskType `json:"taskType"` 657 | } 658 | 659 | type CeilingEvent struct { 660 | EventType string `json:"eventType"` 661 | EventVersion string `json:"eventVersion"` 662 | Context CeilingEventContext `json:"context"` 663 | } 664 | 665 | type CeilingEventContext struct { 666 | DeviceType string `json:"deviceType"` 667 | DeviceMac string `json:"deviceMac"` 668 | TimeOfSample int64 `json:"timeOfSample"` 669 | 670 | // ON/OFF state 671 | PowerState PowerState `json:"powerState"` 672 | // the brightness value, range from 1 to 100 673 | Brightness int `json:"brightness"` 674 | // the color temperature value, range from 2700 to 6500 675 | ColorTemperature int `json:"colorTemperature"` 676 | } 677 | 678 | type KeypadEvent struct { 679 | EventType string `json:"eventType"` 680 | EventVersion string `json:"eventVersion"` 681 | Context KeypadEventContext `json:"context"` 682 | } 683 | 684 | type KeypadEventContext struct { 685 | DeviceType string `json:"deviceType"` 686 | DeviceMac string `json:"deviceMac"` 687 | TimeOfSample int64 `json:"timeOfSample"` 688 | 689 | // the name fo the command being sent 690 | EventName string `json:"eventName"` 691 | // the command ID 692 | CommandID string `json:"commandId"` 693 | // the result of the command, success, failed, or timeout 694 | Result string `json:"result"` 695 | } 696 | 697 | type Hub2Event struct { 698 | EventType string `json:"eventType"` 699 | EventVersion string `json:"eventVersion"` 700 | Context Hub2EventContext `json:"context"` 701 | } 702 | 703 | type Hub2EventContext struct { 704 | DeviceType string `json:"deviceType"` 705 | DeviceMac string `json:"deviceMac"` 706 | TimeOfSample int64 `json:"timeOfSample"` 707 | 708 | // the current temperature reading 709 | Temperature float64 `json:"temperature"` 710 | // the current humidity reading in percentage 711 | Humidity int `json:"humidity"` 712 | // the level of illuminance of the ambience light, 1~20 713 | LightLevel int `json:"lightLevel"` 714 | // the current temperature unit being used 715 | Scale string `json:"scale"` 716 | } 717 | 718 | type Hub3Event struct { 719 | EventType string `json:"eventType"` 720 | EventVersion string `json:"eventVersion"` 721 | Context Hub3EventContext `json:"context"` 722 | } 723 | 724 | type Hub3EventContext struct { 725 | DeviceType string `json:"deviceType"` 726 | DeviceMac string `json:"deviceMac"` 727 | TimeOfSample int64 `json:"timeOfSample"` 728 | 729 | // the motion state of the device, "DETECTED" stands for motion is detected; 730 | // "NOT_DETECTED" stands for motion has not been detected for some time 731 | DetectionState string `json:"detectionState"` 732 | // the current temperature reading 733 | Temperature float64 `json:"temperature"` 734 | // the current humidity reading in percentage 735 | Humidity int `json:"humidity"` 736 | // the level of illuminance of the ambience light, 1~20 737 | LightLevel int `json:"lightLevel"` 738 | // the current temperature unit being used 739 | Scale string `json:"scale"` 740 | } 741 | 742 | type BatteryCirculatorFanEvent struct { 743 | EventType string `json:"eventType"` 744 | EventVersion string `json:"eventVersion"` 745 | Context BatteryCirculatorFanEventContext `json:"context"` 746 | } 747 | 748 | type BatteryCirculatorFanEventContext struct { 749 | DeviceType string `json:"deviceType"` 750 | DeviceMac string `json:"deviceMac"` 751 | TimeOfSample int64 `json:"timeOfSample"` 752 | 753 | // fan mode. direct mode: *direct*; natural mode: "natural"; 754 | // sleep mode: "sleep"; ultra quiet mode: "baby" 755 | Mode CirculatorMode `json:"mode"` 756 | // the current firmware version, e.g. V4.2 757 | Version string `json:"version"` 758 | // the current battery level 759 | Battery int `json:"battery"` 760 | // ON/OFF state 761 | PowerState PowerState `json:"powerState"` 762 | // set nightlight status. turn off: *off*; mode 1: *1*; mode 2: *2* 763 | NightStatus NightStatus `json:"nightStatus"` 764 | // set horizontal oscillation. turn on: *on*; turn off: *off* 765 | Oscillation OscillationStatus `json:"oscillation"` 766 | // set vertical oscillation. turn on: *on*; turn off: *off* 767 | VerticalOscillation OscillationStatus `json:"verticalOscillation"` 768 | // battery charge status. *charging* or *uncharged* 769 | ChargingStatus ChargingStatus `json:"chargingStatus"` 770 | // fan speed. 1~100 771 | FanSpeed int `json:"fanSpeed"` 772 | } 773 | 774 | type EvaporativeHumidifierEvent struct { 775 | EventType string `json:"eventType"` 776 | EventVersion string `json:"eventVersion"` 777 | Context EvaporativeHumidifierEventContext `json:"context"` 778 | } 779 | 780 | type EvaporativeHumidifierEventContext struct { 781 | DeviceType string `json:"deviceType"` 782 | DeviceMac string `json:"deviceMac"` 783 | TimeOfSample int64 `json:"timeOfSample"` 784 | 785 | Power string `json:"power"` 786 | Mode EvaporativeHumidifierMode `json:"mode"` 787 | IsDrying bool `json:"drying"` 788 | } 789 | 790 | func ParseWebhookRequest(r *http.Request) (any, error) { 791 | deviceType, err := deviceTypeFromWebhookRequest(r) 792 | if err != nil { 793 | return nil, err 794 | } 795 | 796 | switch deviceType { 797 | case "WoHand": 798 | // Bot 799 | var event BotEvent 800 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 801 | return nil, err 802 | } 803 | return &event, nil 804 | case "WoCurtain": 805 | // Curtain 806 | var event CurtainEvent 807 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 808 | return nil, err 809 | } 810 | return &event, nil 811 | case "WoCurtain3": 812 | // Curtain 3 813 | var event Curtain3Event 814 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 815 | return nil, err 816 | } 817 | return &event, nil 818 | case "WoPresence": 819 | // Motion Sensor 820 | var event MotionSensorEvent 821 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 822 | return nil, err 823 | } 824 | return &event, nil 825 | case "WoContact": 826 | // Contact Sensor 827 | var event ContactSensorEvent 828 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 829 | return nil, err 830 | } 831 | return &event, nil 832 | case "WoIOSensor": 833 | // Indoor / Outdoor Meter 834 | var event OutdoorMeterEvent 835 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 836 | return nil, err 837 | } 838 | return &event, nil 839 | case "MeterPro": 840 | var event MeterProEvent 841 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 842 | return nil, err 843 | } 844 | return &event, nil 845 | case "MeterPro(CO2)": 846 | var event MeterProCO2Event 847 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 848 | return nil, err 849 | } 850 | return &event, nil 851 | case "WoLock": 852 | // Lock 853 | var event LockEvent 854 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 855 | return nil, err 856 | } 857 | return &event, nil 858 | case "WoLockPro": 859 | // Lock Pro 860 | var event LockProEvent 861 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 862 | return nil, err 863 | } 864 | return &event, nil 865 | case "WoCamera": 866 | // Indoor Cam 867 | var event IndoorCamEvent 868 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 869 | return nil, err 870 | } 871 | return &event, nil 872 | case "WoPanTiltCam": 873 | // Pan/Tilt Cam 874 | var event PanTiltCamEvent 875 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 876 | return nil, err 877 | } 878 | return &event, nil 879 | case "WoBulb": 880 | // Color Bulb 881 | var event ColorBulbEvent 882 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 883 | return nil, err 884 | } 885 | return &event, nil 886 | case "WoStrip": 887 | // LED Strip Light 888 | var event StripLightEvent 889 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 890 | return nil, err 891 | } 892 | return &event, nil 893 | case "WoPlugUS": 894 | // Plug Mini (US) 895 | var event PlugMiniUSEvent 896 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 897 | return nil, err 898 | } 899 | return &event, nil 900 | case "WoPlugJP": 901 | // Plug Mini (JP) 902 | var event PlugMiniJPEvent 903 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 904 | return nil, err 905 | } 906 | return &event, nil 907 | case "Plug Mini (EU)": 908 | // Plug Mini (EU) 909 | var event PlugMiniEUEvent 910 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 911 | return nil, err 912 | } 913 | return &event, nil 914 | case "Water Detector": 915 | // Water Leak Detector 916 | var event WaterLeakDetectorEvent 917 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 918 | return nil, err 919 | } 920 | return &event, nil 921 | case "WoMeter": 922 | // Meter 923 | var event MeterEvent 924 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 925 | return nil, err 926 | } 927 | return &event, nil 928 | case "WoMeterPlus": 929 | // Meter Plus 930 | var event MeterPlusEvent 931 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 932 | return nil, err 933 | } 934 | return &event, nil 935 | case "WoSweeper", "WoSweeperPlus", "WoSweeperMini", "WoSweeperMiniPro": 936 | // Cleaner 937 | var event SweeperEvent 938 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 939 | return nil, err 940 | } 941 | return &event, nil 942 | case "Robot Vacuum Cleaner S10": 943 | // Floor Cleaning Robot S10 944 | var event FloorCleaningRobotS10Event 945 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 946 | return nil, err 947 | } 948 | return &event, nil 949 | case "WoCeiling", "WoCeilingPro": 950 | // Ceiling lights 951 | var event CeilingEvent 952 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 953 | return nil, err 954 | } 955 | return &event, nil 956 | case "WoKeypad", "WoKeypadTouch": 957 | // keypad 958 | var event KeypadEvent 959 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 960 | return nil, err 961 | } 962 | return &event, nil 963 | case "WoHub2": 964 | // Hub 2 965 | var event Hub2Event 966 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 967 | return nil, err 968 | } 969 | return &event, nil 970 | case "WoHub3", "Hub 3": 971 | // Hub 3 972 | // Documented device type is `WoHub3` at https://github.com/OpenWonderLabs/SwitchBotAPI/blob/main/README.md#receive-events-from-webhook 973 | // but `Hub 3` at https://github.com/OpenWonderLabs/SwitchBotAPI/blob/main/README.md#hub-3-1 974 | var event Hub3Event 975 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 976 | return nil, err 977 | } 978 | return &event, nil 979 | case "WoFan2": 980 | // Battery Circulator Fan 981 | var event BatteryCirculatorFanEvent 982 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 983 | return nil, err 984 | } 985 | return &event, nil 986 | case "Humidifier2": 987 | // EvaporativeHumidifier 988 | var event EvaporativeHumidifierEvent 989 | if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 990 | return nil, err 991 | } 992 | return &event, nil 993 | default: 994 | return nil, fmt.Errorf("unknown device type: %s", deviceType) 995 | } 996 | } 997 | -------------------------------------------------------------------------------- /device.go: -------------------------------------------------------------------------------- 1 | package switchbot 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "strconv" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | // DeviceService handles API calls related to devices. 14 | // The devices API is used to access the properties and states of 15 | // SwitchBot devices and to send control commands to those devices. 16 | type DeviceService struct { 17 | c *Client 18 | } 19 | 20 | func newDeviceService(c *Client) *DeviceService { 21 | return &DeviceService{c: c} 22 | } 23 | 24 | // Device returns the Service object for device APIs. 25 | func (c *Client) Device() *DeviceService { 26 | return c.deviceService 27 | } 28 | 29 | type devicesResponse struct { 30 | StatusCode int `json:"statusCode"` 31 | Message string `json:"message"` 32 | Body devicesResponseBody `json:"body"` 33 | } 34 | 35 | type devicesResponseBody struct { 36 | DeviceList []Device `json:"deviceList"` 37 | InfraredRemoteList []InfraredDevice `json:"infraredRemoteList"` 38 | } 39 | 40 | // Device represents a physical SwitchBot device. 41 | type Device struct { 42 | ID string `json:"deviceId"` 43 | Name string `json:"deviceName"` 44 | Type PhysicalDeviceType `json:"deviceType"` 45 | IsEnableCloudService bool `json:"enableCloudService"` 46 | Hub string `json:"hubDeviceId"` 47 | Curtains []string `json:"curtainDevicesIds"` 48 | IsCalibrated bool `json:"calibrate"` 49 | IsGrouped bool `json:"group"` 50 | IsMaster bool `json:"master"` 51 | OpenDirection string `json:"openDirection"` 52 | GroupName string `json:"groupName"` 53 | LockDeviceIDs []string `json:"lockDeviceIds"` 54 | LockDeviceID string `json:"lockDeviceId"` 55 | KeyList []KeyListItem `json:"keyList"` 56 | Version DeviceVersion `json:"version"` 57 | BlindTilts []string `json:"blindTiltDeviceIds"` 58 | Direction string `json:"direction"` 59 | SlidePosition int `json:"slidePosition"` 60 | } 61 | 62 | // KeyListItem is an item for keyList, which maintains a list of passcodes. 63 | type KeyListItem struct { 64 | ID int `json:"id"` 65 | Name string `json:"name"` 66 | Type PasscodeType `json:"type"` 67 | Password string `json:"password"` 68 | IV string `json:"iv"` 69 | Status PasscodeStatus `json:"status"` 70 | CreateTime int64 `json:"createTime"` 71 | } 72 | 73 | type PasscodeType string 74 | 75 | const ( 76 | PermanentPasscode PasscodeType = "permanent" 77 | TimeLimitPasscode PasscodeType = "timeLimit" 78 | DisposablePasscode PasscodeType = "disposable" 79 | UrgentPasscode PasscodeType = "urgent" 80 | ) 81 | 82 | type PasscodeStatus string 83 | 84 | const ( 85 | PasscodeStatusValid PasscodeStatus = "normal" 86 | PasscodeStautsInvalid PasscodeStatus = "expired" 87 | ) 88 | 89 | // InfraredDevice represents a virtual infrared remote device. 90 | type InfraredDevice struct { 91 | ID string `json:"deviceId"` 92 | Name string `json:"deviceName"` 93 | Type VirtualDeviceType `json:"remoteType"` 94 | Hub string `json:"hubDeviceId"` 95 | } 96 | 97 | // List get a list of devices, which include physical devices and virtual infrared 98 | // remote devices that have been added to the current user's account. 99 | // The first returned value is a list of physical devices refer to the SwitchBot products. 100 | // The second returned value is a list of virtual infrared remote devices such like 101 | // air conditioner, TV, light, or so on. 102 | // See also https://github.com/OpenWonderLabs/SwitchBotAPI/blob/7a68353d84d07d439a11cb5503b634f24302f733/README.md#get-device-list 103 | func (svc *DeviceService) List(ctx context.Context) ([]Device, []InfraredDevice, error) { 104 | const path = "/v1.1/devices" 105 | 106 | resp, err := svc.c.get(ctx, path) 107 | if err != nil { 108 | return nil, nil, err 109 | } 110 | defer resp.Close() 111 | 112 | var response devicesResponse 113 | if err := resp.DecodeJSON(&response); err != nil { 114 | return nil, nil, err 115 | } 116 | 117 | if response.StatusCode == 190 { 118 | return nil, nil, errors.New("device internal error due to device states not synchronized with server or too many requests limit reached") 119 | } else if response.StatusCode != 100 { 120 | return nil, nil, fmt.Errorf("unknown error %d from device list API", response.StatusCode) 121 | } 122 | 123 | return response.Body.DeviceList, response.Body.InfraredRemoteList, nil 124 | } 125 | 126 | type deviceStatusResponse struct { 127 | StatusCode int `json:"statusCode"` 128 | Message string `json:"message"` 129 | Body DeviceStatus `json:"body"` 130 | } 131 | 132 | type DeviceStatus struct { 133 | ID string `json:"deviceId"` 134 | Type PhysicalDeviceType `json:"deviceType"` 135 | Hub string `json:"hubDeviceId"` 136 | Power PowerState `json:"power"` 137 | Humidity int `json:"humidity"` 138 | Temperature float64 `json:"temperature"` 139 | NebulizationEfficiency int `json:"nebulizationEfficiency"` 140 | IsAuto bool `json:"auto"` 141 | IsChildLock bool `json:"childLock"` 142 | IsSound bool `json:"sound"` 143 | IsCalibrated bool `json:"calibrate"` 144 | IsGrouped bool `json:"group"` 145 | IsMoving bool `json:"moving"` 146 | SlidePosition int `json:"slidePosition"` 147 | IsMoveDetected bool `json:"moveDetected"` 148 | Brightness BrightnessState `json:"brightness"` 149 | LightLevel int `json:"lightLevel"` 150 | OpenState OpenState `json:"openState"` 151 | Color string `json:"color"` 152 | ColorTemperature int `json:"colorTemperature"` 153 | IsLackWater bool `json:"lackWater"` 154 | Voltage float64 `json:"voltage"` 155 | Weight float64 `json:"weight"` 156 | ElectricityOfDay int `json:"electricityOfDay"` 157 | ElectricCurrent float64 `json:"electricCurrent"` 158 | LockState string `json:"lockState"` 159 | DoorState string `json:"doorState"` 160 | WorkingStatus CleanerWorkingStatus `json:"workingStatus"` 161 | OnlineStatus CleanerOnlineStatus `json:"onlineStatus"` 162 | Battery int `json:"battery"` 163 | WaterBaseBattery int `json:"waterBaseBattery"` 164 | TaskType CleanerTaskType `json:"taskType"` 165 | Version DeviceVersion `json:"version"` 166 | Direction string `json:"direction"` 167 | CO2 int `json:"CO2"` 168 | Mode Mode `json:"mode"` 169 | NightStatus NightStatus `json:"nightStatus"` 170 | Oscillation OscillationStatus `json:"oscillation"` 171 | VerticalOscillation OscillationStatus `json:"verticalOscillation"` 172 | ChargingStatus ChargingStatus `json:"chargingStatus"` 173 | FanSpeed int `json:"fanSpeed"` 174 | DeviceMode BotDeviceMode `json:"deviceMode"` 175 | LeakStatus WaterLeakStatus `json:"status"` 176 | UsedElectricity int `json:"usedElectricity"` 177 | IsDetected bool `json:"detected"` 178 | IsDrying bool `json:"drying"` 179 | FilterElement FilterElement `json:"filterElement"` 180 | } 181 | 182 | type PowerState string 183 | 184 | const ( 185 | PowerOn PowerState = "ON" 186 | PowerOff PowerState = "OFF" 187 | ) 188 | 189 | func (power PowerState) ToLower() string { 190 | return strings.ToLower(string(power)) 191 | } 192 | 193 | type OpenState string 194 | 195 | const ( 196 | ContactOpen OpenState = "open" 197 | ContactClose OpenState = "close" 198 | ContactTimeoutNotClose OpenState = "timeOutNotClose" 199 | ) 200 | 201 | type BrightnessState struct { 202 | intBrightness int 203 | ambientBrightness AmbientBrightness 204 | } 205 | 206 | func (brightness *BrightnessState) UnmarshalJSON(b []byte) error { 207 | brightness.intBrightness = -1 // set invalid value first 208 | 209 | var iv int 210 | if err := json.Unmarshal(b, &iv); err != nil { 211 | var sv string 212 | if err := json.Unmarshal(b, &sv); err != nil { 213 | return fmt.Errorf("cannot unmarshal to both of int and string: %w", err) 214 | } 215 | 216 | brightness.ambientBrightness = AmbientBrightness(sv) 217 | 218 | return nil 219 | } 220 | 221 | brightness.intBrightness = iv 222 | 223 | return nil 224 | } 225 | 226 | func (brightness BrightnessState) Int() (int, error) { 227 | if brightness.intBrightness < 0 { 228 | return -1, errors.New("integer brightness value is only available for color bulb devices") 229 | } 230 | 231 | return brightness.intBrightness, nil 232 | } 233 | 234 | func (brightness BrightnessState) AmbientBrightness() (AmbientBrightness, error) { 235 | if brightness.ambientBrightness != "" { 236 | return brightness.ambientBrightness, nil 237 | } 238 | 239 | return "", errors.New("ambient brightness value is only available for motion sensor, contact sensor devices") 240 | } 241 | 242 | type AmbientBrightness string 243 | 244 | const ( 245 | AmbientBrightnessBright AmbientBrightness = "bright" 246 | AmbientBrightnessDim AmbientBrightness = "dim" 247 | ) 248 | 249 | type CleanerOnlineStatus string 250 | 251 | const ( 252 | CleanerOnline CleanerOnlineStatus = "online" 253 | CleanerOffline CleanerOnlineStatus = "offline" 254 | ) 255 | 256 | type CleanerWorkingStatus string 257 | 258 | const ( 259 | CleanerStandBy CleanerWorkingStatus = "StandBy" 260 | CleanerClearing CleanerWorkingStatus = "Clearing" 261 | 262 | CleanerGotoChargeBase CleanerWorkingStatus = "GotoChargeBase" 263 | CleanerCharging CleanerWorkingStatus = "Charging" 264 | CleanerChargeDone CleanerWorkingStatus = "ChargeDone" 265 | CleanerDormant CleanerWorkingStatus = "Dormant" 266 | CleanerInTrouble CleanerWorkingStatus = "InTrouble" 267 | CleanerInRemoteControl CleanerWorkingStatus = "InRemoteControl" 268 | CleanerInDustCollecting CleanerWorkingStatus = "InDustCollecting" 269 | ) 270 | 271 | type CleanerTaskType string 272 | 273 | const ( 274 | CleanerTaskStandBy CleanerTaskType = "standBy" 275 | CleanerTaskExplore CleanerTaskType = "explore" 276 | CleanerTaskCleanAll CleanerTaskType = "cleanAll" 277 | CleanerTaskCleanArea CleanerTaskType = "cleanArea" 278 | CleanerTaskCleanRoom CleanerTaskType = "cleanRoom" 279 | CleanerTaskFillWater CleanerTaskType = "fillWater" 280 | CleanerTaskDeepWashing CleanerTaskType = "deepWashing" 281 | CleanerTaskBackToCharge CleanerTaskType = "backToCharge" 282 | CleanerTaskMarkingWaterBase CleanerTaskType = "merkingWaterBase" 283 | CleanerTaskDrying CleanerTaskType = "drying" 284 | CleanerTaskCollectDust CleanerTaskType = "collectDust" 285 | CleanerTaskRemoteControl CleanerTaskType = "remoteControl" 286 | CleanerTaskCleanWithExplore CleanerTaskType = "cleanWithExplore" 287 | CleanerTaskFillWaterForHumi CleanerTaskType = "fillWaterForHumi" 288 | CleanerTaskMarkingHumi CleanerTaskType = "markingHumi" 289 | ) 290 | 291 | type Mode struct { 292 | circulatorMode CirculatorMode 293 | intMode int // evaporative humidifier or air purifier 294 | } 295 | 296 | func (mode *Mode) UnmarshalJSON(b []byte) error { 297 | var iv int 298 | if err := json.Unmarshal(b, &iv); err != nil { 299 | var sv string 300 | if err := json.Unmarshal(b, &sv); err != nil { 301 | return fmt.Errorf("cannot unmarshal to both of int and string: %w", err) 302 | } 303 | 304 | mode.circulatorMode = CirculatorMode(sv) 305 | 306 | return nil 307 | } 308 | 309 | mode.intMode = iv 310 | 311 | return nil 312 | } 313 | 314 | func (mode Mode) CirculatorMode() (CirculatorMode, error) { 315 | if mode.circulatorMode != "" { 316 | return mode.circulatorMode, nil 317 | } 318 | 319 | return "", errors.New("circulator mode is only available for circulator and battery circulator") 320 | } 321 | 322 | type CirculatorMode string 323 | 324 | const ( 325 | CirculatorModeDirect CirculatorMode = "direct" 326 | CirculatorModeNatural CirculatorMode = "natural" 327 | CirculatorModeSleep CirculatorMode = "sleep" 328 | CirculatorModeUltraQuitet CirculatorMode = "baby" 329 | ) 330 | 331 | type NightStatus string 332 | 333 | const ( 334 | NightStatusOff NightStatus = "off" 335 | NightStatusMode1 NightStatus = "1" 336 | NightStatusMode2 NightStatus = "2" 337 | ) 338 | 339 | type OscillationStatus string 340 | 341 | const ( 342 | OscillationStatusOn OscillationStatus = "on" 343 | OscillationStatusOff OscillationStatus = "off" 344 | ) 345 | 346 | type ChargingStatus string 347 | 348 | const ( 349 | ChargingStatusCharging ChargingStatus = "charging" 350 | ChargingStatusUncharged ChargingStatus = "uncharged" 351 | ) 352 | 353 | type BotDeviceMode string 354 | 355 | const ( 356 | BotDeviceModePress BotDeviceMode = "pressMode" 357 | BotDeviceModeSwitch BotDeviceMode = "switchMode" 358 | BotDeviceModeCustomize BotDeviceMode = "customizeMode" 359 | ) 360 | 361 | type WaterLeakStatus int 362 | 363 | const ( 364 | WaterLeakStatusDry WaterLeakStatus = iota 365 | WaterLeakStatusLeakDetected 366 | ) 367 | 368 | type FilterElement struct { 369 | // EffectiveUsageHours shows the effective duration of the humidifier filter in hours. 370 | EffectiveUsageHours int `json:"effectiveUsageHours"` 371 | // UsedHours shows the number of hours the humidifier filter has been used. 372 | UsedHours int `json:"usedHours"` 373 | } 374 | 375 | // Status get the status of a physical device that has been added to the current 376 | // user's account. Physical devices refer to the SwitchBot products. 377 | // The first given argument `id` is a device ID which can be retrieved by 378 | // (*Client).Device().List() function. 379 | // See also https://github.com/OpenWonderLabs/SwitchBotAPI/blob/7a68353d84d07d439a11cb5503b634f24302f733/README.md#get-device-status 380 | func (svc *DeviceService) Status(ctx context.Context, id string) (DeviceStatus, error) { 381 | path := "/v1.1/devices/" + id + "/status" 382 | 383 | resp, err := svc.c.get(ctx, path) 384 | if err != nil { 385 | return DeviceStatus{}, err 386 | } 387 | defer resp.Close() 388 | 389 | var response deviceStatusResponse 390 | if err := resp.DecodeJSON(&response); err != nil { 391 | return DeviceStatus{}, err 392 | } 393 | 394 | if response.StatusCode == 190 { 395 | return DeviceStatus{}, errors.New("device internal error due to device states not synchronized with server") 396 | } else if response.StatusCode != 100 { 397 | return DeviceStatus{}, fmt.Errorf("unknown error %d from device list API", response.StatusCode) 398 | } 399 | 400 | return response.Body, nil 401 | } 402 | 403 | // Command is an interface which represents Commands for devices to be used (*Client).Device().Command() method. 404 | type Command interface { 405 | Request() DeviceCommandRequest 406 | } 407 | 408 | type DeviceCommandRequest struct { 409 | Command string `json:"command"` 410 | Parameter string `json:"parameter,omitempty"` 411 | CommandType string `json:"commandType,omitempty"` 412 | } 413 | 414 | type deviceCommandResponse struct { 415 | StatusCode int `json:"statusCode"` 416 | Message string `json:"message"` 417 | } 418 | 419 | func (svc *DeviceService) Command(ctx context.Context, id string, cmd Command) error { 420 | path := "/v1.1/devices/" + id + "/commands" 421 | 422 | resp, err := svc.c.post(ctx, path, cmd.Request()) 423 | if err != nil { 424 | return err 425 | } 426 | defer resp.Close() 427 | 428 | var response deviceCommandResponse 429 | if err := resp.DecodeJSON(&response); err != nil { 430 | return err 431 | } 432 | 433 | switch response.StatusCode { 434 | case 151: 435 | return errors.New("device type error") 436 | case 152: 437 | return errors.New("device not found") 438 | case 160: 439 | return errors.New("command is not supported") 440 | case 161: 441 | return errors.New("device is offline") 442 | case 171: 443 | return errors.New("hub device is offline") 444 | case 190: 445 | return errors.New("device internal error due to device states not synchronizeed with server or command format is invalid") 446 | } 447 | 448 | return nil 449 | } 450 | 451 | func (req DeviceCommandRequest) Request() DeviceCommandRequest { 452 | return req 453 | } 454 | 455 | // TurnOnCommand returns a new Command which turns on Bot, Plug, Curtain, Humidifier, or so on. 456 | // For curtain devices, turn on is equivalent to set position to 0. 457 | func TurnOnCommand() Command { 458 | return DeviceCommandRequest{ 459 | Command: "turnOn", 460 | Parameter: "default", 461 | CommandType: "command", 462 | } 463 | } 464 | 465 | // TurnOffCommand returns a nw Command which turns off Bot, plug, Curtain, Humidifier, or so on. 466 | // For curtain devices, turn off is equivalent to set position to 100. 467 | func TurnOffCommand() Command { 468 | return DeviceCommandRequest{ 469 | Command: "turnOff", 470 | Parameter: "default", 471 | CommandType: "command", 472 | } 473 | } 474 | 475 | type pressCommand struct{} 476 | 477 | // PressCommand returns a new command which trigger Bot's press command. 478 | func PressCommand() Command { 479 | return DeviceCommandRequest{ 480 | Command: "press", 481 | Parameter: "default", 482 | CommandType: "command", 483 | } 484 | } 485 | 486 | type setPositionCommand struct { 487 | index int 488 | mode SetPositionMode 489 | position int 490 | } 491 | 492 | // SetPositionMode represents a mode for curtain devices' set position mode. 493 | type SetPositionMode int 494 | 495 | const ( 496 | DefaultMode SetPositionMode = iota 497 | PerformanceMode 498 | SilentMode 499 | ) 500 | 501 | // SetPositionCommand returns a new Command which sets curtain devices' position. 502 | // The third argument `position` can be take 0 - 100 value, 0 means opened 503 | // and 100 means closed. The position value will be treated as 0 if the given 504 | // value is less than 0, or treated as 100 if the given value is over 100. 505 | func SetPosition(index int, mode SetPositionMode, position int) Command { 506 | if position < 0 { 507 | position = 0 508 | } else if 100 < position { 509 | position = 100 510 | } 511 | 512 | var parameter string 513 | 514 | parameter += strconv.Itoa(index) + "," 515 | 516 | switch mode { 517 | case PerformanceMode, SilentMode: 518 | parameter += strconv.Itoa(int(mode)) 519 | default: 520 | parameter += "ff" 521 | } 522 | parameter += "," 523 | parameter += strconv.Itoa(position) 524 | 525 | return DeviceCommandRequest{ 526 | Command: "setPosition", 527 | Parameter: parameter, 528 | CommandType: "command", 529 | } 530 | } 531 | 532 | // Pause returns a new Command to set curtain devices' state to PAUSE. 533 | func Pause() Command { 534 | return DeviceCommandRequest{ 535 | Command: "pause", 536 | Parameter: "default", 537 | CommandType: "command", 538 | } 539 | } 540 | 541 | // LockCommand returns a new Command which rotates the Lock device to locked position. 542 | func LockCommand() Command { 543 | return DeviceCommandRequest{ 544 | Command: "lock", 545 | Parameter: "default", 546 | CommandType: "command", 547 | } 548 | } 549 | 550 | // LockCommand returns a new Command which rotates the Lock device to unlocked position. 551 | func UnlockCommand() Command { 552 | return DeviceCommandRequest{ 553 | Command: "unlock", 554 | Parameter: "default", 555 | CommandType: "command", 556 | } 557 | } 558 | 559 | type HumidifierMode int 560 | 561 | const ( 562 | AutoMode HumidifierMode = -1 563 | LowMode HumidifierMode = 101 564 | MidMode HumidifierMode = 102 565 | HighMode HumidifierMode = 103 566 | ) 567 | 568 | // SetModeCommand returns a new Command which sets a mode for Humidifier. mode can be take one of HumidifierMode 569 | // constants or 0 - 100 value. To use exact value 0 - 100, you need to pass like 570 | // HumidifierMode(38). 571 | func SetModeCommand(mode HumidifierMode) Command { 572 | var parameter string 573 | 574 | if mode == AutoMode { 575 | parameter = "auto" 576 | } else { 577 | parameter = strconv.Itoa(int(mode)) 578 | } 579 | 580 | return DeviceCommandRequest{ 581 | Command: "setMode", 582 | Parameter: parameter, 583 | CommandType: "command", 584 | } 585 | } 586 | 587 | type EvaporativeHumidifierMode int 588 | 589 | const ( 590 | EvaporativeHumidifierLevel4 EvaporativeHumidifierMode = iota + 1 591 | EvaporativeHumidifierLevel3 592 | EvaporativeHumidifierLevel2 593 | EvaporativeHumidifierLevel1 594 | EvaporativeHumidifierHumidityMode 595 | EvaporativeHumidifierSleepMode 596 | EvaporativeHumidifierAutoMode 597 | EvaporativeHumidifierDryingMode 598 | ) 599 | 600 | // SetEvaporativeHumidifierModeCommand returns a new Command which sets the mode for EvaporativeHumidifier or 601 | // EvaporativeHumidifier (Auto-refill). 602 | // targetHumidity, the target humidity level is in percentage 0-100. 603 | func SetEvaporativeHumidifierModeCommand(mode EvaporativeHumidifierMode, targetHumidity int) (Command, error) { 604 | if targetHumidity < 0 || 100 < targetHumidity { 605 | return nil, errors.New("targetHumidity must be between 0 and 100") 606 | } 607 | 608 | return DeviceCommandRequest{ 609 | Command: "setMode", 610 | Parameter: fmt.Sprintf(`{"mode":%d,"targetHumidity":%d}`, mode, targetHumidity), 611 | CommandType: "command", 612 | }, nil 613 | } 614 | 615 | // SetChildLock returns a new Command which sets the childLock mode for EvaporativeHumidifier or 616 | // EvaporativeHumidifier (Auto-refill). 617 | func SetChildLock(isEnabled bool) Command { 618 | return DeviceCommandRequest{ 619 | Command: "setChildLock", 620 | Parameter: strconv.FormatBool(isEnabled), 621 | CommandType: "command", 622 | } 623 | } 624 | 625 | type SmartFanMode int 626 | 627 | const ( 628 | StandardFanMode SmartFanMode = 1 629 | NaturalFanMode SmartFanMode = 2 630 | ) 631 | 632 | // SetAllStatusCommand returns a new Commend which sets all status for smart fan. 633 | func SetAllStatusCommand(power PowerState, fanMode SmartFanMode, fanSpeed, shakeRange int) Command { 634 | return DeviceCommandRequest{ 635 | Command: "setAllStatus", 636 | Parameter: fmt.Sprintf("%s,%d,%d,%d", power.ToLower(), fanMode, fanSpeed, shakeRange), 637 | CommandType: "command", 638 | } 639 | } 640 | 641 | // ToggleCommand returns a new Command which toggles state of color bulb, strip light or plug mini. 642 | func ToggleCommand() Command { 643 | return DeviceCommandRequest{ 644 | Command: "toggle", 645 | Parameter: "default", 646 | CommandType: "command", 647 | } 648 | } 649 | 650 | // SetBrightnessCommand returns a new Command which set brightness of color bulb, strip light, or ceiling ligths. 651 | func SetBrightnessCommand(brightness int) Command { 652 | return DeviceCommandRequest{ 653 | Command: "setBrightness", 654 | Parameter: strconv.Itoa(brightness), 655 | CommandType: "command", 656 | } 657 | } 658 | 659 | // SetColorCommand returns a new Command which set RGB color value of color bulb or strip light. 660 | func SetColorCommand(r, g, b int) Command { 661 | return DeviceCommandRequest{ 662 | Command: "setColor", 663 | Parameter: fmt.Sprintf("%d:%d:%d", r, g, b), 664 | CommandType: "command", 665 | } 666 | } 667 | 668 | // SetColorTemperatureCommand returns a new Command which set color temperature of color bulb or ceiling lights. 669 | func SetColorTemperatureCommand(temperature int) Command { 670 | return DeviceCommandRequest{ 671 | Command: "setColorTemperature", 672 | Parameter: strconv.Itoa(temperature), 673 | CommandType: "command", 674 | } 675 | } 676 | 677 | // StartCommand returns a new Command which starts vacuuming. 678 | func StartCommand() Command { 679 | return DeviceCommandRequest{ 680 | Command: "start", 681 | Parameter: "default", 682 | CommandType: "command", 683 | } 684 | } 685 | 686 | // StopCommand returns a new Command which stops vacuuming. 687 | func StopCommand() Command { 688 | return DeviceCommandRequest{ 689 | Command: "stop", 690 | Parameter: "default", 691 | CommandType: "command", 692 | } 693 | } 694 | 695 | // DockCommand returns a new Command which returns robot vacuum cleaner to charging dock. 696 | func DockCommand() Command { 697 | return DeviceCommandRequest{ 698 | Command: "dock", 699 | Parameter: "default", 700 | CommandType: "command", 701 | } 702 | } 703 | 704 | type VacuumPowerLevel int 705 | 706 | const ( 707 | QuietVacuumPowerLevel VacuumPowerLevel = 0 708 | StandardVacuumPowerLevel VacuumPowerLevel = 1 709 | StrongVacuumPowerLevel VacuumPowerLevel = 2 710 | MaxVacuumPowerLevel VacuumPowerLevel = 3 711 | ) 712 | 713 | // PowLevelCommand returns a new Command which sets suction power level of robot vacuum cleaner. 714 | func PowLevelCommand(level VacuumPowerLevel) Command { 715 | return DeviceCommandRequest{ 716 | Command: "PowLevel", 717 | Parameter: strconv.Itoa(int(level)), 718 | CommandType: "command", 719 | } 720 | } 721 | 722 | type createKeyCommandParameters struct { 723 | Name string `json:"name"` 724 | Type PasscodeType `json:"type"` 725 | Password string `json:"password"` 726 | Start int64 `json:"startTime"` 727 | End int64 `json:"endTime"` 728 | } 729 | 730 | // CreateKeyCommand returns a new Command which creates a new key for Lock devices. 731 | // Due to security concerns, the created passcodes will be stored locally so you need 732 | // to get the result through webhook. 733 | // A name is a unique name for the passcode, duplicates under the same device are not allowed. 734 | // A password must be a 6 to 12 digit passcode. 735 | // Start time and end time are required for one-time passcode (DisposablePasscode) or temporary 736 | // passcode (TimeLimitPasscode). 737 | func CreateKeyCommand(name string, typ PasscodeType, password string, start, end time.Time) (Command, error) { 738 | if len(password) < 6 || 12 < len(password) { 739 | return nil, fmt.Errorf("the length of password must be 6 to 12 but %d", len(password)) 740 | } 741 | 742 | if (typ == TimeLimitPasscode || typ == DisposablePasscode) && (start.IsZero() || end.IsZero()) { 743 | return nil, fmt.Errorf("when passcode type is %s, startTime and endTime is required but either/both is zero value", typ) 744 | } 745 | 746 | params := createKeyCommandParameters{ 747 | Name: name, 748 | Type: typ, 749 | Password: password, 750 | Start: start.Unix(), 751 | End: end.Unix(), 752 | } 753 | data, err := json.Marshal(params) 754 | if err != nil { 755 | return nil, err 756 | } 757 | 758 | return DeviceCommandRequest{ 759 | Command: "createKey", 760 | Parameter: string(data), 761 | CommandType: "command", 762 | }, nil 763 | } 764 | 765 | // DeleteKeyCommand returns a new Command which deletes a key from Lock devices. 766 | func DeleteKeyCommand(id int) Command { 767 | return DeviceCommandRequest{ 768 | Command: "deleteKey", 769 | Parameter: fmt.Sprintf(`{"id": %d}`, id), 770 | CommandType: "command", 771 | } 772 | } 773 | 774 | // ButtonPushCommand returns a new Command which triggers button push. 775 | func ButtonPushCommand(name string) Command { 776 | return DeviceCommandRequest{ 777 | Command: name, 778 | Parameter: "default", 779 | CommandType: "customize", 780 | } 781 | } 782 | 783 | // BlindTiltSetPositionDirection represents the direction for blind tilt devices' set position direction. 784 | type BlindTiltSetPositionDirection string 785 | 786 | const ( 787 | UpDirection BlindTiltSetPositionDirection = "up" 788 | DownDirection BlindTiltSetPositionDirection = "down" 789 | ) 790 | 791 | // BlindTiltSetPosition returns a new Command which sets blind tilt devices' position. 792 | func BlindTiltSetPositionCommand(direction BlindTiltSetPositionDirection, position int) Command { 793 | parameter := string(direction) + ";" + strconv.Itoa(position) 794 | 795 | return DeviceCommandRequest{ 796 | Command: "setPosition", 797 | Parameter: parameter, 798 | CommandType: "command", 799 | } 800 | } 801 | 802 | // FullyOpenCommand returns a new Command which sets the blind tilt devices' position to open. 803 | // This is equivalent to up;100 or down;100 which means equivalent to BlindTiltSetPositionCommand(UpDirection, 100) 804 | // or BlindTiltSetPositionCommand(DownDirection, 100) but the command itself is different. 805 | func FullyOpenCommand() Command { 806 | return DeviceCommandRequest{ 807 | Command: "fullyOpen", 808 | Parameter: "default", 809 | CommandType: "command", 810 | } 811 | } 812 | 813 | // CloseUpCommand returns a new Command which sets the blind tilt devices' position to closed up. 814 | // This is equivalent to up;0 which means equivalent to BlindTiltSetPositionCommand(UpDirection, 0) 815 | // but the command itself is different. 816 | func CloseUpCommand() Command { 817 | return DeviceCommandRequest{ 818 | Command: "closeUp", 819 | Parameter: "default", 820 | CommandType: "command", 821 | } 822 | } 823 | 824 | // CloseDownCommand returns a new Command which sets the blind tilt devices' position to closed down. 825 | // This is equivalent to down;0 which means equivalent to BlindTiltSetPositionCommand(DownDirection, 0) 826 | // but the command itself is different. 827 | func CloseDownCommand() Command { 828 | return DeviceCommandRequest{ 829 | Command: "closeDown", 830 | Parameter: "default", 831 | CommandType: "command", 832 | } 833 | } 834 | 835 | // SetNightLightModeCommand returns a new Command which sets the circulator devices' night light mode. 836 | func SetNightLightModeCommand(mode NightStatus) Command { 837 | return DeviceCommandRequest{ 838 | Command: "setNightLightMode", 839 | Parameter: string(mode), 840 | CommandType: "command", 841 | } 842 | } 843 | 844 | // SetWindModeCommand returns a new Command which sets the curculator devices' wind mode. 845 | func SetWindModeCommand(mode CirculatorMode) Command { 846 | return DeviceCommandRequest{ 847 | Command: "setWindMode", 848 | Parameter: string(mode), 849 | CommandType: "command", 850 | } 851 | } 852 | 853 | type CleanMode string 854 | 855 | const ( 856 | CleanModeSweep CleanMode = "sweep" 857 | CleanModeSweepMop CleanMode = "sweep_mop" 858 | ) 859 | 860 | // StartCleanCommand returns a new Command which starts cleaning. 861 | func StartCleanCommand(mode CleanMode, vacuumLevel int, waterLevel int, cleanTimes int) (Command, error) { 862 | if vacuumLevel < 1 || 4 < vacuumLevel { 863 | return nil, errors.New("vacuumLevel must be between 1-4") 864 | } 865 | 866 | if waterLevel != 1 && waterLevel != 2 { 867 | return nil, errors.New("waterLevel must be 1 or 2") 868 | } 869 | 870 | if cleanTimes < 1 || 2639999 < cleanTimes { 871 | return nil, errors.New("cleanTimes must be between 1-2639999") 872 | } 873 | 874 | return DeviceCommandRequest{ 875 | Command: "startClean", 876 | Parameter: fmt.Sprintf(`{"action":"%s","param":{"fanLevel":%d,"waterLevel":%d,"times":%d}}`, 877 | mode, 878 | vacuumLevel, 879 | waterLevel, 880 | cleanTimes, 881 | ), 882 | CommandType: "command", 883 | }, nil 884 | } 885 | 886 | // AddWaterForHumiCommand returns a new Command which refills the mind blowing evaporative humidifier. 887 | func AddWaterForHumiCommand() Command { 888 | return DeviceCommandRequest{ 889 | Command: "addWaterForHumi", 890 | Parameter: "default", 891 | CommandType: "command", 892 | } 893 | } 894 | 895 | // SetVolumeCommand returns a new Command which set volume. 896 | func SetVolumeCommand(volume int) (Command, error) { 897 | if volume < 0 || 100 < volume { 898 | return nil, errors.New("volume must be between 0-100") 899 | } 900 | 901 | return DeviceCommandRequest{ 902 | Command: "setVolume", 903 | Parameter: strconv.Itoa(volume), 904 | CommandType: "command", 905 | }, nil 906 | } 907 | 908 | type SelfCleanMode int 909 | 910 | const ( 911 | SelfCleanModeWashMop SelfCleanMode = iota + 1 912 | SelfCleanModeDry 913 | SelfCleanModeTerminate 914 | ) 915 | 916 | // SelfCleanCommand returns a new Command which cleans the floor cleaning robot. 917 | func SelfCleanCommand(mode SelfCleanMode) Command { 918 | return DeviceCommandRequest{ 919 | Command: "selfClean", 920 | Parameter: strconv.Itoa(int(mode)), 921 | CommandType: "command", 922 | } 923 | } 924 | 925 | // ChangeParamCommand returns a new Command which changes the cleaning parameter. 926 | func ChangeParamCommand(vacuumLevel int, waterLevel int, cleanTimes int) (Command, error) { 927 | if vacuumLevel < 1 || 4 < vacuumLevel { 928 | return nil, errors.New("vacuumLevel must be between 1-4") 929 | } 930 | 931 | if waterLevel != 1 && waterLevel != 2 { 932 | return nil, errors.New("waterLevel must be 1 or 2") 933 | } 934 | 935 | if cleanTimes < 1 || 2639999 < cleanTimes { 936 | return nil, errors.New("cleanTimes must be between 1-2639999") 937 | } 938 | 939 | return DeviceCommandRequest{ 940 | Command: "changeParam", 941 | Parameter: fmt.Sprintf(`{"fanLevel":%d,"waterLevel":%d,"times":%d}`, 942 | vacuumLevel, 943 | waterLevel, 944 | cleanTimes, 945 | ), 946 | CommandType: "command", 947 | }, nil 948 | } 949 | 950 | // ACMode represents a mode for air conditioner. 951 | type ACMode int 952 | 953 | const ( 954 | ACAuto ACMode = iota + 1 955 | ACCool 956 | ACDry 957 | ACFan 958 | ACHeat 959 | ) 960 | 961 | // ACFanSpeed represents a fan speed mode for air conditioner. 962 | type ACFanSpeed int 963 | 964 | const ( 965 | ACAutoSpeed ACFanSpeed = iota + 1 966 | ACLow 967 | ACMedium 968 | ACHigh 969 | ) 970 | 971 | // ACSetAllCommand returns a new Command which sets all state of air conditioner. 972 | func ACSetAllCommand(temperature int, mode ACMode, fanSpeed ACFanSpeed, power PowerState) Command { 973 | return DeviceCommandRequest{ 974 | Command: "setAll", 975 | Parameter: fmt.Sprintf("%d,%d,%d,%s", temperature, mode, fanSpeed, power.ToLower()), 976 | CommandType: "command", 977 | } 978 | } 979 | 980 | // SetChannelCommand returns a new Command which set the TV channel to given channel. 981 | func SetChannelCommand(channelNumber int) Command { 982 | return DeviceCommandRequest{ 983 | Command: "SetChannel", 984 | Parameter: strconv.Itoa(channelNumber), 985 | CommandType: "command", 986 | } 987 | } 988 | 989 | // VolumeAddCommand returns a new Command which is for volume up. 990 | func VolumeAddCommand() Command { 991 | return DeviceCommandRequest{ 992 | Command: "volumeAdd", 993 | Parameter: "default", 994 | CommandType: "command", 995 | } 996 | } 997 | 998 | // VolumeSubCommand returns a new Command which is for volume up. 999 | func VolumeSubCommand() Command { 1000 | return DeviceCommandRequest{ 1001 | Command: "volumeSub", 1002 | Parameter: "default", 1003 | CommandType: "command", 1004 | } 1005 | } 1006 | 1007 | // ChannelAddCommand returns a new Command which is for switching to next channel. 1008 | func ChannelAddCommand() Command { 1009 | return DeviceCommandRequest{ 1010 | Command: "channelAdd", 1011 | Parameter: "default", 1012 | CommandType: "command", 1013 | } 1014 | } 1015 | 1016 | // ChannelSubCommand returns a new Command which is for switching to previous channel. 1017 | func ChannelSubCommand() Command { 1018 | return DeviceCommandRequest{ 1019 | Command: "channelSub", 1020 | Parameter: "default", 1021 | CommandType: "command", 1022 | } 1023 | } 1024 | 1025 | // SetMuteCommand returns a new Command to make DVD player or speaker mute/unmute. 1026 | func SetMuteCommand() Command { 1027 | return DeviceCommandRequest{ 1028 | Command: "setMute", 1029 | Parameter: "default", 1030 | CommandType: "command", 1031 | } 1032 | } 1033 | 1034 | // FastForwardCommand returns a new Command to make DVD player or speaker fastforward. 1035 | func FastForwardCommand() Command { 1036 | return DeviceCommandRequest{ 1037 | Command: "FastForward", 1038 | Parameter: "default", 1039 | CommandType: "command", 1040 | } 1041 | } 1042 | 1043 | // RewindCommand returns a new Command to make DVD player or speaker rewind. 1044 | func RewindCommand() Command { 1045 | return DeviceCommandRequest{ 1046 | Command: "Rewind", 1047 | Parameter: "default", 1048 | CommandType: "command", 1049 | } 1050 | } 1051 | 1052 | // NextCommand returns a new Command to switch DVD player or speaker to next track. 1053 | func NextCommand() Command { 1054 | return DeviceCommandRequest{ 1055 | Command: "Next", 1056 | Parameter: "default", 1057 | CommandType: "command", 1058 | } 1059 | } 1060 | 1061 | // PreviousCommand returns a new Command to switch DVD player or speaker to previous track. 1062 | func PreviousCommand() Command { 1063 | return DeviceCommandRequest{ 1064 | Command: "Previous", 1065 | Parameter: "default", 1066 | CommandType: "command", 1067 | } 1068 | } 1069 | 1070 | // PauseCommand returns a new Command to make DVD player or speaker pause. 1071 | func PauseCommand() Command { 1072 | return DeviceCommandRequest{ 1073 | Command: "Pause", 1074 | Parameter: "default", 1075 | CommandType: "command", 1076 | } 1077 | } 1078 | 1079 | // PlayCommand returns a new Command to make DVD player or speaker play. 1080 | func PlayCommand() Command { 1081 | return DeviceCommandRequest{ 1082 | Command: "Play", 1083 | Parameter: "default", 1084 | CommandType: "command", 1085 | } 1086 | } 1087 | 1088 | // PlayerStopCommand returns a new Command to make DVD player or speaker stop. 1089 | func StopPlayerCommand() Command { 1090 | return DeviceCommandRequest{ 1091 | Command: "Stop", 1092 | Parameter: "default", 1093 | CommandType: "command", 1094 | } 1095 | } 1096 | 1097 | // FanSwingCommand returns a new Command which makes a fan swing. 1098 | func FanSwingCommand() Command { 1099 | return DeviceCommandRequest{ 1100 | Command: "swing", 1101 | Parameter: "default", 1102 | CommandType: "command", 1103 | } 1104 | } 1105 | 1106 | // FanTimerCommand returns a new Command which sets timer for a fan. 1107 | func FanTimerCommand() Command { 1108 | return DeviceCommandRequest{ 1109 | Command: "timer", 1110 | Parameter: "default", 1111 | CommandType: "command", 1112 | } 1113 | } 1114 | 1115 | // FanLowSpeedCommand returns a new Command which sets fan speed to low. 1116 | func FanLowSpeedCommand() Command { 1117 | return DeviceCommandRequest{ 1118 | Command: "lowSpeed", 1119 | Parameter: "default", 1120 | CommandType: "command", 1121 | } 1122 | } 1123 | 1124 | // FanMiddleSpeedCommand returns a new Command which sets fan speed to medium. 1125 | func FanMiddleSpeedCommand() Command { 1126 | return DeviceCommandRequest{ 1127 | Command: "middleSpeed", 1128 | Parameter: "default", 1129 | CommandType: "command", 1130 | } 1131 | } 1132 | 1133 | // FanHighSpeedCommand returns a new Command which sets fan speed to high. 1134 | func FanHighSpeedCommand() Command { 1135 | return DeviceCommandRequest{ 1136 | Command: "highSpeed", 1137 | Parameter: "default", 1138 | CommandType: "command", 1139 | } 1140 | } 1141 | 1142 | // LightBrightnessUpCommand returns a new Command which make light's brigtness up. 1143 | func LightBrightnessUpCommand() Command { 1144 | return DeviceCommandRequest{ 1145 | Command: "brightnessUp", 1146 | Parameter: "default", 1147 | CommandType: "command", 1148 | } 1149 | } 1150 | 1151 | // LightBrightnessDownCommand returns a new Command which make light's brigtness down. 1152 | func LightBrightnessDownCommand() Command { 1153 | return DeviceCommandRequest{ 1154 | Command: "brightnessDown", 1155 | Parameter: "default", 1156 | CommandType: "command", 1157 | } 1158 | } 1159 | -------------------------------------------------------------------------------- /webhook_test.go: -------------------------------------------------------------------------------- 1 | package switchbot_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/google/go-cmp/cmp" 12 | "github.com/nasa9084/go-switchbot/v5" 13 | ) 14 | 15 | func TestWebhookSetup(t *testing.T) { 16 | srv := httptest.NewServer( 17 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 | w.Write([]byte(`{"statusCode":100,"body":{},"message":""}`)) 19 | 20 | if r.Method != http.MethodPost { 21 | t.Fatalf("POST method is expected but %s", r.Method) 22 | } 23 | 24 | var got map[string]string 25 | if err := json.NewDecoder(r.Body).Decode(&got); err != nil { 26 | t.Fatal(err) 27 | } 28 | 29 | want := map[string]string{ 30 | "action": "setupWebhook", 31 | "url": "url1", 32 | "deviceList": "ALL", 33 | } 34 | 35 | if diff := cmp.Diff(want, got); diff != "" { 36 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 37 | } 38 | }), 39 | ) 40 | defer srv.Close() 41 | 42 | c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) 43 | 44 | if err := c.Webhook().Setup(context.Background(), "url1", "ALL"); err != nil { 45 | t.Fatal(err) 46 | } 47 | } 48 | 49 | func TestWebhookQuery(t *testing.T) { 50 | t.Run("queryUrl", func(t *testing.T) { 51 | srv := httptest.NewServer( 52 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 53 | w.Write([]byte(`{"statusCode":100,"body":{"urls":[url1]},"message":""}`)) 54 | 55 | if r.Method != http.MethodPost { 56 | t.Fatalf("POST method is expected but %s", r.Method) 57 | } 58 | 59 | var got map[string]string 60 | if err := json.NewDecoder(r.Body).Decode(&got); err != nil { 61 | t.Fatal(err) 62 | } 63 | 64 | want := map[string]string{ 65 | "action": "queryUrl", 66 | "urls": "", 67 | } 68 | 69 | if diff := cmp.Diff(want, got); diff != "" { 70 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 71 | } 72 | }), 73 | ) 74 | defer srv.Close() 75 | 76 | c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) 77 | 78 | if err := c.Webhook().Query(context.Background(), switchbot.QueryURL, ""); err != nil { 79 | t.Fatal(err) 80 | } 81 | }) 82 | 83 | t.Run("queryDetails", func(t *testing.T) { 84 | srv := httptest.NewServer( 85 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 86 | w.Write([]byte(`{"statusCode":100,"body":[{"url":url1,"createTime":123456,"lastUpdateTime":123456,"deviceList":"ALL","enable":true}],"message":""}`)) 87 | 88 | if r.Method != http.MethodPost { 89 | t.Fatalf("POST method is expected but %s", r.Method) 90 | } 91 | 92 | var got map[string]any 93 | if err := json.NewDecoder(r.Body).Decode(&got); err != nil { 94 | t.Fatal(err) 95 | } 96 | 97 | want := map[string]any{ 98 | "action": "queryDetails", 99 | "urls": []any{"url1"}, 100 | } 101 | 102 | if diff := cmp.Diff(want, got); diff != "" { 103 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 104 | } 105 | }), 106 | ) 107 | defer srv.Close() 108 | 109 | c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) 110 | 111 | if err := c.Webhook().Query(context.Background(), switchbot.QueryDetails, "url1"); err != nil { 112 | t.Fatal(err) 113 | } 114 | }) 115 | } 116 | 117 | func TestWebhookUpdate(t *testing.T) { 118 | srv := httptest.NewServer( 119 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 120 | w.Write([]byte(`{"statusCode":100,"body":{},"message":""}`)) 121 | 122 | if r.Method != http.MethodPost { 123 | t.Fatalf("POST method is expected but %s", r.Method) 124 | } 125 | 126 | var got map[string]any 127 | if err := json.NewDecoder(r.Body).Decode(&got); err != nil { 128 | t.Fatal(err) 129 | } 130 | 131 | want := map[string]any{ 132 | "action": "updateWebhook", 133 | "config": map[string]any{ 134 | "url": "url1", 135 | "enable": true, 136 | }, 137 | } 138 | 139 | if diff := cmp.Diff(want, got); diff != "" { 140 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 141 | } 142 | }), 143 | ) 144 | defer srv.Close() 145 | 146 | c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) 147 | 148 | if err := c.Webhook().Update(context.Background(), "url1", true); err != nil { 149 | t.Fatal(err) 150 | } 151 | } 152 | 153 | func TestWebhookDelete(t *testing.T) { 154 | srv := httptest.NewServer( 155 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 156 | w.Write([]byte(`{"statusCode":100,"body":{},"message":""}`)) 157 | 158 | if r.Method != http.MethodPost { 159 | t.Fatalf("POST method is expected but %s", r.Method) 160 | } 161 | 162 | var got map[string]string 163 | if err := json.NewDecoder(r.Body).Decode(&got); err != nil { 164 | t.Fatal(err) 165 | } 166 | 167 | want := map[string]string{ 168 | "action": "deleteWebhook", 169 | "url": "url1", 170 | } 171 | 172 | if diff := cmp.Diff(want, got); diff != "" { 173 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 174 | } 175 | }), 176 | ) 177 | defer srv.Close() 178 | 179 | c := switchbot.New("", "", switchbot.WithEndpoint(srv.URL)) 180 | 181 | if err := c.Webhook().Delete(context.Background(), "url1"); err != nil { 182 | t.Fatal(err) 183 | } 184 | } 185 | 186 | func TestParseWebhook(t *testing.T) { 187 | sendWebhook := func(url, req string) { 188 | http.DefaultClient.Post(url, "application/json", bytes.NewBufferString(req)) 189 | } 190 | 191 | t.Run("bot", func(t *testing.T) { 192 | srv := httptest.NewServer( 193 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 194 | event, err := switchbot.ParseWebhookRequest(r) 195 | if err != nil { 196 | t.Fatal(err) 197 | } 198 | 199 | if got, ok := event.(*switchbot.BotEvent); ok { 200 | want := switchbot.BotEvent{ 201 | EventType: "changeReport", 202 | EventVersion: "1", 203 | Context: switchbot.BotEventContext{ 204 | DeviceType: "WoHand", 205 | DeviceMac: "00:00:5E:00:53:00", 206 | Power: "on", 207 | TimeOfSample: 123456789, 208 | }, 209 | } 210 | 211 | if diff := cmp.Diff(want, *got); diff != "" { 212 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 213 | } 214 | } else { 215 | t.Fatalf("given webhook event must be a bot event but %T", event) 216 | } 217 | }), 218 | ) 219 | defer srv.Close() 220 | 221 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoHand","deviceMac":"00:00:5E:00:53:00","power":"on","battery":10,"deviceMode":"pressMode","timeOfSample":123456789}}`) 222 | }) 223 | 224 | t.Run("curtain", func(t *testing.T) { 225 | srv := httptest.NewServer( 226 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 227 | event, err := switchbot.ParseWebhookRequest(r) 228 | if err != nil { 229 | t.Fatal(err) 230 | } 231 | 232 | if got, ok := event.(*switchbot.CurtainEvent); ok { 233 | want := switchbot.CurtainEvent{ 234 | EventType: "changeReport", 235 | EventVersion: "1", 236 | Context: switchbot.CurtainEventContext{ 237 | DeviceType: "WoCurtain", 238 | DeviceMac: "00:00:5E:00:53:00", 239 | Calibrate: false, 240 | Group: false, 241 | SlidePosition: 50, 242 | Battery: 100, 243 | TimeOfSample: 123456789, 244 | }, 245 | } 246 | 247 | if diff := cmp.Diff(want, *got); diff != "" { 248 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 249 | } 250 | } else { 251 | t.Fatalf("given webhook event must be a curtain event but %T", event) 252 | } 253 | }), 254 | ) 255 | defer srv.Close() 256 | 257 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoCurtain","deviceMac":"00:00:5E:00:53:00","calibrate":false,"group":false,"slidePosition":50,"battery":100,"timeOfSample":123456789}}`) 258 | }) 259 | 260 | t.Run("curtain3", func(t *testing.T) { 261 | srv := httptest.NewServer( 262 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 263 | event, err := switchbot.ParseWebhookRequest(r) 264 | if err != nil { 265 | t.Fatal(err) 266 | } 267 | 268 | if got, ok := event.(*switchbot.Curtain3Event); ok { 269 | want := switchbot.Curtain3Event{ 270 | EventType: "changeReport", 271 | EventVersion: "1", 272 | Context: switchbot.Curtain3EventContext{ 273 | DeviceType: "WoCurtain3", 274 | DeviceMac: "00:00:5E:00:53:00", 275 | IsCalibrated: false, 276 | IsGrouped: false, 277 | SlidePosition: 50, 278 | Battery: 100, 279 | TimeOfSample: 123456789, 280 | }, 281 | } 282 | 283 | if diff := cmp.Diff(want, *got); diff != "" { 284 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 285 | } 286 | } else { 287 | t.Fatalf("given webhook event must be a curtain3 event but %T", event) 288 | } 289 | }), 290 | ) 291 | defer srv.Close() 292 | 293 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoCurtain3","deviceMac":"00:00:5E:00:53:00","calibrate":false,"group":false,"slidePosition":50,"battery":100,"timeOfSample":123456789}}`) 294 | }) 295 | 296 | t.Run("motion sensor", func(t *testing.T) { 297 | srv := httptest.NewServer( 298 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 299 | event, err := switchbot.ParseWebhookRequest(r) 300 | if err != nil { 301 | t.Fatal(err) 302 | } 303 | 304 | if got, ok := event.(*switchbot.MotionSensorEvent); ok { 305 | want := switchbot.MotionSensorEvent{ 306 | EventType: "changeReport", 307 | EventVersion: "1", 308 | Context: switchbot.MotionSensorEventContext{ 309 | DeviceType: "WoPresence", 310 | DeviceMac: "01:00:5e:90:10:00", 311 | DetectionState: "NOT_DETECTED", 312 | TimeOfSample: 123456789, 313 | }, 314 | } 315 | 316 | if diff := cmp.Diff(want, *got); diff != "" { 317 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 318 | } 319 | } else { 320 | t.Fatalf("given webhook event must be a motion sensor event but %T", event) 321 | } 322 | }), 323 | ) 324 | defer srv.Close() 325 | 326 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context": {"deviceType":"WoPresence","deviceMac":"01:00:5e:90:10:00","detectionState":"NOT_DETECTED","timeOfSample":123456789}}`) 327 | }) 328 | 329 | t.Run("contact sensor", func(t *testing.T) { 330 | srv := httptest.NewServer( 331 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 332 | event, err := switchbot.ParseWebhookRequest(r) 333 | if err != nil { 334 | t.Fatal(err) 335 | } 336 | 337 | if got, ok := event.(*switchbot.ContactSensorEvent); ok { 338 | want := switchbot.ContactSensorEvent{ 339 | EventType: "changeReport", 340 | EventVersion: "1", 341 | Context: switchbot.ContactSensorEventContext{ 342 | DeviceType: "WoContact", 343 | DeviceMac: "01:00:5e:90:10:00", 344 | DetectionState: "NOT_DETECTED", 345 | DoorMode: "OUT_DOOR", 346 | Brightness: switchbot.AmbientBrightnessDim, 347 | OpenState: "open", 348 | TimeOfSample: 123456789, 349 | }, 350 | } 351 | 352 | if diff := cmp.Diff(want, *got); diff != "" { 353 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 354 | } 355 | } else { 356 | t.Fatalf("given webhook event must be a contact sensor event but %T", event) 357 | } 358 | }), 359 | ) 360 | defer srv.Close() 361 | 362 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoContact","deviceMac":"01:00:5e:90:10:00","detectionState":"NOT_DETECTED","doorMode":"OUT_DOOR","brightness":"dim","openState":"open","timeOfSample":123456789}}`) 363 | }) 364 | 365 | t.Run("water leak detector", func(t *testing.T) { 366 | srv := httptest.NewServer( 367 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 368 | event, err := switchbot.ParseWebhookRequest(r) 369 | if err != nil { 370 | t.Fatal(err) 371 | } 372 | 373 | if got, ok := event.(*switchbot.WaterLeakDetectorEvent); ok { 374 | want := switchbot.WaterLeakDetectorEvent{ 375 | EventType: "changeReport", 376 | EventVersion: "1", 377 | Context: switchbot.WaterLeakDetectorEventContext{ 378 | DeviceType: "Water Detector", 379 | DeviceMac: "00:00:5E:00:53:00", 380 | DetectionState: 0, 381 | Battery: 100, 382 | TimeOfSample: 123456789, 383 | }, 384 | } 385 | 386 | if diff := cmp.Diff(want, *got); diff != "" { 387 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 388 | } 389 | } else { 390 | t.Fatalf("given webhook event must be a water leak detector event but %T", event) 391 | } 392 | }), 393 | ) 394 | defer srv.Close() 395 | 396 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context": {"deviceType":"Water Detector","deviceMac":"00:00:5E:00:53:00","detectionState":0,"battery":100,"timeOfSample":123456789}}`) 397 | }) 398 | 399 | t.Run("meter", func(t *testing.T) { 400 | srv := httptest.NewServer( 401 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 402 | event, err := switchbot.ParseWebhookRequest(r) 403 | if err != nil { 404 | t.Fatal(err) 405 | } 406 | 407 | if got, ok := event.(*switchbot.MeterEvent); ok { 408 | want := switchbot.MeterEvent{ 409 | EventType: "changeReport", 410 | EventVersion: "1", 411 | Context: switchbot.MeterEventContext{ 412 | DeviceType: "WoMeter", 413 | DeviceMac: "01:00:5e:90:10:00", 414 | Temperature: 22.5, 415 | Scale: "CELSIUS", 416 | Humidity: 31, 417 | Battery: 100, 418 | TimeOfSample: 123456789, 419 | }, 420 | } 421 | 422 | if diff := cmp.Diff(want, *got); diff != "" { 423 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 424 | } 425 | } else { 426 | t.Fatalf("given webhook event must be a meter event but %T", event) 427 | } 428 | }), 429 | ) 430 | defer srv.Close() 431 | 432 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoMeter","deviceMac":"01:00:5e:90:10:00","temperature":22.5,"scale":"CELSIUS","humidity":31,"battery":100,"timeOfSample":123456789}}`) 433 | }) 434 | 435 | t.Run("meter plus", func(t *testing.T) { 436 | srv := httptest.NewServer( 437 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 438 | event, err := switchbot.ParseWebhookRequest(r) 439 | if err != nil { 440 | t.Fatal(err) 441 | } 442 | 443 | if got, ok := event.(*switchbot.MeterPlusEvent); ok { 444 | want := switchbot.MeterPlusEvent{ 445 | EventType: "changeReport", 446 | EventVersion: "1", 447 | Context: switchbot.MeterPlusEventContext{ 448 | DeviceType: "WoMeterPlus", 449 | DeviceMac: "01:00:5e:90:10:00", 450 | Temperature: 22.5, 451 | Scale: "CELSIUS", 452 | Humidity: 31, 453 | Battery: 100, 454 | TimeOfSample: 123456789, 455 | }, 456 | } 457 | 458 | if diff := cmp.Diff(want, *got); diff != "" { 459 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 460 | } 461 | } else { 462 | t.Fatalf("given webhook event must be a meter plus event but %T", event) 463 | } 464 | }), 465 | ) 466 | defer srv.Close() 467 | 468 | // in the request body example the deviceType is Meter but I think it should be WoMeterPlus 469 | // https://github.com/OpenWonderLabs/SwitchBotAPI/blob/main/README-v1.0.md#meter-plus 470 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoMeterPlus","deviceMac":"01:00:5e:90:10:00","temperature":22.5,"scale":"CELSIUS","humidity":31,"battery":100,"timeOfSample":123456789}}`) 471 | }) 472 | 473 | t.Run("outdoor meter", func(t *testing.T) { 474 | srv := httptest.NewServer( 475 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 476 | event, err := switchbot.ParseWebhookRequest(r) 477 | if err != nil { 478 | t.Fatal(err) 479 | } 480 | 481 | if got, ok := event.(*switchbot.OutdoorMeterEvent); ok { 482 | want := switchbot.OutdoorMeterEvent{ 483 | EventType: "changeReport", 484 | EventVersion: "1", 485 | Context: switchbot.OutdoorMeterEventContext{ 486 | DeviceType: "WoIOSensor", 487 | DeviceMac: "00:00:5E:00:53:00", 488 | Temperature: 22.5, 489 | Scale: "CELSIUS", 490 | Humidity: 31, 491 | Battery: 100, 492 | TimeOfSample: 123456789, 493 | }, 494 | } 495 | 496 | if diff := cmp.Diff(want, *got); diff != "" { 497 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 498 | } 499 | } else { 500 | t.Fatalf("given webhook event must be an outdoor meter event but %T", event) 501 | } 502 | }), 503 | ) 504 | defer srv.Close() 505 | 506 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoIOSensor","deviceMac":"00:00:5E:00:53:00","temperature":22.5,"scale":"CELSIUS","humidity":31,"battery":100,"timeOfSample":123456789}}`) 507 | }) 508 | 509 | t.Run("meter pro", func(t *testing.T) { 510 | srv := httptest.NewServer( 511 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 512 | event, err := switchbot.ParseWebhookRequest(r) 513 | if err != nil { 514 | t.Fatal(err) 515 | } 516 | 517 | if got, ok := event.(*switchbot.MeterProEvent); ok { 518 | want := switchbot.MeterProEvent{ 519 | EventType: "changeReport", 520 | EventVersion: "1", 521 | Context: switchbot.MeterProEventContext{ 522 | DeviceType: "MeterPro", 523 | DeviceMac: "00:00:5E:00:53:00", 524 | Temperature: 22.5, 525 | Scale: "CELSIUS", 526 | Humidity: 31, 527 | Battery: 100, 528 | TimeOfSample: 123456789, 529 | }, 530 | } 531 | 532 | if diff := cmp.Diff(want, *got); diff != "" { 533 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 534 | } 535 | } else { 536 | t.Fatalf("given webhook event must be an meter pro event but %T", event) 537 | } 538 | }), 539 | ) 540 | defer srv.Close() 541 | 542 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"MeterPro","deviceMac":"00:00:5E:00:53:00","temperature":22.5,"scale":"CELSIUS","humidity":31,"battery":100,"timeOfSample":123456789}}`) 543 | }) 544 | 545 | t.Run("meter pro CO2", func(t *testing.T) { 546 | srv := httptest.NewServer( 547 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 548 | event, err := switchbot.ParseWebhookRequest(r) 549 | if err != nil { 550 | t.Fatal(err) 551 | } 552 | 553 | if got, ok := event.(*switchbot.MeterProCO2Event); ok { 554 | want := switchbot.MeterProCO2Event{ 555 | EventType: "changeReport", 556 | EventVersion: "1", 557 | Context: switchbot.MeterProCO2EventContext{ 558 | DeviceType: "MeterPro(CO2)", 559 | DeviceMac: "00:00:5E:00:53:00", 560 | Temperature: 22.5, 561 | Scale: "CELSIUS", 562 | Humidity: 31, 563 | CO2: 1203, 564 | Battery: 100, 565 | TimeOfSample: 123456789, 566 | }, 567 | } 568 | 569 | if diff := cmp.Diff(want, *got); diff != "" { 570 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 571 | } 572 | } else { 573 | t.Fatalf("given webhook event must be an outdoor meter event but %T", event) 574 | } 575 | }), 576 | ) 577 | defer srv.Close() 578 | 579 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"MeterPro(CO2)","deviceMac":"00:00:5E:00:53:00","temperature":22.5,"scale":"CELSIUS","humidity":31,"CO2":1203,"battery":100,"timeOfSample":123456789}}`) 580 | }) 581 | 582 | t.Run("lock", func(t *testing.T) { 583 | srv := httptest.NewServer( 584 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 585 | event, err := switchbot.ParseWebhookRequest(r) 586 | if err != nil { 587 | t.Fatal(err) 588 | } 589 | 590 | if got, ok := event.(*switchbot.LockEvent); ok { 591 | want := switchbot.LockEvent{ 592 | EventType: "changeReport", 593 | EventVersion: "1", 594 | Context: switchbot.LockEventContext{ 595 | DeviceType: "WoLock", 596 | DeviceMac: "01:00:5e:90:10:00", 597 | LockState: "LOCKED", 598 | TimeOfSample: 123456789, 599 | }, 600 | } 601 | 602 | if diff := cmp.Diff(want, *got); diff != "" { 603 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 604 | } 605 | } else { 606 | t.Fatalf("given webhook event must be a lock event but %T", event) 607 | } 608 | }), 609 | ) 610 | defer srv.Close() 611 | 612 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoLock","deviceMac":"01:00:5e:90:10:00","lockState":"LOCKED","timeOfSample":123456789}}`) 613 | }) 614 | 615 | t.Run("lock pro", func(t *testing.T) { 616 | srv := httptest.NewServer( 617 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 618 | event, err := switchbot.ParseWebhookRequest(r) 619 | if err != nil { 620 | t.Fatal(err) 621 | } 622 | 623 | if got, ok := event.(*switchbot.LockProEvent); ok { 624 | want := switchbot.LockProEvent{ 625 | EventType: "changeReport", 626 | EventVersion: "1", 627 | Context: switchbot.LockProEventContext{ 628 | DeviceType: "WoLockPro", 629 | DeviceMac: "00:00:5E:00:53:00", 630 | LockState: "LOCKED", 631 | TimeOfSample: 123456789, 632 | }, 633 | } 634 | 635 | if diff := cmp.Diff(want, *got); diff != "" { 636 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 637 | } 638 | } else { 639 | t.Fatalf("given webhook event must be a lock event but %T", event) 640 | } 641 | }), 642 | ) 643 | defer srv.Close() 644 | 645 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoLockPro","deviceMac":"00:00:5E:00:53:00","lockState":"LOCKED","battery":100,"timeOfSample":123456789}}`) 646 | }) 647 | 648 | t.Run("indoor cam", func(t *testing.T) { 649 | srv := httptest.NewServer( 650 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 651 | event, err := switchbot.ParseWebhookRequest(r) 652 | if err != nil { 653 | t.Fatal(err) 654 | } 655 | 656 | if got, ok := event.(*switchbot.IndoorCamEvent); ok { 657 | want := switchbot.IndoorCamEvent{ 658 | EventType: "changeReport", 659 | EventVersion: "1", 660 | Context: switchbot.IndoorCamEventContext{ 661 | DeviceType: "WoCamera", 662 | DeviceMac: "01:00:5e:90:10:00", 663 | DetectionState: "DETECTED", 664 | TimeOfSample: 123456789, 665 | }, 666 | } 667 | 668 | if diff := cmp.Diff(want, *got); diff != "" { 669 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 670 | } 671 | } else { 672 | t.Fatalf("given webhook event must be a camera event but %T", event) 673 | } 674 | }), 675 | ) 676 | defer srv.Close() 677 | 678 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoCamera","deviceMac":"01:00:5e:90:10:00","detectionState":"DETECTED","timeOfSample":123456789}}`) 679 | }) 680 | 681 | t.Run("pan/tilt cam", func(t *testing.T) { 682 | srv := httptest.NewServer( 683 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 684 | event, err := switchbot.ParseWebhookRequest(r) 685 | if err != nil { 686 | t.Fatal(err) 687 | } 688 | 689 | if got, ok := event.(*switchbot.PanTiltCamEvent); ok { 690 | want := switchbot.PanTiltCamEvent{ 691 | EventType: "changeReport", 692 | EventVersion: "1", 693 | Context: switchbot.PanTiltCamEventContext{ 694 | DeviceType: "WoPanTiltCam", 695 | DeviceMac: "01:00:5e:90:10:00", 696 | DetectionState: "DETECTED", 697 | TimeOfSample: 123456789, 698 | }, 699 | } 700 | 701 | if diff := cmp.Diff(want, *got); diff != "" { 702 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 703 | } 704 | } else { 705 | t.Fatalf("given webhook event must be a pan/tilt camera event but %T", event) 706 | } 707 | }), 708 | ) 709 | defer srv.Close() 710 | 711 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoPanTiltCam","deviceMac":"01:00:5e:90:10:00","detectionState":"DETECTED","timeOfSample":123456789}}`) 712 | }) 713 | 714 | t.Run("color bulb", func(t *testing.T) { 715 | srv := httptest.NewServer( 716 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 717 | event, err := switchbot.ParseWebhookRequest(r) 718 | if err != nil { 719 | t.Fatal(err) 720 | } 721 | 722 | if got, ok := event.(*switchbot.ColorBulbEvent); ok { 723 | want := switchbot.ColorBulbEvent{ 724 | EventType: "changeReport", 725 | EventVersion: "1", 726 | Context: switchbot.ColorBulbEventContext{ 727 | DeviceType: "WoBulb", 728 | DeviceMac: "01:00:5e:90:10:00", 729 | PowerState: switchbot.PowerOn, 730 | Brightness: 10, 731 | Color: "255:245:235", 732 | ColorTemperature: 3500, 733 | TimeOfSample: 123456789, 734 | }, 735 | } 736 | 737 | if diff := cmp.Diff(want, *got); diff != "" { 738 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 739 | } 740 | } else { 741 | t.Fatalf("given webhook event must be a color bulb event but %T", event) 742 | } 743 | }), 744 | ) 745 | defer srv.Close() 746 | 747 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoBulb","deviceMac":"01:00:5e:90:10:00","powerState":"ON","brightness":10,"color":"255:245:235","colorTemperature":3500,"timeOfSample":123456789}}`) 748 | }) 749 | 750 | t.Run("led strip light", func(t *testing.T) { 751 | srv := httptest.NewServer( 752 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 753 | event, err := switchbot.ParseWebhookRequest(r) 754 | if err != nil { 755 | t.Fatal(err) 756 | } 757 | 758 | if got, ok := event.(*switchbot.StripLightEvent); ok { 759 | want := switchbot.StripLightEvent{ 760 | EventType: "changeReport", 761 | EventVersion: "1", 762 | Context: switchbot.StripLightEventContext{ 763 | DeviceType: "WoStrip", 764 | DeviceMac: "01:00:5e:90:10:00", 765 | PowerState: switchbot.PowerOn, 766 | Brightness: 10, 767 | Color: "255:245:235", 768 | TimeOfSample: 123456789, 769 | }, 770 | } 771 | 772 | if diff := cmp.Diff(want, *got); diff != "" { 773 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 774 | } 775 | } else { 776 | t.Fatalf("given webhook event must be a LED strip light event but %T", event) 777 | } 778 | }), 779 | ) 780 | defer srv.Close() 781 | 782 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoStrip","deviceMac":"01:00:5e:90:10:00","powerState":"ON","brightness":10,"color":"255:245:235","timeOfSample":123456789}}`) 783 | }) 784 | 785 | t.Run("plug mini (US)", func(t *testing.T) { 786 | srv := httptest.NewServer( 787 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 788 | event, err := switchbot.ParseWebhookRequest(r) 789 | if err != nil { 790 | t.Fatal(err) 791 | } 792 | 793 | if got, ok := event.(*switchbot.PlugMiniUSEvent); ok { 794 | want := switchbot.PlugMiniUSEvent{ 795 | EventType: "changeReport", 796 | EventVersion: "1", 797 | Context: switchbot.PlugMiniUSEventContext{ 798 | DeviceType: "WoPlugUS", 799 | DeviceMac: "01:00:5e:90:10:00", 800 | PowerState: switchbot.PowerOn, 801 | TimeOfSample: 123456789, 802 | }, 803 | } 804 | 805 | if diff := cmp.Diff(want, *got); diff != "" { 806 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 807 | } 808 | } else { 809 | t.Fatalf("given webhook event must be a plug mini (US) event but %T", event) 810 | } 811 | }), 812 | ) 813 | defer srv.Close() 814 | 815 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoPlugUS","deviceMac":"01:00:5e:90:10:00","powerState":"ON","timeOfSample":123456789}}`) 816 | }) 817 | 818 | t.Run("plug mini (JP)", func(t *testing.T) { 819 | srv := httptest.NewServer( 820 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 821 | event, err := switchbot.ParseWebhookRequest(r) 822 | if err != nil { 823 | t.Fatal(err) 824 | } 825 | 826 | if got, ok := event.(*switchbot.PlugMiniJPEvent); ok { 827 | want := switchbot.PlugMiniJPEvent{ 828 | EventType: "changeReport", 829 | EventVersion: "1", 830 | Context: switchbot.PlugMiniJPEventContext{ 831 | DeviceType: "WoPlugJP", 832 | DeviceMac: "01:00:5e:90:10:00", 833 | PowerState: switchbot.PowerOn, 834 | TimeOfSample: 123456789, 835 | }, 836 | } 837 | 838 | if diff := cmp.Diff(want, *got); diff != "" { 839 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 840 | } 841 | } else { 842 | t.Fatalf("given webhook event must be a plug mini (JP) event but %T", event) 843 | } 844 | }), 845 | ) 846 | defer srv.Close() 847 | 848 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoPlugJP","deviceMac":"01:00:5e:90:10:00","powerState":"ON","timeOfSample":123456789}}`) 849 | }) 850 | 851 | t.Run("plug mini (EU)", func(t *testing.T) { 852 | srv := httptest.NewServer( 853 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 854 | event, err := switchbot.ParseWebhookRequest(r) 855 | if err != nil { 856 | t.Fatal(err) 857 | } 858 | 859 | if got, ok := event.(*switchbot.PlugMiniEUEvent); ok { 860 | want := switchbot.PlugMiniEUEvent{ 861 | EventType: "changeReport", 862 | EventVersion: "1", 863 | Context: switchbot.PlugMiniEUEventContext{ 864 | DeviceType: "Plug Mini (EU)", 865 | DeviceMac: "94A990502B72", // is this correct? 866 | Online: false, 867 | OverTemperature: true, 868 | Overload: true, 869 | SwitchStatus: 1, 870 | }, 871 | } 872 | 873 | if diff := cmp.Diff(want, *got); diff != "" { 874 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 875 | } 876 | } else { 877 | t.Fatalf("given webhook event must be a plug mini (EU) event but %T", event) 878 | } 879 | }), 880 | ) 881 | defer srv.Close() 882 | 883 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"Plug Mini (EU)","deviceMac":"94A990502B72","online":false,"overTemperature":true,"overload":true,"switchStatus":1}}`) 884 | }) 885 | 886 | t.Run("Robot Vacuum Cleaner S1", func(t *testing.T) { 887 | srv := httptest.NewServer( 888 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 889 | event, err := switchbot.ParseWebhookRequest(r) 890 | if err != nil { 891 | t.Fatal(err) 892 | } 893 | 894 | if got, ok := event.(*switchbot.SweeperEvent); ok { 895 | want := switchbot.SweeperEvent{ 896 | EventType: "changeReport", 897 | EventVersion: "1", 898 | Context: switchbot.SweeperEventContext{ 899 | DeviceType: "WoSweeper", 900 | DeviceMac: "01:00:5e:90:10:00", 901 | WorkingStatus: switchbot.CleanerStandBy, 902 | OnlineStatus: switchbot.CleanerOnline, 903 | Battery: 100, 904 | TimeOfSample: 123456789, 905 | }, 906 | } 907 | 908 | if diff := cmp.Diff(want, *got); diff != "" { 909 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 910 | } 911 | } else { 912 | t.Fatalf("given webhook event must be a sweeper event but %T", event) 913 | } 914 | }), 915 | ) 916 | defer srv.Close() 917 | 918 | 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}}`) 919 | }) 920 | 921 | t.Run("Robot Vacuum Cleaner S1 Plus", func(t *testing.T) { 922 | srv := httptest.NewServer( 923 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 924 | event, err := switchbot.ParseWebhookRequest(r) 925 | if err != nil { 926 | t.Fatal(err) 927 | } 928 | 929 | if got, ok := event.(*switchbot.SweeperEvent); ok { 930 | want := switchbot.SweeperEvent{ 931 | EventType: "changeReport", 932 | EventVersion: "1", 933 | Context: switchbot.SweeperEventContext{ 934 | DeviceType: "WoSweeperPlus", 935 | DeviceMac: "01:00:5e:90:10:00", 936 | WorkingStatus: switchbot.CleanerStandBy, 937 | OnlineStatus: switchbot.CleanerOnline, 938 | Battery: 100, 939 | TimeOfSample: 123456789, 940 | }, 941 | } 942 | 943 | if diff := cmp.Diff(want, *got); diff != "" { 944 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 945 | } 946 | } else { 947 | t.Fatalf("given webhook event must be a sweeper plus event but %T", event) 948 | } 949 | }), 950 | ) 951 | defer srv.Close() 952 | 953 | 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}}`) 954 | }) 955 | 956 | t.Run("Mini Robot Vacuum Cleaner K10+", func(t *testing.T) { 957 | srv := httptest.NewServer( 958 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 959 | event, err := switchbot.ParseWebhookRequest(r) 960 | if err != nil { 961 | t.Fatal(err) 962 | } 963 | 964 | if got, ok := event.(*switchbot.SweeperEvent); ok { 965 | want := switchbot.SweeperEvent{ 966 | EventType: "changeReport", 967 | EventVersion: "1", 968 | Context: switchbot.SweeperEventContext{ 969 | DeviceType: "WoSweeperMini", 970 | DeviceMac: "01:00:5e:90:10:00", 971 | WorkingStatus: switchbot.CleanerStandBy, 972 | OnlineStatus: switchbot.CleanerOnline, 973 | Battery: 100, 974 | TimeOfSample: 123456789, 975 | }, 976 | } 977 | 978 | if diff := cmp.Diff(want, *got); diff != "" { 979 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 980 | } 981 | } else { 982 | t.Fatalf("given webhook event must be a sweeper mini event but %T", event) 983 | } 984 | }), 985 | ) 986 | defer srv.Close() 987 | 988 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoSweeperMini","deviceMac":"01:00:5e:90:10:00","workingStatus":"StandBy","onlineStatus":"online","battery":100,"timeOfSample":123456789}}`) 989 | }) 990 | 991 | t.Run("Mini Robot Vacuum Cleaner K10+ Pro", func(t *testing.T) { 992 | srv := httptest.NewServer( 993 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 994 | event, err := switchbot.ParseWebhookRequest(r) 995 | if err != nil { 996 | t.Fatal(err) 997 | } 998 | 999 | if got, ok := event.(*switchbot.SweeperEvent); ok { 1000 | want := switchbot.SweeperEvent{ 1001 | EventType: "changeReport", 1002 | EventVersion: "1", 1003 | Context: switchbot.SweeperEventContext{ 1004 | DeviceType: "WoSweeperMiniPro", 1005 | DeviceMac: "01:00:5e:90:10:00", 1006 | WorkingStatus: switchbot.CleanerStandBy, 1007 | OnlineStatus: switchbot.CleanerOnline, 1008 | Battery: 100, 1009 | TimeOfSample: 123456789, 1010 | }, 1011 | } 1012 | 1013 | if diff := cmp.Diff(want, *got); diff != "" { 1014 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 1015 | } 1016 | } else { 1017 | t.Fatalf("given webhook event must be a sweeper mini pro event but %T", event) 1018 | } 1019 | }), 1020 | ) 1021 | defer srv.Close() 1022 | 1023 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoSweeperMiniPro","deviceMac":"01:00:5e:90:10:00","workingStatus":"StandBy","onlineStatus":"online","battery":100,"timeOfSample":123456789}}`) 1024 | }) 1025 | 1026 | t.Run("Robot Vacuum Cleaner S10", func(t *testing.T) { 1027 | srv := httptest.NewServer( 1028 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1029 | event, err := switchbot.ParseWebhookRequest(r) 1030 | if err != nil { 1031 | t.Fatal(err) 1032 | } 1033 | 1034 | if got, ok := event.(*switchbot.FloorCleaningRobotS10Event); ok { 1035 | want := switchbot.FloorCleaningRobotS10Event{ 1036 | EventType: "changeReport", 1037 | EventVersion: "1", 1038 | Context: switchbot.FloorCleaningRobotS10EventContext{ 1039 | DeviceType: "Robot Vacuum Cleaner S10", 1040 | DeviceMac: "00:00:5E:00:53:00", 1041 | WorkingStatus: switchbot.CleanerStandBy, 1042 | OnlineStatus: switchbot.CleanerOnline, 1043 | Battery: 100, 1044 | WaterBaseBattery: 100, 1045 | TaskType: switchbot.CleanerTaskExplore, 1046 | TimeOfSample: 123456789, 1047 | }, 1048 | } 1049 | 1050 | if diff := cmp.Diff(want, *got); diff != "" { 1051 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 1052 | } 1053 | } else { 1054 | t.Fatalf("given webhook event must be a sweeper plus event but %T", event) 1055 | } 1056 | }), 1057 | ) 1058 | defer srv.Close() 1059 | 1060 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"Robot Vacuum Cleaner S10","deviceMac":"00:00:5E:00:53:00","workingStatus":"StandBy","onlineStatus":"online","battery":100,"waterBaseBattery":100,"taskType":"explore","timeOfSample":123456789}}`) 1061 | }) 1062 | 1063 | t.Run("Ceiling Light", func(t *testing.T) { 1064 | srv := httptest.NewServer( 1065 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1066 | event, err := switchbot.ParseWebhookRequest(r) 1067 | if err != nil { 1068 | t.Fatal(err) 1069 | } 1070 | 1071 | if got, ok := event.(*switchbot.CeilingEvent); ok { 1072 | want := switchbot.CeilingEvent{ 1073 | EventType: "changeReport", 1074 | EventVersion: "1", 1075 | Context: switchbot.CeilingEventContext{ 1076 | DeviceType: "WoCeiling", 1077 | DeviceMac: "01:00:5e:90:10:00", 1078 | PowerState: switchbot.PowerOn, 1079 | Brightness: 10, 1080 | ColorTemperature: 3500, 1081 | TimeOfSample: 123456789, 1082 | }, 1083 | } 1084 | 1085 | if diff := cmp.Diff(want, *got); diff != "" { 1086 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 1087 | } 1088 | } else { 1089 | t.Fatalf("given webhook event must be a ceiling event but %T", event) 1090 | } 1091 | }), 1092 | ) 1093 | defer srv.Close() 1094 | 1095 | 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}}`) 1096 | }) 1097 | 1098 | t.Run("Ceiling Light Pro", func(t *testing.T) { 1099 | srv := httptest.NewServer( 1100 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1101 | event, err := switchbot.ParseWebhookRequest(r) 1102 | if err != nil { 1103 | t.Fatal(err) 1104 | } 1105 | 1106 | if got, ok := event.(*switchbot.CeilingEvent); ok { 1107 | want := switchbot.CeilingEvent{ 1108 | EventType: "changeReport", 1109 | EventVersion: "1", 1110 | Context: switchbot.CeilingEventContext{ 1111 | DeviceType: "WoCeilingPro", 1112 | DeviceMac: "01:00:5e:90:10:00", 1113 | PowerState: switchbot.PowerOn, 1114 | Brightness: 10, 1115 | ColorTemperature: 3500, 1116 | TimeOfSample: 123456789, 1117 | }, 1118 | } 1119 | 1120 | if diff := cmp.Diff(want, *got); diff != "" { 1121 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 1122 | } 1123 | } else { 1124 | t.Fatalf("given webhook event must be a ceiling event but %T", event) 1125 | } 1126 | }), 1127 | ) 1128 | defer srv.Close() 1129 | 1130 | 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}}`) 1131 | }) 1132 | 1133 | t.Run("Keypad", func(t *testing.T) { 1134 | t.Run("create a passcode", func(t *testing.T) { 1135 | srv := httptest.NewServer( 1136 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1137 | event, err := switchbot.ParseWebhookRequest(r) 1138 | if err != nil { 1139 | t.Fatal(err) 1140 | } 1141 | 1142 | if got, ok := event.(*switchbot.KeypadEvent); ok { 1143 | want := switchbot.KeypadEvent{ 1144 | EventType: "changeReport", 1145 | EventVersion: "1", 1146 | Context: switchbot.KeypadEventContext{ 1147 | DeviceType: "WoKeypad", 1148 | DeviceMac: "01:00:5e:90:10:00", 1149 | EventName: "createKey", 1150 | CommandID: "CMD-1663558451952-01", 1151 | Result: "success", 1152 | TimeOfSample: 123456789, 1153 | }, 1154 | } 1155 | 1156 | if diff := cmp.Diff(want, *got); diff != "" { 1157 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 1158 | } 1159 | } else { 1160 | t.Fatalf("given webhook event must be a keypad event but %T", event) 1161 | } 1162 | }), 1163 | ) 1164 | defer srv.Close() 1165 | 1166 | 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}}`) 1167 | }) 1168 | t.Run("delete a passcode", func(t *testing.T) { 1169 | srv := httptest.NewServer( 1170 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1171 | event, err := switchbot.ParseWebhookRequest(r) 1172 | if err != nil { 1173 | t.Fatal(err) 1174 | } 1175 | 1176 | if got, ok := event.(*switchbot.KeypadEvent); ok { 1177 | want := switchbot.KeypadEvent{ 1178 | EventType: "changeReport", 1179 | EventVersion: "1", 1180 | Context: switchbot.KeypadEventContext{ 1181 | DeviceType: "WoKeypad", 1182 | DeviceMac: "01:00:5e:90:10:00", 1183 | EventName: "deleteKey", 1184 | CommandID: "CMD-1663558451952-01", 1185 | Result: "success", 1186 | TimeOfSample: 123456789, 1187 | }, 1188 | } 1189 | 1190 | if diff := cmp.Diff(want, *got); diff != "" { 1191 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 1192 | } 1193 | } else { 1194 | t.Fatalf("given webhook event must be a keypad event but %T", event) 1195 | } 1196 | }), 1197 | ) 1198 | defer srv.Close() 1199 | 1200 | 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}}`) 1201 | }) 1202 | }) 1203 | 1204 | t.Run("Keypad Touch", func(t *testing.T) { 1205 | t.Run("create a passcode", func(t *testing.T) { 1206 | srv := httptest.NewServer( 1207 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1208 | event, err := switchbot.ParseWebhookRequest(r) 1209 | if err != nil { 1210 | t.Fatal(err) 1211 | } 1212 | 1213 | if got, ok := event.(*switchbot.KeypadEvent); ok { 1214 | want := switchbot.KeypadEvent{ 1215 | EventType: "changeReport", 1216 | EventVersion: "1", 1217 | Context: switchbot.KeypadEventContext{ 1218 | DeviceType: "WoKeypadTouch", 1219 | DeviceMac: "01:00:5e:90:10:00", 1220 | EventName: "createKey", 1221 | CommandID: "CMD-1663558451952-01", 1222 | Result: "success", 1223 | TimeOfSample: 123456789, 1224 | }, 1225 | } 1226 | 1227 | if diff := cmp.Diff(want, *got); diff != "" { 1228 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 1229 | } 1230 | } else { 1231 | t.Fatalf("given webhook event must be a keypad touch event but %T", event) 1232 | } 1233 | }), 1234 | ) 1235 | defer srv.Close() 1236 | 1237 | 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}}`) 1238 | }) 1239 | t.Run("delete a passcode", func(t *testing.T) { 1240 | srv := httptest.NewServer( 1241 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1242 | event, err := switchbot.ParseWebhookRequest(r) 1243 | if err != nil { 1244 | t.Fatal(err) 1245 | } 1246 | 1247 | if got, ok := event.(*switchbot.KeypadEvent); ok { 1248 | want := switchbot.KeypadEvent{ 1249 | EventType: "changeReport", 1250 | EventVersion: "1", 1251 | Context: switchbot.KeypadEventContext{ 1252 | DeviceType: "WoKeypadTouch", 1253 | DeviceMac: "01:00:5e:90:10:00", 1254 | EventName: "deleteKey", 1255 | CommandID: "CMD-1663558451952-01", 1256 | Result: "success", 1257 | TimeOfSample: 123456789, 1258 | }, 1259 | } 1260 | 1261 | if diff := cmp.Diff(want, *got); diff != "" { 1262 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 1263 | } 1264 | } else { 1265 | t.Fatalf("given webhook event must be a keypad touch event but %T", event) 1266 | } 1267 | }), 1268 | ) 1269 | defer srv.Close() 1270 | 1271 | 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}}`) 1272 | }) 1273 | }) 1274 | 1275 | t.Run("hub2", func(t *testing.T) { 1276 | srv := httptest.NewServer( 1277 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1278 | event, err := switchbot.ParseWebhookRequest(r) 1279 | if err != nil { 1280 | t.Fatal(err) 1281 | } 1282 | 1283 | if got, ok := event.(*switchbot.Hub2Event); ok { 1284 | want := switchbot.Hub2Event{ 1285 | EventType: "changeReport", 1286 | EventVersion: "1", 1287 | Context: switchbot.Hub2EventContext{ 1288 | DeviceType: "WoHub2", 1289 | DeviceMac: "00:00:5E:00:53:00", 1290 | Temperature: 13, 1291 | Humidity: 18, 1292 | LightLevel: 19, 1293 | Scale: "CELSIUS", 1294 | TimeOfSample: 123456789, 1295 | }, 1296 | } 1297 | 1298 | if diff := cmp.Diff(want, *got); diff != "" { 1299 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 1300 | } 1301 | } else { 1302 | t.Fatalf("given webhook event must be a hub2 event but %T", event) 1303 | } 1304 | }), 1305 | ) 1306 | defer srv.Close() 1307 | 1308 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoHub2","deviceMac":"00:00:5E:00:53:00","temperature":13,"humidity":18,"lightLevel":19,"scale":"CELSIUS","timeOfSample":123456789}}`) 1309 | }) 1310 | 1311 | t.Run("hub3", func(t *testing.T) { 1312 | srv := httptest.NewServer( 1313 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1314 | event, err := switchbot.ParseWebhookRequest(r) 1315 | if err != nil { 1316 | t.Fatal(err) 1317 | } 1318 | 1319 | if got, ok := event.(*switchbot.Hub3Event); ok { 1320 | want := switchbot.Hub3Event{ 1321 | EventType: "changeReport", 1322 | EventVersion: "1", 1323 | Context: switchbot.Hub3EventContext{ 1324 | DeviceType: "Hub 3", 1325 | DeviceMac: "B0E9FE582974", 1326 | DetectionState: "DETECTED", 1327 | Temperature: 30.3, 1328 | Humidity: 45, 1329 | LightLevel: 10, 1330 | Scale: "CELSIUS", 1331 | TimeOfSample: 1742807095763, 1332 | }, 1333 | } 1334 | 1335 | if diff := cmp.Diff(want, *got); diff != "" { 1336 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 1337 | } 1338 | } else { 1339 | t.Fatalf("given webhook event must be a hub3 event but %T", event) 1340 | } 1341 | }), 1342 | ) 1343 | defer srv.Close() 1344 | 1345 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"detectionState":"DETECTED","deviceMac":"B0E9FE582974","deviceType":"Hub 3","humidity":45,"lightLevel":10,"scale":"CELSIUS","temperature":30.3,"timeOfSample":1742807095763}}`) 1346 | }) 1347 | 1348 | t.Run("battery circulator fan", func(t *testing.T) { 1349 | srv := httptest.NewServer( 1350 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1351 | event, err := switchbot.ParseWebhookRequest(r) 1352 | if err != nil { 1353 | t.Fatal(err) 1354 | } 1355 | 1356 | if got, ok := event.(*switchbot.BatteryCirculatorFanEvent); ok { 1357 | want := switchbot.BatteryCirculatorFanEvent{ 1358 | EventType: "changeReport", 1359 | EventVersion: "1", 1360 | Context: switchbot.BatteryCirculatorFanEventContext{ 1361 | DeviceType: "WoFan2", 1362 | DeviceMac: "00:00:5E:00:53:00", 1363 | Mode: switchbot.CirculatorModeDirect, 1364 | Version: "V3.1", 1365 | Battery: 22, 1366 | PowerState: switchbot.PowerOn, 1367 | NightStatus: switchbot.NightStatusOff, 1368 | Oscillation: switchbot.OscillationStatusOn, 1369 | VerticalOscillation: switchbot.OscillationStatusOn, 1370 | ChargingStatus: switchbot.ChargingStatusCharging, 1371 | FanSpeed: 3, 1372 | TimeOfSample: 123456789, 1373 | }, 1374 | } 1375 | 1376 | if diff := cmp.Diff(want, *got); diff != "" { 1377 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 1378 | } 1379 | } else { 1380 | t.Fatalf("given webhook event must be a battery circulator fan event but %T", event) 1381 | } 1382 | }), 1383 | ) 1384 | defer srv.Close() 1385 | 1386 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"WoFan2","deviceMac":"00:00:5E:00:53:00","mode":"direct","version":"V3.1","battery":22,"powerState":"ON","nightStatus":"off","oscillation":"on","verticalOscillation":"on","chargingStatus":"charging","fanSpeed":3,"timeOfSample":123456789}}`) 1387 | }) 1388 | 1389 | t.Run("evaporative humidifier", func(t *testing.T) { 1390 | srv := httptest.NewServer( 1391 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1392 | event, err := switchbot.ParseWebhookRequest(r) 1393 | if err != nil { 1394 | t.Fatal(err) 1395 | } 1396 | 1397 | if got, ok := event.(*switchbot.EvaporativeHumidifierEvent); ok { 1398 | want := switchbot.EvaporativeHumidifierEvent{ 1399 | EventType: "changeReport", 1400 | EventVersion: "1", 1401 | Context: switchbot.EvaporativeHumidifierEventContext{ 1402 | DeviceType: "Humidifier2", 1403 | DeviceMac: "00:00:5E:00:53:00", 1404 | Power: "on", 1405 | Mode: 1, 1406 | IsDrying: false, 1407 | TimeOfSample: 123456789, 1408 | }, 1409 | } 1410 | 1411 | if diff := cmp.Diff(want, *got); diff != "" { 1412 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 1413 | } 1414 | } else { 1415 | t.Fatalf("given webhook event must be a battery circulator fan event but %T", event) 1416 | } 1417 | }), 1418 | ) 1419 | defer srv.Close() 1420 | 1421 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"Humidifier2","deviceMac":"00:00:5E:00:53:00","power":"on","mode":1,"drying":false,"timeOfSample":123456789}}`) 1422 | }) 1423 | 1424 | t.Run("evaporative humidifier (auto-refill)", func(t *testing.T) { 1425 | srv := httptest.NewServer( 1426 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1427 | event, err := switchbot.ParseWebhookRequest(r) 1428 | if err != nil { 1429 | t.Fatal(err) 1430 | } 1431 | 1432 | if got, ok := event.(*switchbot.EvaporativeHumidifierEvent); ok { 1433 | want := switchbot.EvaporativeHumidifierEvent{ 1434 | EventType: "changeReport", 1435 | EventVersion: "1", 1436 | Context: switchbot.EvaporativeHumidifierEventContext{ 1437 | DeviceType: "Humidifier2", 1438 | DeviceMac: "00:00:5E:00:53:00", 1439 | Power: "on", 1440 | Mode: 1, 1441 | IsDrying: false, 1442 | TimeOfSample: 123456789, 1443 | }, 1444 | } 1445 | 1446 | if diff := cmp.Diff(want, *got); diff != "" { 1447 | t.Fatalf("event mismatch (-want +got):\n%s", diff) 1448 | } 1449 | } else { 1450 | t.Fatalf("given webhook event must be a battery circulator fan event but %T", event) 1451 | } 1452 | }), 1453 | ) 1454 | defer srv.Close() 1455 | 1456 | sendWebhook(srv.URL, `{"eventType":"changeReport","eventVersion":"1","context":{"deviceType":"Humidifier2","deviceMac":"00:00:5E:00:53:00","power":"on","mode":1,"drying":false,"timeOfSample":123456789}}`) 1457 | }) 1458 | } 1459 | --------------------------------------------------------------------------------