451 lines
14 KiB
Go
451 lines
14 KiB
Go
/*
|
|
* Copyright (c) 2018 Jeffrey Walter <jeffreydwalter@gmail.com>
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
|
* documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
|
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
|
|
* permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
|
* Software.
|
|
*
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
|
* WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
*/
|
|
|
|
package arlo
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
const eventStreamTimeout = 30 * time.Second
|
|
const pingTime = 30 * time.Second
|
|
|
|
// A Basestation is a Device that's not type "camera" (basestation, arloq, arloqs, etc.).
|
|
// This type is here just for semantics. Some methods explicitly require a device of a certain type.
|
|
type Basestation struct {
|
|
Device
|
|
eventStream *eventStream
|
|
}
|
|
|
|
type BaseStationState struct {
|
|
InterfaceVersion int `json:"interfaceVersion"`
|
|
APIVersion int `json:"apiVersion"`
|
|
State string `json:"state"`
|
|
SwVersion string `json:"swVersion"`
|
|
HwVersion string `json:"hwVersion"`
|
|
ModelID string `json:"modelId"`
|
|
Capabilities []string `json:"capabilities"`
|
|
McsEnabled bool `json:"mcsEnabled"`
|
|
AutoUpdateEnabled bool `json:"autoUpdateEnabled"`
|
|
UpdateAvailable interface{} `json:"updateAvailable"`
|
|
TimeZone string `json:"timeZone"`
|
|
OlsonTimeZone string `json:"olsonTimeZone"`
|
|
UploadBandwidthSaturated bool `json:"uploadBandwidthSaturated"`
|
|
AntiFlicker struct {
|
|
Mode int `json:"mode"`
|
|
AutoDefault int `json:"autoDefault"`
|
|
} `json:"antiFlicker"`
|
|
LowBatteryAlert struct {
|
|
Enabled bool `json:"enabled"`
|
|
} `json:"lowBatteryAlert"`
|
|
LowSignalAlert struct {
|
|
Enabled bool `json:"enabled"`
|
|
} `json:"lowSignalAlert"`
|
|
Claimed bool `json:"claimed"`
|
|
TimeSyncState string `json:"timeSyncState"`
|
|
Connectivity []struct {
|
|
Type string `json:"type"`
|
|
Connected bool `json:"connected"`
|
|
} `json:"connectivity"`
|
|
Groups []interface{} `json:"groups"`
|
|
LocalCert struct {
|
|
OwnCert string `json:"ownCert"`
|
|
PeerCerts []string `json:"peerCerts"`
|
|
} `json:"localCert"`
|
|
}
|
|
|
|
type GetModesResponse struct {
|
|
Active string `json:"active"`
|
|
Modes []*Mode `json:"modes"`
|
|
}
|
|
type Mode struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type,omitempty"`
|
|
RulesIds []string `json:"rules"`
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
type GetRulesResponse struct {
|
|
Rules []Rule `json:"rules"`
|
|
}
|
|
|
|
type Rule struct {
|
|
Name string `json:"name"`
|
|
Protected bool `json:"protected"`
|
|
Triggers []struct {
|
|
Type string `json:"type"`
|
|
DeviceID string `json:"deviceId"`
|
|
Sensitivity int `json:"sensitivity"`
|
|
} `json:"triggers"`
|
|
Actions []struct {
|
|
Type string `json:"type"`
|
|
Recipients []string `json:"recipients,omitempty"`
|
|
DeviceID string `json:"deviceId,omitempty"`
|
|
StopCondition struct {
|
|
Type string `json:"type"`
|
|
DeviceID string `json:"deviceId"`
|
|
} `json:"stopCondition,omitempty"`
|
|
} `json:"actions"`
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
// Basestations is a slice of Basestation objects.
|
|
type Basestations []*Basestation
|
|
|
|
// Find returns a basestation with the device id passed in.
|
|
func (bs *Basestations) Find(deviceId string) *Basestation {
|
|
for _, b := range *bs {
|
|
if b.DeviceId == deviceId {
|
|
return b
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// makeEventStreamRequest is a helper function sets up a response channel, sends a message to the event stream, and blocks waiting for the response.
|
|
func (b *Basestation) makeEventStreamRequest(payload EventStreamPayload) (*EventStreamResponse, error) {
|
|
transId := genTransId()
|
|
payload.TransId = transId
|
|
|
|
if err := b.IsConnected(); err != nil {
|
|
return nil, fmt.Errorf("event stream not connected")
|
|
}
|
|
|
|
responseChan := make(chan *EventStreamResponse)
|
|
errorChan := make(chan error)
|
|
b.eventStream.subscribe(transId, responseChan, errorChan)
|
|
defer b.eventStream.unsubscribe(transId)
|
|
|
|
// Send the payload to the event stream.
|
|
if err := b.NotifyEventStream(payload); err != nil {
|
|
return nil, fmt.Errorf("notifying event stream: %v", err)
|
|
}
|
|
|
|
timer := time.NewTimer(eventStreamTimeout)
|
|
defer timer.Stop()
|
|
|
|
// Wait for the response to come back from the event stream on the response channel.
|
|
select {
|
|
// If we get a response, return it to the caller.
|
|
case response := <-responseChan:
|
|
return response, nil
|
|
case err := <-b.eventStream.Error:
|
|
return nil, fmt.Errorf("event stream error: %v", err)
|
|
// If the event stream is closed, return an error about it.
|
|
case <-b.eventStream.DisconnectedChan:
|
|
return nil, fmt.Errorf("event stream was closed before response was read")
|
|
// If we timeout, return an error about it.
|
|
case <-timer.C:
|
|
return nil, fmt.Errorf("event stream response timed out after %.0f second", eventStreamTimeout.Seconds())
|
|
}
|
|
}
|
|
|
|
func (b *Basestation) IsConnected() error {
|
|
// If the event stream is closed, return an error about it.
|
|
select {
|
|
case <-b.eventStream.DisconnectedChan:
|
|
return fmt.Errorf("basestation not connected to event stream")
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (b *Basestation) Subscribe(ctx context.Context) error {
|
|
b.eventStream = newEventStream(BaseUrl+fmt.Sprintf(NotifyResponsesPushServiceUri, b.arlo.Account.Token), &http.Client{Jar: b.arlo.client.GetClient().Jar})
|
|
|
|
connectedChan, err := b.eventStream.listen(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("setting up event stream: %v", err)
|
|
}
|
|
forLoop:
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("failed to subscribe to the event stream: requesting shutdown")
|
|
case connected := <-connectedChan:
|
|
if !connected {
|
|
return fmt.Errorf("failed to subscribe to the event stream")
|
|
}
|
|
break forLoop
|
|
case <-b.eventStream.DisconnectedChan:
|
|
return fmt.Errorf("failed to subscribe to the event stream: event stream was closed")
|
|
}
|
|
}
|
|
|
|
if err := b.Ping(); err != nil {
|
|
_ = b.Disconnect()
|
|
return fmt.Errorf("Pingloop > error while pinging: %v > disconnect event stream", err)
|
|
}
|
|
|
|
// The Arlo event stream requires a "ping" every 30s.
|
|
go func(ctx context.Context) {
|
|
ticker := time.NewTicker(pingTime)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case _ = <-ticker.C:
|
|
if err := b.Ping(); err != nil {
|
|
log.Errorf("Pingloop > error while pinging: %v > disconnect event stream", err)
|
|
_ = b.Disconnect()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}(ctx)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (b *Basestation) Unsubscribe() error {
|
|
var response Status
|
|
_, err := b.arlo.client.R().
|
|
SetResult(&response).
|
|
SetHeader("xcloudId", b.XCloudId).
|
|
Put(UnsubscribeUri)
|
|
if err != nil {
|
|
return fmt.Errorf("unsubscribing from event stream: %v", err)
|
|
}
|
|
if response.Success == false {
|
|
return fmt.Errorf("unsubscribing from event stream: %s", response.Reason)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *Basestation) Disconnect() error {
|
|
// disconnect channel to stop event stream.
|
|
if b.eventStream != nil {
|
|
b.eventStream.disconnect()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Ping makes a call to the subscriptions endpoint. The Arlo event stream requires this message to be sent every 30s.
|
|
func (b *Basestation) Ping() error {
|
|
payload := EventStreamPayload{
|
|
Action: "set",
|
|
Resource: fmt.Sprintf("subscriptions/%s_%s", b.UserId, TransIdPrefix),
|
|
PublishResponse: false,
|
|
Properties: map[string][1]string{"devices": {b.DeviceId}},
|
|
From: fmt.Sprintf("%s_%s", b.UserId, TransIdPrefix),
|
|
To: b.DeviceId,
|
|
}
|
|
|
|
if _, err := b.makeEventStreamRequest(payload); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *Basestation) NotifyEventStream(payload EventStreamPayload) error {
|
|
var response Status
|
|
_, err := b.arlo.client.R().
|
|
SetBody(payload).
|
|
SetResult(&response).
|
|
SetHeader("xcloudId", b.XCloudId).
|
|
Post(fmt.Sprintf(NotifyUri, b.DeviceId))
|
|
if err != nil {
|
|
return fmt.Errorf("notifying event stream: %v", err)
|
|
}
|
|
if response.Success == false {
|
|
return fmt.Errorf("notifying event stream: %s", response.Reason)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *Basestation) makeRequest(action string, resource string, publishResponse bool, properties interface{}, result interface{}) error {
|
|
payload := EventStreamPayload{
|
|
Action: action,
|
|
Resource: resource,
|
|
PublishResponse: publishResponse,
|
|
Properties: properties,
|
|
From: fmt.Sprintf("%s_%s", b.UserId, TransIdPrefix),
|
|
To: b.DeviceId,
|
|
}
|
|
resp, err := b.makeEventStreamRequest(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("making event stream request: %v", err)
|
|
}
|
|
if result != nil {
|
|
err = json.Unmarshal(resp.RawProperties, result)
|
|
if err != nil {
|
|
return fmt.Errorf("unmarshalling properties: %v", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *Basestation) GetState() (*BaseStationState, error) {
|
|
var state BaseStationState
|
|
err := b.makeRequest("get", "basestation", false, nil, &state)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting basestation %s state: %v", b.DeviceName, err)
|
|
}
|
|
return &state, nil
|
|
}
|
|
|
|
func (b *Basestation) GetAllCameraState() ([]CameraState, error) {
|
|
var states []CameraState
|
|
err := b.makeRequest("get", "cameras", false, nil, &states)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting associated cameras state: %v", err)
|
|
}
|
|
return states, nil
|
|
}
|
|
|
|
func (b *Basestation) GetRules() ([]Rule, error) {
|
|
var resp GetRulesResponse
|
|
err := b.makeRequest("get", "rules", false, nil, &resp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting rules: %v", err)
|
|
}
|
|
return resp.Rules, nil
|
|
}
|
|
|
|
func (b *Basestation) GetCalendarMode() (response *EventStreamResponse, err error) {
|
|
payload := EventStreamPayload{
|
|
Action: "get",
|
|
Resource: "schedule",
|
|
PublishResponse: false,
|
|
From: fmt.Sprintf("%s_%s", b.UserId, TransIdPrefix),
|
|
To: b.DeviceId,
|
|
}
|
|
|
|
return b.makeEventStreamRequest(payload)
|
|
}
|
|
|
|
// SetCalendarMode toggles calendar mode.
|
|
// NOTE: The Arlo API seems to disable calendar mode when switching to other modes, if it's enabled.
|
|
// You should probably do the same, although, the UI reflects the switch from calendar mode to say armed mode without explicitly setting calendar mode to inactive.
|
|
func (b *Basestation) SetCalendarMode(active bool) error {
|
|
resp := make(map[string]bool)
|
|
err := b.makeRequest("set", "schedule", true, struct {
|
|
Active bool `json:"active"`
|
|
}{
|
|
Active: active,
|
|
}, &resp)
|
|
if err != nil {
|
|
return fmt.Errorf("setting calendar mode %t: %v", active, err)
|
|
}
|
|
activemode, ok := resp["active"]
|
|
if !ok {
|
|
return fmt.Errorf("active mode not present in response")
|
|
}
|
|
if activemode != active {
|
|
return fmt.Errorf("active mode is not the mode requested: requested %t, set %t", active, activemode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *Basestation) GetModes() (*GetModesResponse, error) {
|
|
var resp GetModesResponse
|
|
err := b.makeRequest("get", "modes", false, nil, &resp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting modes: %v", err)
|
|
}
|
|
return &resp, nil
|
|
}
|
|
|
|
func (b *Basestation) SetCustomMode(mode string) error {
|
|
resp := make(map[string]string)
|
|
err := b.makeRequest("set", "modes", true, struct {
|
|
Active string `json:"active"`
|
|
}{
|
|
Active: mode,
|
|
}, &resp)
|
|
if err != nil {
|
|
return fmt.Errorf("setting custom mode %s: %v", mode, err)
|
|
}
|
|
activemode, ok := resp["active"]
|
|
if !ok {
|
|
return fmt.Errorf("active mode not present in response")
|
|
}
|
|
if activemode != mode {
|
|
return fmt.Errorf("active mode is not the mode requested: requested %s, set %s", mode, activemode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *Basestation) DeleteMode(mode string) (response *EventStreamResponse, err error) {
|
|
payload := EventStreamPayload{
|
|
Action: "delete",
|
|
Resource: fmt.Sprintf("modes/%s", mode),
|
|
PublishResponse: true,
|
|
From: fmt.Sprintf("%s_%s", b.UserId, TransIdPrefix),
|
|
To: b.DeviceId,
|
|
}
|
|
|
|
return b.makeEventStreamRequest(payload)
|
|
}
|
|
|
|
func (b *Basestation) Arm() error {
|
|
err := b.SetCustomMode("mode1")
|
|
if err != nil {
|
|
return fmt.Errorf("arming (mode1): %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *Basestation) Disarm() error {
|
|
err := b.SetCustomMode("mode0")
|
|
if err != nil {
|
|
return fmt.Errorf("disarming (mode0): %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *Basestation) SirenOn() (response *EventStreamResponse, err error) {
|
|
payload := EventStreamPayload{
|
|
Action: "set",
|
|
Resource: "siren",
|
|
PublishResponse: true,
|
|
Properties: SirenProperties{
|
|
SirenState: "on",
|
|
Duration: 300,
|
|
Volume: 8,
|
|
Pattern: "alarm",
|
|
},
|
|
From: fmt.Sprintf("%s_%s", b.UserId, TransIdPrefix),
|
|
To: b.DeviceId,
|
|
}
|
|
|
|
return b.makeEventStreamRequest(payload)
|
|
}
|
|
|
|
func (b *Basestation) SirenOff() (response *EventStreamResponse, err error) {
|
|
payload := EventStreamPayload{
|
|
Action: "set",
|
|
Resource: "siren",
|
|
PublishResponse: true,
|
|
Properties: SirenProperties{
|
|
SirenState: "off",
|
|
Duration: 300,
|
|
Volume: 8,
|
|
Pattern: "alarm",
|
|
},
|
|
From: fmt.Sprintf("%s_%s", b.UserId, TransIdPrefix),
|
|
To: b.DeviceId,
|
|
}
|
|
|
|
return b.makeEventStreamRequest(payload)
|
|
}
|