From 2dd8c960d142501768000d7c6dd8bb814020f51f Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Wed, 27 May 2020 12:49:40 +0400 Subject: [PATCH] big refactorisation --- LICENSE | 21 -- Makefile | 12 - README.md | 130 --------- arlo.go | 224 +++++++-------- arlo_test.go | 17 -- arlobaby.go | 522 ----------------------------------- basestation.go | 357 ++++++++++++++++-------- camera.go | 478 ++++++-------------------------- cmd/main.go | 30 ++ devices.go | 61 ++-- events_stream.go | 196 +++++++------ go.mod | 8 +- go.sum | 23 ++ gopher-arlo.png | Bin 47543 -> 0 bytes internal/request/client.go | 162 ----------- internal/request/request.go | 25 -- internal/request/response.go | 87 ------ internal/util/util.go | 94 ------- library.go | 256 ++++++----------- logo.png | Bin 10292 -> 0 bytes responses.go | 3 +- types.go | 8 - util.go | 111 +++----- 23 files changed, 746 insertions(+), 2079 deletions(-) delete mode 100644 LICENSE delete mode 100644 Makefile delete mode 100644 README.md delete mode 100644 arlo_test.go delete mode 100644 arlobaby.go create mode 100644 cmd/main.go delete mode 100644 gopher-arlo.png delete mode 100644 internal/request/client.go delete mode 100644 internal/request/request.go delete mode 100644 internal/request/response.go delete mode 100644 internal/util/util.go delete mode 100644 logo.png diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 517a4c5..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2017 jeffreydwalter - -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. diff --git a/Makefile b/Makefile deleted file mode 100644 index 10f5540..0000000 --- a/Makefile +++ /dev/null @@ -1,12 +0,0 @@ - -all: test build - -build: - go build -v ./... - -test: - go test -v ./... - -clean: - go clean - diff --git a/README.md b/README.md deleted file mode 100644 index 400381b..0000000 --- a/README.md +++ /dev/null @@ -1,130 +0,0 @@ -# arlo-go -![](https://godoc.org/github.com/jeffreydwalter/arlo-go?status.svg) -[![Go Report Card](https://goreportcard.com/badge/github.com/jeffreydwalter/arlo-go)](https://goreportcard.com/report/github.com/jeffreydwalter/arlo-go) - -![](gopher-arlo.png) -> Go package for interacting with Netgear's Arlo camera system. - ---- -### Now in Go! -I love Go. That is why I decided to write this library! I am the creator of the first [arlo](https://github.com/jeffreydwalter/arlo) library written in Python. - -My goal is to bring parity to the Python version asap. If you know what you're doing in Go, I would appreciate any feedback on the general structure of the library, bugs found, contributions, etc. - ---- -It is by no means complete, although it does expose quite a bit of the Arlo interface in an easy to use Go pacakge. As such, this package does not come with unit tests (feel free to add them, or I will eventually) or guarantees. -**All [contributions](https://github.com/jeffreydwalter/arlo-go/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) are welcome and appreciated!** - -**Please, feel free to [contribute](https://github.com/jeffreydwalter/arlo-go/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) to this repo or buy Jeff a beer!** [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=R77B7UXMLA6ML&lc=US&item_name=Jeff%20Needs%20Beer&item_number=buyjeffabeer¤cy_code=USD&bn=PP%2dDonationsBF%3abtn_donateCC_LG%2egif%3aNonHosted) - ---- -### Generous Benefactors (Thank you!) -No beers for Jeff yet! 🍺 - ---- -### Awesomely Smart Contributors (Thank you!) -* [bwagner5](https://github.com/bwagner5) - Dec 8, 2019 - Migrated package from dep to go modules. - -If You'd like to make a diffrence in the world and get your name on this most prestegious list, have a look at our [help wanted](https://github.com/jeffreydwalter/arlo-go/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) section! - ---- -### Filing an Issue -Please read the [Issue Guidelines and Policies](https://github.com/jeffreydwalter/arlo-go/wiki/Issue-Guidelines-and-Policies) wiki page **BEFORE** you file an issue. Thanks. - ---- - -## Install -```bash -# Install latest stable package -$ go get github.com/jeffreydwalter/arlo-go -``` - -```golang -package main - -import ( - "fmt" - "log" - "sync" - "time" - - "github.com/jeffreydwalter/arlo-go" -) - -const ( - USERNAME = "user@example.com" - PASSWORD = "supersecretpassword" -) - -func main() { - - // Instantiating the Arlo object automatically calls Login(), which returns an oAuth token that gets cached. - // Subsequent successful calls to login will update the oAuth token. - arlo, err := arlo.Login(USERNAME, PASSWORD) - if err != nil { - log.Printf("Failed to login: %s\n", err) - return - } - // At this point you're logged into Arlo. - - now := time.Now() - start := now.Add(-7 * 24 * time.Hour) - - // Get all of the recordings for a date range. - library, err := arlo.GetLibrary(start, now) - if err != nil { - log.Println(err) - return - } - - // We need to wait for all of the recordings to download. - var wg sync.WaitGroup - - for _, recording := range *library { - - // Let the wait group know about the go routine that we're about to run. - wg.Add(1) - - // The go func() here makes this script download the files concurrently. - // If you want to download them serially for some reason, just remove the go func() call. - go func() { - fileToWrite, err := os.Create(fmt.Sprintf("downloads/%s_%s.mp4", time.Unix(0, recording.UtcCreatedDate*int64(time.Millisecond)).Format(("2006-01-02_15.04.05")), recording.UniqueId)) - defer fileToWrite.Close() - - if err != nil { - log.Fatal(err) - } - - // The videos produced by Arlo are pretty small, even in their longest, best quality settings. - // DownloadFile() efficiently streams the file from the http.Response.Body directly to a file. - if err := arlo.DownloadFile(recording.PresignedContentUrl, fileToWrite); err != nil { - log.Println(err) - } else { - log.Printf("Downloaded video %s from %s", recording.CreatedDate, recording.PresignedContentUrl) - } - - // Mark this go routine as done in the wait group. - wg.Done() - }() - } - - // Wait here until all of the go routines are done. - wg.Wait() - - - // The below example demonstrates how you could delete the cloud recordings after downloading them. - // Simply uncomment the below code to start using it. - - // Delete all of the videos you just downloaded from the Arlo library. - // Notice that you can pass the "library" object we got back from the GetLibrary() call. - /* if err := arlo.BatchDeleteRecordings(library); err != nil { - log.Println(err) - return - } */ - - // If we made it here without an exception, then the videos were successfully deleted. - /* log.Println("Batch deletion of videos completed successfully.") */ -} -``` - -** (coming soon) For more code examples check out the [wiki](https://github.com/jeffreydwalter/arlo-go/wiki)** diff --git a/arlo.go b/arlo.go index 35ba064..5ea02f2 100644 --- a/arlo.go +++ b/arlo.go @@ -17,121 +17,106 @@ package arlo import ( + "context" "fmt" - "net/http" "sync" "time" - "github.com/jeffreydwalter/arlo-go/internal/request" - - "github.com/pkg/errors" + "github.com/go-resty/resty/v2" ) type Arlo struct { user string pass string - client *request.Client + client *resty.Client Account Account Basestations Basestations Cameras Cameras rwmutex sync.RWMutex } -func newArlo(user string, pass string) (arlo *Arlo) { +func NewArlo() (arlo *Arlo) { - // Add important headers. - baseHeaders := make(http.Header) - baseHeaders.Add("DNT", "1") - baseHeaders.Add("schemaVersion", "1") - baseHeaders.Add("Host", "my.arlo.com") - baseHeaders.Add("Referer", "https://my.arlo.com/") - - c, _ := request.NewClient(BaseUrl, baseHeaders) + c := resty.New(). + SetHostURL(BaseUrl). + SetTimeout(30 * time.Second) return &Arlo{ - user: user, - pass: pass, client: c, } } -func Login(user string, pass string) (arlo *Arlo, err error) { - arlo = newArlo(user, pass) - - body := map[string]string{"email": arlo.user, "password": arlo.pass} - resp, err := arlo.post(LoginV2Uri, "", body, nil) - if err != nil { - return nil, errors.WithMessage(err, "failed to login") - } - defer resp.Body.Close() - +func (a *Arlo) Login(ctx context.Context, user string, pass string) error { var loginResponse LoginResponse - if err := resp.Decode(&loginResponse); err != nil { - return nil, err + _, err := a.client.R(). + SetBody(map[string]string{ + "email": user, + "password": pass, + }). + SetResult(&loginResponse). // or SetResult(AuthSuccess{}). + Post(LoginV2Uri) + + if err != nil { + return fmt.Errorf("failed to login: %v", err) + } + if !loginResponse.Success { + return fmt.Errorf("failed to login") } - if loginResponse.Success { - // Cache the auth token. - arlo.client.AddHeader("Authorization", loginResponse.Data.Token) - - // Save the account info with the arlo struct. - arlo.Account = loginResponse.Data - - // Get the devices, which also caches them on the arlo object. - if _, err := arlo.GetDevices(); err != nil { - return nil, errors.WithMessage(err, "failed to login") - } - } else { - return nil, errors.New("failed to login") + // Cache the auth token. + a.client.SetHeader("Authorization", loginResponse.Data.Token) + // Save the account info with the arlo struct. + a.Account = loginResponse.Data + // Get the devices, which also caches them on the arlo object. + if _, err := a.GetDevices(ctx); err != nil { + return fmt.Errorf("getting devices: %v", err) } - return arlo, nil + return nil } func (a *Arlo) Logout() error { - resp, err := a.put(LogoutUri, "", nil, nil) - return checkRequest(resp, err, "failed to logout") + + var response Status + _, err := a.client.R(). + SetResult(&response). + Put(LogoutUri) + if err != nil { + return fmt.Errorf("logging out: %v", err) + } + if response.Success == false { + return fmt.Errorf("logging out: %s", response.Reason) + } + return nil } -func (a *Arlo) CheckSession() (session *Session, err error) { - msg := "failed to get session" - resp, err := a.get(SessionUri, "", nil) - if err != nil { - return nil, errors.WithMessage(err, msg) - } - defer resp.Body.Close() - +func (a *Arlo) GetSession() (*Session, error) { var response SessionResponse - if err := resp.Decode(&response); err != nil { - return nil, err + _, err := a.client.R(). + SetResult(&response). + Get(SessionUri) + if err != nil { + return nil, fmt.Errorf("getting session: %v", err) } - if response.Success == false { - return nil, errors.WithMessage(errors.New(response.Reason), msg) + return nil, fmt.Errorf("getting session: %s", response.Reason) } return &response.Data, nil } -// GetDevices returns an array of all devices. -// When you call Login, this method is called and all devices are cached in the arlo object. -func (a *Arlo) GetDevices() (devices *Devices, err error) { - resp, err := a.get(fmt.Sprintf(DevicesUri, time.Now().Format("20060102")), "", nil) - if err != nil { - return nil, errors.WithMessage(err, "failed to get devices") - } - defer resp.Body.Close() - +func (a *Arlo) GetDevices(ctx context.Context) (*Devices, error) { var response DeviceResponse - if err := resp.Decode(&response); err != nil { - return nil, err + _, err := a.client.R(). + SetResult(&response). + Get(fmt.Sprintf(DevicesUri, time.Now().Format("20060102"))) + if err != nil { + return nil, fmt.Errorf("getting devices: %v", err) } - if !response.Success { - return nil, errors.New("failed to get devices") + return nil, fmt.Errorf("failed to get devices") } - if len(response.Data) == 0 { - return nil, errors.New("no devices found") + return nil, fmt.Errorf("no device found") } // Cache a pointer to the arlo object with each device. @@ -140,74 +125,69 @@ func (a *Arlo) GetDevices() (devices *Devices, err error) { } // Disconnect all of the basestations from the EventStream. - for i := range a.Basestations { - if err := a.Basestations[i].Disconnect(); err != nil { - return nil, errors.WithMessage(err, "failed to get devices") + for _, basestation := range a.Basestations { + if err := basestation.Disconnect(); err != nil { + return nil, fmt.Errorf("disconnecting device %s: %v", basestation.DeviceName, err) } } a.rwmutex.Lock() // Cache the devices as their respective types. - a.Cameras = *response.Data.GetCameras() - a.Basestations = *response.Data.GetBasestations() + a.Cameras = response.Data.GetCameras() + a.Basestations = response.Data.GetBasestations() a.rwmutex.Unlock() // subscribe each basestation to the EventStream. - for i := range a.Basestations { - if err := a.Basestations[i].Subscribe(); err != nil { - return nil, errors.WithMessage(err, "failed to get devices") + for _, basestation := range a.Basestations { + if err := basestation.Subscribe(ctx); err != nil { + return nil, fmt.Errorf("subscribing device %s: %v", basestation.DeviceName, err) } } - return &response.Data, nil } // GetProfile returns the user profile for the currently logged in user. -func (a *Arlo) GetProfile() (profile *UserProfile, err error) { - resp, err := a.get(ProfileUri, "", nil) - if err != nil { - return nil, errors.WithMessage(err, "failed to get user profile") - } - defer resp.Body.Close() - +func (a *Arlo) GetProfile() (*UserProfile, error) { var response UserProfileResponse - if err := resp.Decode(&response); err != nil { - return nil, err - } + _, err := a.client.R(). + SetResult(&response). + Get(ProfileUri) - if !response.Success { - return nil, errors.New("failed to get user profile") + if err != nil { + return nil, fmt.Errorf("getting user profile: %v", err) + } + if response.Success == false { + return nil, fmt.Errorf("getting user profile: %s", response.Reason) } - return &response.Data, nil } -// UpdateDisplayOrder sets the display order according to the order defined in the DeviceOrder given. -func (a *Arlo) UpdateDisplayOrder(d DeviceOrder) error { - resp, err := a.post(CameraOrderUri, "", d, nil) - return checkRequest(resp, err, "failed to display order") -} - -// UpdateProfile takes a first and last name, and updates the user profile with that information. -func (a *Arlo) UpdateProfile(firstName, lastName string) error { - body := map[string]string{"firstName": firstName, "lastName": lastName} - resp, err := a.put(ProfileUri, "", body, nil) - return checkRequest(resp, err, "failed to update profile") -} - -func (a *Arlo) UpdatePassword(pass string) error { - body := map[string]string{"currentPassword": a.pass, "newPassword": pass} - resp, err := a.post(UpdatePasswordUri, "", body, nil) - if err := checkRequest(resp, err, "failed to update password"); err != nil { - return err - } - - a.pass = pass - - return nil -} - -func (a *Arlo) UpdateFriends(f Friend) error { - resp, err := a.put(FriendsUri, "", f, nil) - return checkRequest(resp, err, "failed to update friends") -} +//// UpdateDisplayOrder sets the display order according to the order defined in the DeviceOrder given. +//func (a *Arlo) UpdateDisplayOrder(d DeviceOrder) error { +// resp, err := a.post(CameraOrderUri, "", d, nil) +// return checkRequest(resp, err, "failed to display order") +//} +// +//// UpdateProfile takes a first and last name, and updates the user profile with that information. +//func (a *Arlo) UpdateProfile(firstName, lastName string) error { +// body := map[string]string{"firstName": firstName, "lastName": lastName} +// resp, err := a.put(ProfileUri, "", body, nil) +// return checkRequest(resp, err, "failed to update profile") +//} +// +//func (a *Arlo) UpdatePassword(pass string) error { +// body := map[string]string{"currentPassword": a.pass, "newPassword": pass} +// resp, err := a.post(UpdatePasswordUri, "", body, nil) +// if err := checkRequest(resp, err, "failed to update password"); err != nil { +// return err +// } +// +// a.pass = pass +// +// return nil +//} +// +//func (a *Arlo) UpdateFriends(f Friend) error { +// resp, err := a.put(FriendsUri, "", f, nil) +// return checkRequest(resp, err, "failed to update friends") +//} diff --git a/arlo_test.go b/arlo_test.go deleted file mode 100644 index 3bfbd77..0000000 --- a/arlo_test.go +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright (c) 2018 Jeffrey Walter - * - * 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 diff --git a/arlobaby.go b/arlobaby.go deleted file mode 100644 index 6403c23..0000000 --- a/arlobaby.go +++ /dev/null @@ -1,522 +0,0 @@ -/* - * Copyright (c) 2018 Jeffrey Walter - * - * 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 ( - "fmt" - "time" - - "github.com/pkg/errors" -) - -/* -The methods in this file are all related to Arlo Baby (afaik). -They may apply to other camera types that have audio playback or nightlight capabilities. -*/ - -/* - The follow methods are all related to the audio features of Arlo Baby. -*/ - -// SetVolume sets the volume of the audio playback to a level from 0-100. -func (c *Camera) SetVolume(volume int) (response *EventStreamResponse, err error) { - payload := EventStreamPayload{ - Action: "set", - Resource: fmt.Sprintf("cameras/%s", c.DeviceId), - PublishResponse: true, - Properties: SpeakerProperties{ - Speaker: VolumeProperties{ - Mute: false, - Volume: volume, - }, - }, - From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), - To: c.ParentId, - } - - msg := "failed to set audio volume" - - b := c.arlo.Basestations.Find(c.ParentId) - if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) - } - return b.makeEventStreamRequest(payload, msg) -} - -// Mute mutes the audio playback. -func (c *Camera) Mute() (response *EventStreamResponse, err error) { - payload := EventStreamPayload{ - Action: "set", - Resource: fmt.Sprintf("cameras/%s", c.DeviceId), - PublishResponse: true, - Properties: SpeakerProperties{ - Speaker: VolumeProperties{ - Mute: true, - }, - }, - From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), - To: c.ParentId, - } - - msg := "failed to mute audio" - - b := c.arlo.Basestations.Find(c.ParentId) - if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) - } - return b.makeEventStreamRequest(payload, msg) -} - -// UnMute un-mutes the audio playback. -func (c *Camera) UnMute() (response *EventStreamResponse, err error) { - payload := EventStreamPayload{ - Action: "set", - Resource: fmt.Sprintf("cameras/%s", c.DeviceId), - PublishResponse: true, - Properties: SpeakerProperties{ - Speaker: VolumeProperties{ - Mute: false, - }, - }, - From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), - To: c.ParentId, - } - - msg := "failed to un-mute audio" - - b := c.arlo.Basestations.Find(c.ParentId) - if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) - } - return b.makeEventStreamRequest(payload, msg) -} - -// Play plays an audio track, specified by the track ID, from a given position starting from 0 seconds. -func (c *Camera) Play(trackId string, position int) error { - - // Defaulting to 'hugh little baby', which is a supplied track. Hopefully, the ID is the same for everyone. - if trackId == "" { - trackId = "2391d620-e491-4412-99f6-e9a40d6046ed" - } - - if position < 0 { - position = 0 - } - - payload := EventStreamPayload{ - Action: "playTrack", - Resource: "audioPlayback/player", - PublishResponse: false, - Properties: PlayTrackProperties{trackId, position}, - From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), - To: c.ParentId, - } - - msg := "failed to play audio" - - b := c.arlo.Basestations.Find(c.ParentId) - if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return errors.WithMessage(err, msg) - } - - if err := b.NotifyEventStream(payload, msg); err != nil { - return errors.WithMessage(err, msg) - } - return nil -} - -// Pause pauses audio playback. -func (c *Camera) Pause() error { - payload := EventStreamPayload{ - Action: "pause", - Resource: "audioPlayback/player", - PublishResponse: false, - From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), - To: c.ParentId, - } - - msg := "failed to pause audio" - - b := c.arlo.Basestations.Find(c.ParentId) - if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return errors.WithMessage(err, msg) - } - - if err := b.NotifyEventStream(payload, msg); err != nil { - return errors.WithMessage(err, msg) - } - return nil -} - -// Next moves audio playback to the next track. -func (c *Camera) Next() error { - payload := EventStreamPayload{ - Action: "nextTrack", - Resource: "audioPlayback/player", - PublishResponse: false, - From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), - To: c.ParentId, - } - - msg := "failed to skip audio" - - b := c.arlo.Basestations.Find(c.ParentId) - if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return errors.WithMessage(err, msg) - } - - if err := b.NotifyEventStream(payload, msg); err != nil { - return errors.WithMessage(err, msg) - } - return nil -} - -// Shuffle toggles the audio play back mode to shuffle or not. -func (c *Camera) Shuffle(on bool) (response *EventStreamResponse, err error) { - payload := EventStreamPayload{ - Action: "set", - Resource: "audioPlayback/config", - PublishResponse: true, - Properties: ShuffleProperties{ - Config: BaseShuffleProperties{ - ShuffleActive: on, - }, - }, - From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), - To: c.ParentId, - } - - var msg string - if on { - msg = "failed to enable shuffle" - } else { - msg = "failed to disable shuffle" - } - - b := c.arlo.Basestations.Find(c.ParentId) - if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) - } - return b.makeEventStreamRequest(payload, msg) -} - -func (c *Camera) Continuous() (response *EventStreamResponse, err error) { - return c.SetLoopBackMode("continuous") -} - -func (c *Camera) SingleTrack() (response *EventStreamResponse, err error) { - return c.SetLoopBackMode("singleTrack") -} - -func (c *Camera) SetLoopBackMode(loopbackMode string) (response *EventStreamResponse, err error) { - payload := EventStreamPayload{ - Action: "set", - Resource: "audioPlayback/config", - PublishResponse: true, - Properties: LoopbackModeProperties{ - Config: BaseLoopbackModeProperties{ - LoopbackMode: loopbackMode, - }, - }, - From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), - To: c.ParentId, - } - - msg := "failed to set loop back mode to %s" - - b := c.arlo.Basestations.Find(c.ParentId) - if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, fmt.Sprintf(msg, loopbackMode)) - } - return b.makeEventStreamRequest(payload, msg) -} - -func (c *Camera) GetAudioPlayback() (response *EventStreamResponse, err error) { - payload := EventStreamPayload{ - Action: "get", - Resource: "audioPlayback", - PublishResponse: false, - From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), - To: c.ParentId, - } - - msg := "failed to get audio playback" - - b := c.arlo.Basestations.Find(c.ParentId) - if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) - } - return b.makeEventStreamRequest(payload, msg) -} - -func (c *Camera) EnableSleepTimer(sleepTime int64 /* milliseconds */, sleepTimeRel int) (response *EventStreamResponse, err error) { - if sleepTime == 0 { - sleepTime = 300 + (time.Now().UnixNano() / 1000000) /* milliseconds */ - } - - payload := EventStreamPayload{ - Action: "set", - Resource: "audioPlayback/config", - PublishResponse: true, - Properties: SleepTimerProperties{ - Config: BaseSleepTimerProperties{ - SleepTime: sleepTime, - SleepTimeRel: sleepTimeRel, - }, - }, - From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), - To: c.ParentId, - } - - msg := "failed to enable sleep timer" - - b := c.arlo.Basestations.Find(c.ParentId) - if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) - } - return b.makeEventStreamRequest(payload, msg) -} - -func (c *Camera) DisableSleepTimer(sleepTimeRel int) (response *EventStreamResponse, err error) { - if sleepTimeRel == 0 { - sleepTimeRel = 300 - } - - payload := EventStreamPayload{ - Action: "set", - Resource: "audioPlayback/config", - PublishResponse: true, - Properties: SleepTimerProperties{ - Config: BaseSleepTimerProperties{ - SleepTime: 0, - SleepTimeRel: sleepTimeRel, - }, - }, - From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), - To: c.ParentId, - } - - msg := "failed to disable sleep timer" - - b := c.arlo.Basestations.Find(c.ParentId) - if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) - } - return b.makeEventStreamRequest(payload, msg) -} - -/* -The follow methods are all related to the nightlight features of Arlo Baby. - -NOTE: The current state is in: cameras[0]["properties"][0]["nightLight"] returned from the basestation.GetAssociatedCamerasState() method. -*/ -func (c *Camera) NightLight(on bool) (response *EventStreamResponse, err error) { - payload := EventStreamPayload{ - Action: "set", - Resource: fmt.Sprintf("cameras/%s", c.DeviceId), - PublishResponse: true, - Properties: NightLightProperties{ - NightLight: BaseNightLightProperties{ - Enabled: on, - }, - }, - From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), - To: c.ParentId, - } - - var msg string - if on { - msg = "failed to turn night light on" - } else { - msg = "failed to turn night light off" - } - - b := c.arlo.Basestations.Find(c.ParentId) - if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) - } - return b.makeEventStreamRequest(payload, msg) -} - -func (c *Camera) SetNightLightBrightness(level int) (response *EventStreamResponse, err error) { - payload := EventStreamPayload{ - Action: "set", - Resource: fmt.Sprintf("cameras/%s", c.DeviceId), - PublishResponse: true, - Properties: NightLightProperties{ - NightLight: BaseNightLightProperties{ - Brightness: level, - }, - }, - From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), - To: c.ParentId, - } - - msg := "failed to set night light brightness" - - b := c.arlo.Basestations.Find(c.ParentId) - if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) - } - return b.makeEventStreamRequest(payload, msg) -} - -// SetNightLightMode set the night light mode. Valid values are: "rainbow" or "rgb". -func (c *Camera) SetNightLightMode(mode string) (response *EventStreamResponse, err error) { - msg := "failed to set night light brightness" - - if mode != "rainbow" && mode != "rgb" { - return nil, errors.WithMessage(errors.New("mode can only be \"rainbow\" or \"rgb\""), msg) - } - - payload := EventStreamPayload{ - Action: "set", - Resource: fmt.Sprintf("cameras/%s", c.DeviceId), - PublishResponse: true, - Properties: NightLightProperties{ - NightLight: BaseNightLightProperties{ - Mode: mode, - }, - }, - From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), - To: c.ParentId, - } - - b := c.arlo.Basestations.Find(c.ParentId) - if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) - } - return b.makeEventStreamRequest(payload, msg) -} - -// SetNightLightColor sets the night light color to the RGB value specified by the three parameters, which have valid values from 0-255. -func (c *Camera) SetNightLightColor(red, blue, green int) (response *EventStreamResponse, err error) { - // Sanity check; if the values are above or below the allowed limits, set them to their limit. - if red < 0 { - red = 0 - } else if red > 255 { - red = 255 - } - if blue < 0 { - blue = 0 - } else if blue > 255 { - blue = 255 - } - if green < 0 { - green = 0 - } else if green > 255 { - green = 255 - } - - payload := EventStreamPayload{ - Action: "set", - Resource: fmt.Sprintf("cameras/%s", c.DeviceId), - PublishResponse: true, - Properties: NightLightProperties{ - NightLight: BaseNightLightProperties{ - RGB: NightLightRGBProperties{ - Red: red, - Blue: blue, - Green: green, - }, - }, - }, - From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), - To: c.ParentId, - } - - msg := "failed to set night light color" - - b := c.arlo.Basestations.Find(c.ParentId) - if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) - } - return b.makeEventStreamRequest(payload, msg) -} - -func (c *Camera) EnableNightLightTimer(sleepTime int64 /* milliseconds */, sleepTimeRel int) (response *EventStreamResponse, err error) { - if sleepTime == 0 { - sleepTime = 300 + (time.Now().UnixNano() / 1000000) /* milliseconds */ - } - - payload := EventStreamPayload{ - Action: "set", - Resource: fmt.Sprintf("cameras/%s", c.DeviceId), - PublishResponse: true, - Properties: NightLightProperties{ - NightLight: BaseNightLightProperties{ - SleepTime: sleepTime, - SleepTimeRel: sleepTimeRel, - }, - }, - From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), - To: c.ParentId, - } - - msg := "failed to enable night light timer" - - b := c.arlo.Basestations.Find(c.ParentId) - if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) - } - return b.makeEventStreamRequest(payload, msg) -} - -func (c *Camera) DisableNightLightTimer(sleepTimeRel int) (response *EventStreamResponse, err error) { - if sleepTimeRel == 0 { - sleepTimeRel = 300 - } - - payload := EventStreamPayload{ - Action: "set", - Resource: fmt.Sprintf("cameras/%s", c.DeviceId), - PublishResponse: true, - Properties: NightLightProperties{ - NightLight: BaseNightLightProperties{ - SleepTime: 0, - SleepTimeRel: sleepTimeRel, - }, - }, - From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), - To: c.ParentId, - } - - msg := "failed to disable night light timer" - - b := c.arlo.Basestations.Find(c.ParentId) - if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) - } - return b.makeEventStreamRequest(payload, msg) -} diff --git a/basestation.go b/basestation.go index 55617be..73d04c8 100644 --- a/basestation.go +++ b/basestation.go @@ -17,14 +17,16 @@ package arlo import ( + "context" + "encoding/json" "fmt" "net/http" "time" - "github.com/pkg/errors" + log "github.com/sirupsen/logrus" ) -const eventStreamTimeout = 10 * time.Second +const eventStreamTimeout = 30 * time.Second const pingTime = 30 * time.Second // A Basestation is a Device that's not type "camera" (basestation, arloq, arloqs, etc.). @@ -34,41 +36,108 @@ type Basestation struct { 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 +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 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, msg string) (response *EventStreamResponse, err error) { +func (b *Basestation) makeEventStreamRequest(payload EventStreamPayload) (*EventStreamResponse, error) { transId := genTransId() payload.TransId = transId if err := b.IsConnected(); err != nil { - //if err := b.Subscribe(); err != nil { - return nil, errors.WithMessage(errors.WithMessage(err, msg), "failed to reconnect to event stream") - //} + return nil, fmt.Errorf("event stream not connected") } - subscriber := make(subscriber) - - // Add the response channel to the event stream queue so the response can be written to it. - b.eventStream.subscribe(transId, subscriber) - // Make sure we close and remove the response channel before returning. + 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, msg); err != nil { - return nil, err + if err := b.NotifyEventStream(payload); err != nil { + return nil, fmt.Errorf("notifying event stream: %v", err) } timer := time.NewTimer(eventStreamTimeout) @@ -77,72 +146,89 @@ func (b *Basestation) makeEventStreamRequest(payload EventStreamPayload, msg str // 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 := <-subscriber: + case response := <-responseChan: return response, nil - case err = <-b.eventStream.Error: - return nil, errors.Wrap(err, msg) + 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.Disconnected: - err = errors.New("event stream was closed before response was read") - return nil, errors.WithMessage(err, msg) + 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: - err = fmt.Errorf("event stream response timed out after %.0f second", eventStreamTimeout.Seconds()) - return nil, errors.WithMessage(err, msg) + 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.Disconnected: - return errors.New("basestation not connected to event stream") + case <-b.eventStream.DisconnectedChan: + return fmt.Errorf("basestation not connected to event stream") default: return nil } } -func (b *Basestation) Subscribe() error { - b.eventStream = newEventStream(BaseUrl+fmt.Sprintf(NotifyResponsesPushServiceUri, b.arlo.Account.Token), &http.Client{Jar: b.arlo.client.HttpClient.Jar}) +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 { - // We blocking here because we can't really do anything with the event stream until we're connected. - // Once we have confirmation that we're connected to the event stream, we will "subscribe" to events. select { - case connected := <-b.eventStream.listen(): - if connected { - break forLoop - } else { - return errors.New("failed to subscribe to the event stream") + 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") } - case <-b.eventStream.Disconnected: - err := errors.New("event stream was closed") - return errors.WithMessage(err, "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 { - return errors.WithMessage(err, "failed to subscribe to the event stream") + _ = 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() { + go func(ctx context.Context) { + ticker := time.NewTicker(pingTime) for { - time.Sleep(pingTime) - if err := b.Ping(); err != nil { - b.Disconnect() - break + 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 { - resp, err := b.arlo.get(UnsubscribeUri, b.XCloudId, nil) - return checkRequest(resp, err, "failed to unsubscribe from event stream") + 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 { @@ -164,57 +250,75 @@ func (b *Basestation) Ping() error { To: b.DeviceId, } - if _, err := b.makeEventStreamRequest(payload, "failed to ping the event stream"); err != nil { + if _, err := b.makeEventStreamRequest(payload); err != nil { return err } return nil } -func (b *Basestation) NotifyEventStream(payload EventStreamPayload, msg string) error { - resp, err := b.arlo.post(fmt.Sprintf(NotifyUri, b.DeviceId), b.XCloudId, payload, nil) - if err := checkRequest(resp, err, msg); err != nil { - return errors.WithMessage(err, "failed to notify event stream") +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) } - defer resp.Body.Close() - return nil } -func (b *Basestation) GetState() (response *EventStreamResponse, err error) { - +func (b *Basestation) makeRequest(action string, resource string, publishResponse bool, properties interface{}, result interface{}) error { payload := EventStreamPayload{ - Action: "get", - Resource: "basestation", - PublishResponse: false, + Action: action, + Resource: resource, + PublishResponse: publishResponse, + Properties: properties, From: fmt.Sprintf("%s_%s", b.UserId, TransIdPrefix), To: b.DeviceId, } - - return b.makeEventStreamRequest(payload, "failed to get basestation state") + 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) GetAssociatedCamerasState() (response *EventStreamResponse, err error) { - payload := EventStreamPayload{ - Action: "get", - Resource: "cameras", - PublishResponse: false, - From: fmt.Sprintf("%s_%s", b.UserId, TransIdPrefix), - To: b.DeviceId, +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 b.makeEventStreamRequest(payload, "failed to get associated cameras state") + return &state, nil } -func (b *Basestation) GetRules() (response *EventStreamResponse, err error) { - payload := EventStreamPayload{ - Action: "get", - Resource: "rules", - PublishResponse: false, - From: fmt.Sprintf("%s_%s", b.UserId, TransIdPrefix), - To: b.DeviceId, +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 +} - return b.makeEventStreamRequest(payload, "failed to get rules") +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) { @@ -226,52 +330,59 @@ func (b *Basestation) GetCalendarMode() (response *EventStreamResponse, err erro To: b.DeviceId, } - return b.makeEventStreamRequest(payload, "failed to get schedule") + 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) (response *EventStreamResponse, err error) { - payload := EventStreamPayload{ - Action: "set", - Resource: "schedule", - PublishResponse: true, - Properties: BasestationScheduleProperties{ - Active: active, - }, - From: fmt.Sprintf("%s_%s", b.UserId, TransIdPrefix), - To: b.DeviceId, +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) } - - return b.makeEventStreamRequest(payload, "failed to set schedule") + 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() (response *EventStreamResponse, err error) { - payload := EventStreamPayload{ - Action: "get", - Resource: "modes", - PublishResponse: false, - From: fmt.Sprintf("%s_%s", b.UserId, TransIdPrefix), - To: b.DeviceId, +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 b.makeEventStreamRequest(payload, "failed to get modes") + return &resp, nil } -func (b *Basestation) SetCustomMode(mode string) (response *EventStreamResponse, err error) { - payload := EventStreamPayload{ - Action: "set", - Resource: "modes", - PublishResponse: true, - Properties: BasestationModeProperties{ - Active: mode, - }, - From: fmt.Sprintf("%s_%s", b.UserId, TransIdPrefix), - To: b.DeviceId, +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) } - - return b.makeEventStreamRequest(payload, "failed to set mode") + 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) { @@ -283,15 +394,23 @@ func (b *Basestation) DeleteMode(mode string) (response *EventStreamResponse, er To: b.DeviceId, } - return b.makeEventStreamRequest(payload, "failed to set mode") + return b.makeEventStreamRequest(payload) } -func (b *Basestation) Arm() (response *EventStreamResponse, err error) { - return b.SetCustomMode("mode1") +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() (response *EventStreamResponse, err error) { - return b.SetCustomMode("mode0") +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) { @@ -309,7 +428,7 @@ func (b *Basestation) SirenOn() (response *EventStreamResponse, err error) { To: b.DeviceId, } - return b.makeEventStreamRequest(payload, "failed to get modes") + return b.makeEventStreamRequest(payload) } func (b *Basestation) SirenOff() (response *EventStreamResponse, err error) { @@ -327,5 +446,5 @@ func (b *Basestation) SirenOff() (response *EventStreamResponse, err error) { To: b.DeviceId, } - return b.makeEventStreamRequest(payload, "failed to get modes") + return b.makeEventStreamRequest(payload) } diff --git a/camera.go b/camera.go index f7fa291..e169f9d 100644 --- a/camera.go +++ b/camera.go @@ -18,24 +18,88 @@ package arlo import ( "fmt" - "strings" - "time" - - "github.com/pkg/errors" ) // A Camera is a Device of type "camera". // This type is here just for semantics. Some methods explicitly require a device of a certain type. type Camera Device +type CameraState struct { + InterfaceVersion int `json:"interfaceVersion"` + SerialNumber string `json:"serialNumber"` + BatteryLevel int `json:"batteryLevel"` + BatteryTech string `json:"batteryTech"` + ChargerTech string `json:"chargerTech"` + ChargingState string `json:"chargingState"` + ChargeOnly bool `json:"chargeOnly"` + ChargeNotificationLedEnable bool `json:"chargeNotificationLedEnable"` + AudioMicAGC int `json:"audioMicAGC"` + SignalStrength int `json:"signalStrength"` + Brightness int `json:"brightness"` + Mirror bool `json:"mirror"` + Flip bool `json:"flip"` + PowerSaveMode int `json:"powerSaveMode"` + Zoom struct { + Topleftx int `json:"topleftx"` + Toplefty int `json:"toplefty"` + Bottomrightx int `json:"bottomrightx"` + Bottomrighty int `json:"bottomrighty"` + } `json:"zoom"` + Mic struct { + Mute bool `json:"mute"` + Volume int `json:"volume"` + } `json:"mic"` + Speaker struct { + Mute bool `json:"mute"` + Volume int `json:"volume"` + } `json:"speaker"` + StreamingMode string `json:"streamingMode"` + ContinuousStreamState string `json:"continuousStreamState"` + Motion struct { + Sensitivity int `json:"sensitivity"` + Zones []interface{} `json:"zones"` + } `json:"motion"` + Resolution struct { + Width int `json:"width"` + Height int `json:"height"` + } `json:"resolution"` + IdleLedEnable bool `json:"idleLedEnable"` + PrivacyActive bool `json:"privacyActive"` + StandbyActive bool `json:"standbyActive"` + SetupActive bool `json:"setupActive"` + ConnectionState string `json:"connectionState"` + ActivityState string `json:"activityState"` + SwVersion string `json:"swVersion"` + HwVersion string `json:"hwVersion"` + ModelID string `json:"modelId"` + MotionSetupModeEnabled bool `json:"motionSetupModeEnabled"` + MotionSetupModeSensitivity int `json:"motionSetupModeSensitivity"` + MotionDetected bool `json:"motionDetected"` + AudioDetected bool `json:"audioDetected"` + HasStreamed bool `json:"hasStreamed"` + LocalRecordingActive bool `json:"localRecordingActive"` + OlsonTimeZone string `json:"olsonTimeZone"` + Name string `json:"name"` + NightVisionMode int `json:"nightVisionMode"` + VideoMode string `json:"videoMode"` + Hdr string `json:"hdr"` + UpdateAvailable interface{} `json:"updateAvailable"` + BlockNotifications struct { + Block bool `json:"block"` + Duration int `json:"duration"` + EndTime int `json:"endTime"` + } `json:"blockNotifications"` + BestLocalLiveStreaming string `json:"bestLocalLiveStreaming"` +} + // Cameras is a slice of Camera objects. -type Cameras []Camera +type Cameras []*Camera // Find returns a camera with the device id passed in. func (cs *Cameras) Find(deviceId string) *Camera { for _, c := range *cs { if c.DeviceId == deviceId { - return &c + return c } } @@ -55,14 +119,11 @@ func (c *Camera) On() (response *EventStreamResponse, err error) { To: c.ParentId, } - msg := "failed to turn camera on" - b := c.arlo.Basestations.Find(c.ParentId) if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) + return nil, fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) } - return b.makeEventStreamRequest(payload, msg) + return b.makeEventStreamRequest(payload) } // On turns a camera off; meaning it won't detect and record events. @@ -78,14 +139,11 @@ func (c *Camera) Off() (response *EventStreamResponse, err error) { To: c.ParentId, } - msg := "failed to turn camera off" - b := c.arlo.Basestations.Find(c.ParentId) if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) + return nil, fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) } - return b.makeEventStreamRequest(payload, msg) + return b.makeEventStreamRequest(payload) } // SetBrightness sets the camera brightness. @@ -109,15 +167,11 @@ func (c *Camera) SetBrightness(brightness int) (response *EventStreamResponse, e From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), To: c.ParentId, } - - msg := "failed to set camera brightness" - b := c.arlo.Basestations.Find(c.ParentId) if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) + return nil, fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) } - return b.makeEventStreamRequest(payload, msg) + return b.makeEventStreamRequest(payload) } func (c *Camera) EnableMotionAlerts(sensitivity int, zones []string) (response *EventStreamResponse, err error) { @@ -135,15 +189,11 @@ func (c *Camera) EnableMotionAlerts(sensitivity int, zones []string) (response * From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), To: c.ParentId, } - - msg := "failed to enable motion alerts" - b := c.arlo.Basestations.Find(c.ParentId) if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) + return nil, fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) } - return b.makeEventStreamRequest(payload, msg) + return b.makeEventStreamRequest(payload) } func (c *Camera) DisableMotionAlerts(sensitivity int, zones []string) (response *EventStreamResponse, err error) { @@ -162,14 +212,11 @@ func (c *Camera) DisableMotionAlerts(sensitivity int, zones []string) (response To: c.ParentId, } - msg := "failed to enable motion alerts" - b := c.arlo.Basestations.Find(c.ParentId) if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) + return nil, fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) } - return b.makeEventStreamRequest(payload, msg) + return b.makeEventStreamRequest(payload) } func (c *Camera) EnableAudioAlerts(sensitivity int) (response *EventStreamResponse, err error) { @@ -187,14 +234,11 @@ func (c *Camera) EnableAudioAlerts(sensitivity int) (response *EventStreamRespon To: c.ParentId, } - msg := "failed to enable audio alerts" - b := c.arlo.Basestations.Find(c.ParentId) if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) + return nil, fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) } - return b.makeEventStreamRequest(payload, msg) + return b.makeEventStreamRequest(payload) } func (c *Camera) DisableAudioAlerts(sensitivity int) (response *EventStreamResponse, err error) { @@ -212,192 +256,11 @@ func (c *Camera) DisableAudioAlerts(sensitivity int) (response *EventStreamRespo To: c.ParentId, } - msg := "failed to disable audio alerts" - b := c.arlo.Basestations.Find(c.ParentId) if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) + return nil, fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) } - return b.makeEventStreamRequest(payload, msg) -} - -// PushToTalk starts a push-to-talk session. -// FIXME: This feature requires more API calls to make it actually work, and I haven't figure out how to fully implement it. -// It appears that the audio stream is Real-Time Transport Protocol (RTP), which requires a player (ffmpeg?) to consume the stream. -func (c *Camera) PushToTalk() error { - /* - processResponse: function(e) { - if (g.pc) - if (e.properties && "answerSdp" == e.properties.type) { - var t = e.properties.data - , i = { - type: "answer", - sdp: t - }; - r.debug(i), - g.pc.setRemoteDescription(new g.SessionDescription(i), u, d) - } else if (e.properties && "answerCandidate" == e.properties.type) - if (g.candidateCache) - g.candidateCache.push(e.properties.data); - else { - var n = e.properties.data - , a = window.mozRTCIceCandidate || window.RTCIceCandidate - , o = new a({ - candidate: n, - sdpMLineIndex: 0 - }); - r.debug(o), - g.pc.addIceCandidate(o) - } - }, - startConnection: function(t) { - g.loading = !0, - g.error = !1, - g.candidateCache = []; - var i = t.deviceId - , o = t.parentId - , u = t.uniqueId; - g.device = t; - var p = { - method: "GET", - url: l.getPttUrl(u), - data: "", - headers: { - Authorization: s.ssoToken, - "Content-Type": "application/json; charset=utf-8", - "Data-Type": "json" - } - }; - r.debug("getting ptt data: " + JSON.stringify(p)); - n(p).then(function(u) { - if (!u.data.success) - return e.$broadcast("show_error", u.data), - void (g.error = u.data.data.message || !0); - var m = u.data.data.data; - g.uSessionId = u.data.data.uSessionId, - _.each(m, function(e) { - e.url && (e.urls = e.url, - delete e.url) - }); - var f = new g.PeerConnection({ - iceServers: m, - iceCandidatePoolSize: 0 - }); - f.onicecandidate = function(e) { - if (null != e.candidate) { - r.debug(e.candidate); - var a = { - action: "pushToTalk", - from: t.userId, - publishResponse: !1, - resource: "cameras/" + i, - responseUrl: "", - to: o, - transId: "web!98b0c88b!1429756137177", - properties: { - uSessionId: g.uSessionId, - type: "offerCandidate", - data: e.candidate.candidate - } - }; - p = { - method: "POST", - url: l.getPttNotifyUrl(o), - data: a, - headers: { - xcloudId: t.xCloudId, - Authorization: s.ssoToken - } - }, - n(p) - } else - r.debug("Failed to get any more candidate") - } - , - f.oniceconnectionstatechange = function(e) { - r.debug("ICE Connection State Change:" + f.iceConnectionState), - "connected" == f.iceConnectionState || "completed" == f.iceConnectionState ? g.loading = !1 : "disconnected" != f.iceConnectionState && "failed" != f.iceConnectionState || (g.stopConnection(), - g.error = a("i18n")("camera_label_ptt_failed_to_connect")) - } - , - g.pc = f, - (navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia).call(navigator, { - audio: !0, - video: !1 - }, function(e) { - g.stream = e, - g.stream.getAudioTracks()[0].enabled = !1, - f.addStream(e), - f.createOffer(function(e) { - f.setLocalDescription(e, c, d), - r.debug(e.sdp); - var a = { - action: "pushToTalk", - from: t.userId, - publishResponse: !0, - resource: "cameras/" + i, - responseUrl: "", - to: o, - transId: "web!98b0c88b!1429756137177", - properties: { - uSessionId: g.uSessionId, - type: "offerSdp", - data: e.sdp - } - }; - p = { - method: "POST", - url: l.getPttNotifyUrl(o), - data: a, - headers: { - xcloudId: t.xCloudId, - Authorization: s.ssoToken - } - }, - n(p) - }, d) - }, d) - }) - }, - stopConnection: function() { - if (g.pc) { - var e = { - action: "pushToTalk", - from: g.device.userId, - publishResponse: !1, - resource: "cameras/" + g.device.deviceId, - responseUrl: "", - to: g.device.deviceId, - transId: "web!98b0c88b!1429756137177", - properties: { - uSessionId: g.uSessionId, - type: "endSession" - } - } - , t = { - method: "POST", - url: l.getPttNotifyUrl(g.device.deviceId), - data: e, - headers: { - xcloudId: g.device.xCloudId, - Authorization: s.ssoToken - } - }; - n(t); - try { - g.stream.getAudioTracks()[0].stop(), - g.stream = null - } catch (e) {} - g.pc.close(), - g.pc = null, - g.loading = !0 - } - } - }; - */ - resp, err := c.arlo.get(fmt.Sprintf(PttUri, c.UniqueId), c.XCloudId, nil) - return checkRequest(resp, err, "failed to enable push to talk") + return b.makeEventStreamRequest(payload) } // action: disabled OR recordSnapshot OR recordVideo @@ -422,178 +285,9 @@ func (c *Camera) SetAlertNotificationMethods(action string, email, push bool) (r To: c.ParentId, } - msg := "failed to set alert notification methods" - b := c.arlo.Basestations.Find(c.ParentId) if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) + return nil, fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) } - return b.makeEventStreamRequest(payload, msg) -} - -// StartStream returns a json object containing the rtmps url to the requested video stream. -// You will need something like ffmpeg to read the rtmps stream. - -// If you call StartStream(), you have to start reading data from the stream, or streaming will be cancelled -// and taking a snapshot may fail (since it requires the stream to be active). -func (c *Camera) StartStream() (url string, err error) { - payload := EventStreamPayload{ - Action: "set", - Resource: fmt.Sprintf("cameras/%s", c.DeviceId), - PublishResponse: true, - Properties: map[string]string{ - "activityState": "startUserStream", - "cameraId": c.DeviceId, - }, - TransId: genTransId(), - From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), - To: c.ParentId, - } - - msg := "failed to start stream" - - resp, err := c.arlo.post(StartStreamUri, c.XCloudId, payload, nil) - if err != nil { - return "", errors.WithMessage(err, msg) - } - defer resp.Body.Close() - - response := new(StreamResponse) - if err := resp.Decode(response); err != nil { - return "", err - } - - if !response.Success { - return "", errors.WithMessage(errors.New("status was false"), msg) - } - - response.Data.URL = strings.Replace(response.Data.URL, "rtsp://", "rtsps://", 1) - - return response.Data.URL, nil -} - -// TakeSnapshot causes the camera to snapshot while recording. -// NOTE: You MUST call StartStream() before calling this function. -// If you call StartStream(), you have to start reading data from the stream, or streaming will be cancelled -// and taking a snapshot may fail (since it requires the stream to be active). - -// NOTE: You should not use this function is you just want a snapshot and aren't intending to stream. -// Use TriggerFullFrameSnapshot() instead. -// -// NOTE: Use DownloadSnapshot() to download the actual image file. -// TODO: Need to refactor the even stream code to allow handling of events whose transIds don't correlate. :/ -func (c *Camera) TakeSnapshot() (response *EventStreamResponse, err error) { - - return nil, errors.New("TakeSnapshot not implemented") - /* - msg := "failed to take snapshot" - - body := map[string]string{"deviceId": c.DeviceId, "parentId": c.ParentId, "xcloudId": c.XCloudId, "olsonTimeZone": c.Properties.OlsonTimeZone} - resp, err := c.arlo.post(TakeSnapshotUri, c.XCloudId, body, nil) - if err := checkRequest(resp, err, msg); err != nil { - return nil, errors.WithMessage(err, msg) - } - */ - - // TODO: Need to write the code to handle the event stream message. - /* - def callback(self, event): - if event.get("deviceId") == camera.get("deviceId") and event.get("resource") == "mediaUploadNotification": - presigned_content_url = event.get("presignedContentUrl") - if presigned_content_url is not None: - r return presigned_content_url - */ -} - -// TriggerFullFrameSnapshot causes the camera to record a full-frame snapshot. -// The presignedFullFrameSnapshotUrl url is returned. -// Use DownloadSnapshot() to download the actual image file. -// TODO: Need to refactor the even stream code to allow handling of events whose transIds don't correlate. :/ -func (c *Camera) TriggerFullFrameSnapshot() (response *EventStreamResponse, err error) { - - return nil, errors.New("TriggerFullFrameSnapshot not implemented") - /* - payload := EventStreamPayload{ - Action: "set", - Resource: fmt.Sprintf("cameras/%s", c.DeviceId), - PublishResponse: true, - Properties: map[string]string{ - "activityState": "fullFrameSnapshot", - }, - TransId: genTransId(), - From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), - To: c.ParentId, - } - - msg := "failed to trigger full-frame snapshot" - - b := c.arlo.Basestations.Find(c.ParentId) - if b == nil { - err := fmt.Errorf("basestation (%s) not found for camera (%s)", c.ParentId, c.DeviceId) - return nil, errors.WithMessage(err, msg) - } - return b.makeEventStreamRequest(payload, msg) - */ - /* - def callback(self, event): - if event.get("from") == basestation.get("deviceId") and event.get("resource") == "cameras/"+camera.get("deviceId") and event.get("action") == "fullFrameSnapshotAvailable": - return event.get("properties", {}).get("presignedFullFrameSnapshotUrl") - return None - */ -} - -// StartRecording causes the camera to start recording and returns a url that you must start reading from using ffmpeg -// or something similar. -func (c *Camera) StartRecording() (url string, err error) { - msg := "failed to start recording" - - url, err = c.StartStream() - if err != nil { - return "", errors.WithMessage(err, msg) - } - - body := map[string]string{"deviceId": c.DeviceId, "parentId": c.ParentId, "xcloudId": c.XCloudId, "olsonTimeZone": c.Properties.OlsonTimeZone} - resp, err := c.arlo.post(StartRecordUri, c.XCloudId, body, nil) - if err := checkRequest(resp, err, msg); err != nil { - return "", errors.WithMessage(err, msg) - } - - return url, nil -} - -// StopRecording causes the camera to stop recording. -func (c *Camera) StopRecording() error { - msg := "failed to stop recording" - - body := map[string]string{"deviceId": c.DeviceId, "parentId": c.ParentId, "xcloudId": c.XCloudId, "olsonTimeZone": c.Properties.OlsonTimeZone} - resp, err := c.arlo.post(StopRecordUri, c.XCloudId, body, nil) - if err := checkRequest(resp, err, msg); err != nil { - return errors.WithMessage(err, msg) - } - - return nil -} - -// This function downloads a Cvr Playlist file for the period fromDate to toDate. -func (c *Camera) GetCvrPlaylist(fromDate, toDate time.Time) (playlist *CvrPlaylist, err error) { - msg := "failed to get cvr playlist" - - resp, err := c.arlo.get(fmt.Sprintf(PlaylistUri, c.UniqueId, fromDate.Format("20060102"), toDate.Format("20060102")), c.XCloudId, nil) - - if err != nil { - return nil, errors.WithMessage(err, msg) - } - defer resp.Body.Close() - - response := new(CvrPlaylistResponse) - if err := resp.Decode(&response); err != nil { - return nil, err - } - - if !response.Success { - return nil, errors.New(msg) - } - - return &response.Data, nil + return b.makeEventStreamRequest(payload) } diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..e9bc66b --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "context" + "git.lehouerou.net/laurent/arlo-go" + log "github.com/sirupsen/logrus" +) + +func main() { + a := arlo.NewArlo() + err := a.Login(context.Background(), "hass@lehouerou.net", "TiPXMVLUeZfUg6RrmwzK") + if err != nil { + log.Errorf("login: %v", err) + return + } + + for _, device := range a.Cameras { + log.Infof("%s", device.DeviceName) + } + + for _, b := range a.Basestations { + err := b.SetCustomMode("mode3") + if err != nil { + log.Error(err) + } + log.Info("ok") + } + select {} + +} diff --git a/devices.go b/devices.go index 6ab01a8..1443f15 100644 --- a/devices.go +++ b/devices.go @@ -46,7 +46,7 @@ type Device struct { PresignedFullFrameSnapshotUrl string `json:"presignedFullFrameSnapshotUrl"` PresignedLastImageUrl string `json:"presignedLastImageUrl"` PresignedSnapshotUrl string `json:"presignedSnapshotUrl"` - MediaObjectCount uint8 `json:"mediaObjectCount"` + MediaObjectCount uint32 `json:"mediaObjectCount"` ModelId string `json:"modelId"` Owner Owner `json:"owner"` ParentId string `json:"parentId"` @@ -59,7 +59,7 @@ type Device struct { } // Devices is a slice of Device objects. -type Devices []Device +type Devices []*Device // A DeviceOrder holds a map of device ids and a numeric index. The numeric index is the device order. // Device order is mainly used by the UI to determine which order to show the devices. @@ -76,10 +76,10 @@ type DeviceOrder struct { } // Find returns a device with the device id passed in. -func (ds *Devices) Find(deviceId string) *Device { - for _, d := range *ds { +func (ds Devices) Find(deviceId string) *Device { + for _, d := range ds { if d.DeviceId == deviceId { - return &d + return d } } @@ -87,28 +87,28 @@ func (ds *Devices) Find(deviceId string) *Device { } func (ds Devices) FindCameras(basestationId string) Cameras { - cs := new(Cameras) + cs := Cameras{} for _, d := range ds { if d.ParentId == basestationId { - *cs = append(*cs, Camera(d)) + cam := Camera(*d) + cs = append(cs, &cam) } } - - return *cs + return cs } func (d Device) IsBasestation() bool { - return d.DeviceType == DeviceTypeBasestation || d.DeviceId == d.ParentId + return d.DeviceType == DeviceTypeBasestation } func (d Device) IsCamera() bool { - switch(d.DeviceType) { - case - DeviceTypeCamera, - DeviceTypeArloQ: - return true - } - return false + switch d.DeviceType { + case + DeviceTypeCamera, + DeviceTypeArloQ: + return true + } + return false } func (d Device) IsArloQ() bool { @@ -127,11 +127,11 @@ func (d Device) IsSiren() bool { // I did this because some device types, like arloq, don't have a basestation. // So, when interacting with them you must treat them like a basestation and a camera. // Cameras also includes devices of this type, so you can get the same data there or cast. -func (ds Devices) GetBasestations() *Basestations { - basestations := new(Basestations) +func (ds Devices) GetBasestations() Basestations { + var basestations Basestations for _, d := range ds { - if d.IsBasestation() || !d.IsCamera() { - *basestations = append(*basestations, Basestation{Device: d}) + if d.IsBasestation() { + basestations = append(basestations, &Basestation{Device: *d}) } } return basestations @@ -141,19 +141,20 @@ func (ds Devices) GetBasestations() *Basestations { // I did this because some device types, like arloq, don't have a basestation. // So, when interacting with them you must treat them like a basestation and a camera. // Basestations also includes devices of this type, so you can get the same data there or cast. -func (ds Devices) GetCameras() *Cameras { - cameras := new(Cameras) +func (ds Devices) GetCameras() Cameras { + var cameras Cameras for _, d := range ds { - if d.IsCamera() || !d.IsBasestation() { - *cameras = append(*cameras, Camera(d)) + if d.IsCamera() { + cam := Camera(*d) + cameras = append(cameras, &cam) } } return cameras } // UpdateDeviceName sets the name of the given device to the name argument. -func (d *Device) UpdateDeviceName(name string) error { - body := map[string]string{"deviceId": d.DeviceId, "deviceName": name, "parentId": d.ParentId} - resp, err := d.arlo.put(RenameDeviceUri, d.XCloudId, body, nil) - return checkRequest(resp, err, "failed to update device name") -} +//func (d *Device) UpdateDeviceName(name string) error { +// body := map[string]string{"deviceId": d.DeviceId, "deviceName": name, "parentId": d.ParentId} +// resp, err := d.arlo.put(RenameDeviceUri, d.XCloudId, body, nil) +// return checkRequest(resp, err, "failed to update device name") +//} diff --git a/events_stream.go b/events_stream.go index 32fd2c7..0d188cf 100644 --- a/events_stream.go +++ b/events_stream.go @@ -18,132 +18,148 @@ package arlo import ( "bytes" + "context" "encoding/json" + "fmt" "net/http" "sync" - "github.com/pkg/errors" - "github.com/r3labs/sse" + log "github.com/sirupsen/logrus" ) -var ( - FAILED_TO_PUBLISH = errors.New("failed to publish") - FAILED_TO_DECODE_JSON = errors.New("failed to decode json") - FAILED_TO_SUBSCRIBE = errors.New("failed to subscribe to seeclient") -) - -type subscriber chan *EventStreamResponse - -type subscribers map[string]subscriber - -type subscriptions struct { - subscribers - rwmutex sync.RWMutex -} - type eventStream struct { - SSEClient *sse.Client - Events chan *sse.Event - Error chan error - Verbose bool - Disconnected chan interface{} - once *sync.Once + SSEClient *sse.Client + Events chan *sse.Event + Error chan error + Verbose bool + DisconnectedChan chan interface{} + once *sync.Once - subscriptions + subscribers map[string]chan *EventStreamResponse + subscribersMutex sync.RWMutex + + errorsubsribers map[string]chan error + errorMutex sync.RWMutex } func newEventStream(url string, client *http.Client) *eventStream { + e := &eventStream{ + Events: make(chan *sse.Event), + subscribers: make(map[string]chan *EventStreamResponse), + subscribersMutex: sync.RWMutex{}, + errorsubsribers: make(map[string]chan error), + errorMutex: sync.RWMutex{}, + DisconnectedChan: make(chan interface{}), + once: new(sync.Once), + } SSEClient := sse.NewClient(url) SSEClient.Connection = client - - return &eventStream{ - SSEClient: SSEClient, - Events: make(chan *sse.Event), - subscriptions: subscriptions{make(map[string]subscriber), sync.RWMutex{}}, - Error: make(chan error), - Disconnected: make(chan interface{}), - once: new(sync.Once), - } + SSEClient.OnDisconnect(func(c *sse.Client) { + e.disconnect() + }) + e.SSEClient = SSEClient + return e } func (e *eventStream) disconnect() { e.once.Do(func() { - close(e.Disconnected) + close(e.DisconnectedChan) }) } -func (e *eventStream) listen() (connected chan bool) { - connected = make(chan bool) +func (e *eventStream) listen(ctx context.Context) (chan bool, error) { + connectedChan := make(chan bool) + err := e.SSEClient.SubscribeChanRaw(e.Events) + if err != nil { + return nil, fmt.Errorf("failed to subscribe to seeclient") + } go func() { - e.SSEClient.OnDisconnect(func(c *sse.Client) { - e.disconnect() - // fmt.Printf("\n\n\n\nClIENT DISCONNECTED!!!!!\n\n\n\n") - }) - err := e.SSEClient.SubscribeChanRaw(e.Events) - if err != nil { - e.Error <- FAILED_TO_SUBSCRIBE - } - for { select { - case event := <-e.Events: - //fmt.Println("Got event message.") - /* - fmt.Print(".") - fmt.Printf("EVENT: %s\n", event.Event) - fmt.Printf("DATA: %s\n", event.Data) - */ - - if event != nil && event.Data != nil { - notifyResponse := &EventStreamResponse{} - b := bytes.NewBuffer(event.Data) - err := json.NewDecoder(b).Decode(notifyResponse) - if err != nil { - e.Error <- FAILED_TO_DECODE_JSON - break - } - - // FIXME: This is a shitty way to handle this. It's potentially leaking a chan. - if notifyResponse.Status == "connected" { - connected <- true - } else if notifyResponse.Status == "disconnected" { - e.disconnect() - } else if notifyResponse.Action == "logout" { - e.disconnect() - } else { - e.subscriptions.rwmutex.RLock() - subscriber, ok := e.subscribers[notifyResponse.TransId] - e.subscriptions.rwmutex.RUnlock() - if ok { - subscriber <- notifyResponse - } - } + case <-ctx.Done(): + e.disconnect() + return + case event, ok := <-e.Events: + if !ok { + return } - case <-e.Disconnected: - connected <- false + + if event == nil || event.Data == nil { + log.Warn("EventStream > nil event or nil data in event") + continue + } + + fmt.Printf("DATA : %s\n", event.Data) + + var notifyResponse EventStreamResponse + err := json.NewDecoder(bytes.NewBuffer(event.Data)).Decode(¬ifyResponse) + if err != nil { + log.Warnf("EventStream > failed to decode event: %s", event.Data) + continue + } + bytesProperties, err := json.Marshal(notifyResponse.EventStreamPayload.Properties) + if err != nil { + log.Warnf("EventStream > failed to marshal raw properties: %s", event.Data) + continue + } + notifyResponse.RawProperties = bytesProperties + + if notifyResponse.Status == "connected" { + connectedChan <- true + continue + } + if notifyResponse.Status == "disconnected" { + e.disconnect() + continue + } + if notifyResponse.Action == "logout" { + log.Warn("EventStream > logged out") + e.disconnect() + continue + } + e.subscribersMutex.RLock() + subscriber, ok := e.subscribers[notifyResponse.TransId] + e.subscribersMutex.RUnlock() + if ok { + subscriber <- ¬ifyResponse + } + + case <-e.DisconnectedChan: + connectedChan <- false return } } }() - return connected + return connectedChan, nil } -func (s *subscriptions) unsubscribe(transId string) { - s.rwmutex.Lock() - defer s.rwmutex.Unlock() - if _, ok := s.subscribers[transId]; ok { - close(s.subscribers[transId]) - delete(s.subscribers, transId) +func (e *eventStream) unsubscribe(transId string) { + e.subscribersMutex.Lock() + if c, ok := e.subscribers[transId]; ok { + close(c) + delete(e.subscribers, transId) } + e.subscribersMutex.Unlock() + + e.errorMutex.Lock() + if c, ok := e.errorsubsribers[transId]; ok { + close(c) + delete(e.errorsubsribers, transId) + } + e.errorMutex.Unlock() } -func (s *subscriptions) subscribe(transId string, subscriber subscriber) { - s.rwmutex.Lock() - s.subscribers[transId] = subscriber - s.rwmutex.Unlock() +func (e *eventStream) subscribe(transId string, subscriber chan *EventStreamResponse, errorChan chan error) { + e.subscribersMutex.Lock() + e.subscribers[transId] = subscriber + e.subscribersMutex.Unlock() + + e.errorMutex.Lock() + e.errorsubsribers[transId] = errorChan + e.errorMutex.Unlock() } diff --git a/go.mod b/go.mod index 59590de..27db254 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,9 @@ -module github.com/jeffreydwalter/arlo-go +module git.lehouerou.net/laurent/arlo-go -go 1.13 +go 1.14 require ( - github.com/pkg/errors v0.8.1 + github.com/go-resty/resty/v2 v2.3.0 github.com/r3labs/sse v0.0.0-20191120111931-24eacf438413 - golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933 + github.com/sirupsen/logrus v1.6.0 ) diff --git a/go.sum b/go.sum index 92f762f..2c9c022 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,41 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-resty/resty v1.12.0 h1:L1P5qymrXL5H/doXe2pKUr1wxovAI5ilm2LdVLbwThc= +github.com/go-resty/resty/v2 v2.3.0 h1:JOOeAvjSlapTT92p8xiS19Zxev1neGikoHsXJeOq8So= +github.com/go-resty/resty/v2 v2.3.0/go.mod h1:UpN9CgLZNsv4e9XG50UU8xdI0F43UQ4HmxLBDwaroHU= +github.com/jeffreydwalter/arlo-go v0.0.0-20200420231349-d68ae1fb4cb7 h1:vFCcYqhV2UgUB2ROxwdW1fVoOVE33jr4snGsRKKIBQ8= +github.com/jeffreydwalter/arlo-go v0.0.0-20200420231349-d68ae1fb4cb7/go.mod h1:eNo//rDcum+KKc3Itlb1YzEzX+7dBuRzdD93eS5pd2Q= +github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/r3labs/sse v0.0.0-20191120111931-24eacf438413 h1:bF3heZD0lrJF16uVKKJsorZjBS7ET5Y9QKYtgno7X4Q= github.com/r3labs/sse v0.0.0-20191120111931-24eacf438413/go.mod h1:S8xSOnV3CgpNrWd0GQ/OoQfMtlg2uPRSuTzcSGrzwK8= +github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933 h1:e6HwijUxhDe+hPNjZQQn9bA5PW3vNmnN64U2ZW759Lk= golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120 h1:EZ3cVSzKOlJxAd8e8YAJ7no8nNypTxexh/YE/xW3ZEY= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/gopher-arlo.png b/gopher-arlo.png deleted file mode 100644 index 786654877d25a13260b5bc596eb0c91d00188470..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 47543 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 - * - * 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 request - -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "net/http/cookiejar" - "net/url" - "sync" - "time" - - "github.com/pkg/errors" - - "golang.org/x/net/publicsuffix" -) - -type Client struct { - BaseURL *url.URL - BaseHeaders *http.Header - HttpClient *http.Client - rwmutex sync.RWMutex -} - -func NewClient(baseURL string, baseHeaders http.Header) (*Client, error) { - var err error - var jar *cookiejar.Jar - - options := cookiejar.Options{PublicSuffixList: publicsuffix.List} - - if jar, err = cookiejar.New(&options); err != nil { - return nil, errors.Wrap(err, "failed to create client object") - } - - var u *url.URL - if u, err = url.Parse(baseURL); err != nil { - return nil, errors.Wrap(err, "failed to create client object") - } - - header := make(http.Header) - header.Set("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 11_1_2 like Mac OS X) AppleWebKit/604.3.5 (KHTML, like Gecko) Mobile/15B202 NETGEAR/v1 (iOS Vuezone)") - header.Set("Content-Type", "application/json") - header.Set("Accept", "application/json") - - return &Client{ - BaseURL: u, - BaseHeaders: &header, - HttpClient: &http.Client{Jar: jar, Timeout: 30 * time.Second}, - }, nil -} - -func (c *Client) AddHeader(key, value string) { - c.rwmutex.Lock() - c.BaseHeaders.Set(key, value) - c.rwmutex.Unlock() -} - -func (c *Client) Get(uri string, header http.Header) (*Response, error) { - req, err := c.newRequest("GET", uri, nil, header) - if err != nil { - return nil, errors.WithMessage(err, "get request "+uri+" failed") - } - return c.do(req) -} - -func (c *Client) Post(uri string, body interface{}, header http.Header) (*Response, error) { - req, err := c.newRequest("POST", uri, body, header) - if err != nil { - return nil, errors.WithMessage(err, "post request "+uri+" failed") - } - return c.do(req) -} - -func (c *Client) Put(uri string, body interface{}, header http.Header) (*Response, error) { - req, err := c.newRequest("PUT", uri, body, header) - if err != nil { - return nil, errors.WithMessage(err, "put request "+uri+" failed") - } - return c.do(req) -} - -func (c *Client) newRequest(method string, uri string, body interface{}, header http.Header) (*Request, error) { - - var buf io.ReadWriter - if body != nil { - buf = new(bytes.Buffer) - err := json.NewEncoder(buf).Encode(body) - if err != nil { - return nil, errors.Wrap(err, "failed to create request object") - } - } - // log.Printf("\n\nBODY (%s): %s\n\n", uri, buf) - - u := c.BaseURL.String() + uri - req, err := http.NewRequest(method, u, buf) - if err != nil { - return nil, errors.Wrap(err, "failed to create request object") - } - - c.rwmutex.RLock() - baseHeaders := *c.BaseHeaders - c.rwmutex.RUnlock() - - for k, v := range baseHeaders { - for _, h := range v { - //log.Printf("Adding header (%s): (%s - %s)\n\n", u, k, h) - req.Header.Set(k, h) - } - } - - for k, v := range header { - for _, h := range v { - //log.Printf("Adding header (%s): (%s - %s)\n\n", u, k, h) - req.Header.Set(k, h) - } - } - - return &Request{ - Request: *req, - }, nil -} - -func (c *Client) newResponse(resp *http.Response) (*Response, error) { - - return &Response{ - Response: *resp, - }, nil -} - -func (c *Client) do(req *Request) (*Response, error) { - - // log.Printf("\n\nCOOKIES (%s): %v\n\n", req.URL, c.HttpClient.Jar.Cookies(req.URL)) - // log.Printf("\n\nHEADERS (%s): %v\n\n", req.URL, req.Header) - - resp, err := c.HttpClient.Do(&req.Request) - if err != nil { - return nil, errors.Wrap(err, "failed to execute http request") - } - - if resp.StatusCode >= http.StatusBadRequest { - defer resp.Body.Close() - return nil, errors.New("http request failed with status: " + resp.Status) - } - - return c.newResponse(resp) -} diff --git a/internal/request/request.go b/internal/request/request.go deleted file mode 100644 index ffa85a0..0000000 --- a/internal/request/request.go +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (c) 2018 Jeffrey Walter - * - * 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 request - -import ( - "net/http" -) - -type Request struct { - http.Request -} diff --git a/internal/request/response.go b/internal/request/response.go deleted file mode 100644 index 1efc4cd..0000000 --- a/internal/request/response.go +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright (c) 2018 Jeffrey Walter - * - * 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 request - -import ( - "encoding/json" - "io" - "log" - "mime" - "net/http" - "os" - "reflect" - - "github.com/pkg/errors" -) - -type Response struct { - http.Response -} - -func (resp *Response) GetContentType() (string, error) { - - mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) - if err != nil { - return "", errors.Wrap(err, "failed to get content type") - } - return mediaType, nil -} - -func (resp *Response) Decode(s interface{}) error { - - defer resp.Body.Close() - - mediaType, err := resp.GetContentType() - if err != nil { - return errors.WithMessage(err, "failed to decode response body") - } - - switch mediaType { - case "application/json": - err := json.NewDecoder(resp.Body).Decode(&s) - if err != nil { - return errors.Wrap(err, "failed to create "+reflect.TypeOf(s).String()+" object") - } - default: - return errors.New("unsupported content type: " + mediaType) - } - return nil -} - -func (resp *Response) Download(to string) (error, int64) { - - defer resp.Body.Close() - - // Create output file - newFile, err := os.Create(to) - if err != nil { - log.Fatal(err) - } - defer newFile.Close() - - // Write bytes from HTTP response to file. - // response.Body satisfies the reader interface. - // newFile satisfies the writer interface. - // That allows us to use io.Copy which accepts - // any type that implements reader and writer interface - bytesWritten, err := io.Copy(newFile, resp.Body) - if err != nil { - log.Fatal(err) - } - - return nil, bytesWritten -} diff --git a/internal/util/util.go b/internal/util/util.go deleted file mode 100644 index 98921fa..0000000 --- a/internal/util/util.go +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright (c) 2018 Jeffrey Walter - * - * 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 util - -import ( - "encoding/json" - "fmt" - "net/http" - "strconv" -) - -func PrettyPrint(data interface{}) string { - j, err := json.MarshalIndent(data, "", " ") - if err != nil { - return fmt.Sprint("error:", err) - } - return fmt.Sprint(string(j)) -} - -func FloatToHex(x float64) string { - var result []byte - quotient := int(x) - fraction := x - float64(quotient) - - for quotient > 0 { - quotient = int(x / 16) - remainder := int(x - (float64(quotient) * 16)) - - if remainder > 9 { - result = append([]byte{byte(remainder + 55)}, result...) - } else { - for _, c := range strconv.Itoa(int(remainder)) { - result = append([]byte{byte(c)}, result...) - } - } - - x = float64(quotient) - } - - if fraction == 0 { - return string(result) - } - - result = append(result, '.') - - for fraction > 0 { - fraction = fraction * 16 - integer := int(fraction) - fraction = fraction - float64(integer) - - if integer > 9 { - result = append(result, byte(integer+55)) - } else { - for _, c := range strconv.Itoa(int(integer)) { - result = append(result, byte(c)) - } - } - } - - return string(result) -} - -func HeaderToArray(header http.Header) (res []string) { - for name, values := range header { - for _, value := range values { - res = append(res, fmt.Sprintf("%s: %s", name, value)) - } - } - return -} - -func HeaderToMap(header http.Header) map[string]string { - h := make(map[string]string) - for name, values := range header { - for _, value := range values { - h[name] = value - } - } - return h -} diff --git a/library.go b/library.go index 2470dec..1850c8e 100644 --- a/library.go +++ b/library.go @@ -16,12 +16,6 @@ package arlo -import ( - "time" - - "github.com/pkg/errors" -) - // LibraryMetaData is the library meta data. type LibraryMetaData struct { DateTo string `json:"dateTo"` @@ -53,169 +47,87 @@ type Recording struct { type Library []Recording -func (a *Arlo) GetLibraryMetaData(fromDate, toDate time.Time) (libraryMetaData *LibraryMetaData, err error) { - msg := "failed to get library metadata" - - body := map[string]string{"dateFrom": fromDate.Format("20060102"), "dateTo": toDate.Format("20060102")} - resp, err := a.post(MetadataUri, "", body, nil) - if err != nil { - return nil, errors.WithMessage(err, msg) - } - defer resp.Body.Close() - - response := new(LibraryMetaDataResponse) - if err := resp.Decode(&response); err != nil { - return nil, err - } - - if !response.Success { - return nil, errors.New(msg) - } - - return &response.Data, nil -} - -func (a *Arlo) GetLibrary(fromDate, toDate time.Time) (library *Library, err error) { - msg := "failed to get library" - - body := map[string]string{"dateFrom": fromDate.Format("20060102"), "dateTo": toDate.Format("20060102")} - resp, err := a.post(RecordingsUri, "", body, nil) - if err != nil { - return nil, errors.WithMessage(err, msg) - } - defer resp.Body.Close() - - response := new(LibraryResponse) - if err := resp.Decode(&response); err != nil { - return nil, err - } - - if !response.Success { - return nil, errors.New(msg) - } - - return &response.Data, nil -} - -/* - Delete a single video recording from arlo. - - All of the date info and device id you need to pass into this method are given in the results of the GetLibrary() call. - - NOTE: {"data": [{"createdDate": r.CreatedDate, "utcCreatedDate": r.UtcCreatedDate, "deviceId": r.DeviceId}]} is all that's really required. -*/ -func (a *Arlo) DeleteRecording(r *Recording) error { - body := map[string]Library{"data": {*r}} - resp, err := a.post(RecycleUri, "", body, nil) - return checkRequest(resp, err, "failed to delete recording") -} - -/* - Delete a batch of video recordings from arlo. - - The GetLibrary() call response json can be passed directly to this method if you'd like to delete the same list of videos you queried for. - - NOTE: {"data": [{"createdDate": r.CreatedDate, "utcCreatedDate": r.UtcCreatedDate, "deviceId": r.DeviceId}]} is all that's really required. -*/ -func (a *Arlo) BatchDeleteRecordings(l *Library) error { - body := map[string]Library{"data": *l} - resp, err := a.post(RecycleUri, "", body, nil) - return checkRequest(resp, err, "failed to delete recordings") -} - -// SendAnalyticFeedback is only really used by the GUI. It is a response to a prompt asking you whether an object which -// was tagged by it's AI in your recording was tagged correctly. -func (a *Arlo) SendAnalyticFeedback(r *Recording) error { - category := "Person" // Other - body := map[string]map[string]interface{}{"data": {"utcCreatedDate": r.UtcCreatedDate, "category": category, "createdDate": r.CreatedDate}} - resp, err := a.put(AnalyticFeedbackUri, "", body, nil) - return checkRequest(resp, err, "failed to send analytic feedback about recording") -} - -// GetActiveAutomationDefinitions gets the mode metadata (this API replaces the older GetModes(), which still works). -func (a *Arlo) GetActiveAutomationDefinitions() error { - resp, err := a.get(ActiveAutomationUri, "", nil) - return checkRequest(resp, err, "failed to get active automation definitions") -} - -/* -func (a *Arlo) SetActiveAutomationMode() error { - - body := struct{}{} //map[string]map[string]interface{}{"data": {"utcCreatedDate": r.UtcCreatedDate, "category": category, "createdDate": r.CreatedDate}} - resp, err := a.put(AnalyticFeedbackUri, "", body, nil) - return checkRequest(resp, err, "failed to send analytic feedback about recording") -} -*/ -/* -[ - { - "activeModes": [ - "mode1" - ], - "activeSchedules": [], - "gatewayId": "48935B7SA9847", - "schemaVersion": 1, - "timestamp": 1536781758034, - "type": "activeAutomations", - "uniqueId": "336-4764296_48935B7SA9847" - } -] -*/ -/* - setActiveAutomationMode: function(r, a) { - var s = { - activeAutomations: [{ - deviceId: a.gatewayId, - timestamp: _.now(), - activeModes: [r], - activeSchedules: [] - }] - } - , l = { - method: "POST", - data: s, - url: d.getActiveAutomationUrl(a.gatewayId), - headers: { - Authorization: o.ssoToken, - schemaVersion: 1 - } - }; - return n.debug("calling set active automation mode with config:" + JSON.stringify(l)), - i(l).then(function(i) { - if (n.debug("got set active automation mode result:" + JSON.stringify(i)), - i && i.data && !i.data.success) - return e.$broadcast(c.appEvents.SHOW_ERROR, i.data), - t.reject(i.data) - }) - }, - setActiveAutomationSchedule: function(r) { - var r = { - activeAutomations: [{ - deviceId: r.deviceId, - timestamp: _.now(), - activeModes: [], - activeSchedules: [r.scheduleId] - }] - } - , a = { - method: "POST", - data: r, - url: d.getActiveAutomationUrl(r.deviceId), - headers: { - Authorization: o.ssoToken, - schemaVersion: 1 - } - } - , s = this; - return n.debug("calling set active automation schedule with config:" + JSON.stringify(a)), - i(a).then(function(i) { - return n.debug("got set active automation schedule result:" + JSON.stringify(i)), - i && i.data && !i.data.success ? (e.$broadcast(c.appEvents.SHOW_ERROR, i.data), - t.reject(i.data)) : i && i.data && i.data.success ? (_.filter(s.activeAutomationDefinitions, function(e) { - e.gatewayId == i.config.data.activeAutomations[0].deviceId && (e.activeModes = i.config.data.activeAutomations[0].activeModes, - e.activeSchedules = i.config.data.activeAutomations[0].activeSchedules) - }), - i.config.data.activeAutomations[0]) : void 0 - }) - }, -*/ +//func (a *Arlo) GetLibraryMetaData(fromDate, toDate time.Time) (libraryMetaData *LibraryMetaData, err error) { +// msg := "failed to get library metadata" +// +// body := map[string]string{"dateFrom": fromDate.Format("20060102"), "dateTo": toDate.Format("20060102")} +// resp, err := a.post(MetadataUri, "", body, nil) +// if err != nil { +// return nil, errors.WithMessage(err, msg) +// } +// defer resp.Body.Close() +// +// response := new(LibraryMetaDataResponse) +// if err := resp.Decode(&response); err != nil { +// return nil, err +// } +// +// if !response.Success { +// return nil, errors.New(msg) +// } +// +// return &response.Data, nil +//} +// +//func (a *Arlo) GetLibrary(fromDate, toDate time.Time) (library *Library, err error) { +// msg := "failed to get library" +// +// body := map[string]string{"dateFrom": fromDate.Format("20060102"), "dateTo": toDate.Format("20060102")} +// resp, err := a.post(RecordingsUri, "", body, nil) +// if err != nil { +// return nil, errors.WithMessage(err, msg) +// } +// defer resp.Body.Close() +// +// response := new(LibraryResponse) +// if err := resp.Decode(&response); err != nil { +// return nil, err +// } +// +// if !response.Success { +// return nil, errors.New(msg) +// } +// +// return &response.Data, nil +//} +// +///* +// Delete a single video recording from arlo. +// +// All of the date info and device id you need to pass into this method are given in the results of the GetLibrary() call. +// +// NOTE: {"data": [{"createdDate": r.CreatedDate, "utcCreatedDate": r.UtcCreatedDate, "deviceId": r.DeviceId}]} is all that's really required. +//*/ +//func (a *Arlo) DeleteRecording(r *Recording) error { +// body := map[string]Library{"data": {*r}} +// resp, err := a.post(RecycleUri, "", body, nil) +// return checkRequest(resp, err, "failed to delete recording") +//} +// +///* +// Delete a batch of video recordings from arlo. +// +// The GetLibrary() call response json can be passed directly to this method if you'd like to delete the same list of videos you queried for. +// +// NOTE: {"data": [{"createdDate": r.CreatedDate, "utcCreatedDate": r.UtcCreatedDate, "deviceId": r.DeviceId}]} is all that's really required. +//*/ +//func (a *Arlo) BatchDeleteRecordings(l *Library) error { +// body := map[string]Library{"data": *l} +// resp, err := a.post(RecycleUri, "", body, nil) +// return checkRequest(resp, err, "failed to delete recordings") +//} +// +//// SendAnalyticFeedback is only really used by the GUI. It is a response to a prompt asking you whether an object which +//// was tagged by it's AI in your recording was tagged correctly. +//func (a *Arlo) SendAnalyticFeedback(r *Recording) error { +// category := "Person" // Other +// body := map[string]map[string]interface{}{"data": {"utcCreatedDate": r.UtcCreatedDate, "category": category, "createdDate": r.CreatedDate}} +// resp, err := a.put(AnalyticFeedbackUri, "", body, nil) +// return checkRequest(resp, err, "failed to send analytic feedback about recording") +//} +// +//// GetActiveAutomationDefinitions gets the mode metadata (this API replaces the older GetModes(), which still works). +//func (a *Arlo) GetActiveAutomationDefinitions() error { +// resp, err := a.get(ActiveAutomationUri, "", nil) +// return checkRequest(resp, err, "failed to get active automation definitions") +//} diff --git a/logo.png b/logo.png deleted file mode 100644 index d33a19515a1d75cb6d8371cf7e752edfb1ee690a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10292 zcmV-4D9hK0P)^eVy6OSz4n;xpo<55v;=MUJ!DW3qIi*IC?vIjo&X~8B{Q@9N+yG?$nwP0TidHY$1gu8+iy`kc%TTBN59pRtkci zKSG$r;`JLh-k>O{s>;RZY|U@zF&yJd05Ossajd`~-1iGTmgMy8jjp57hZ-|HkDP{)>^006){Ac>laTXXtm%cv0upS(7+$>xSHUe6V7JTZA z$3EQn|LgBxNh4k*MyCIa_fCGj`~Sk1`~P5`WdTJT2QZ{rfc`)9@#=qDJ}t&y|Nj63 z_&*|=By{0j=rzmSMK_UZb=KMen6Ku-GubT0^tvkEe7d42M~t$-fm zABLX@Pv8CibcsHXf;=c5e*#T7@#)roP@LHFb1`x-vooIld=sqDfLjR?<~EnBfu8;g zOd9tXKrsyhphU;Sz&IDkOh)#w3cDmC;$VuwA;G{13LRdIH~`uV5P*)dX=x+~!f^FW z&xi4YDB`z!aamZ78Ju(KD@@=QOy*M9t%dV zzMgPsHCW|S>2dw8{JhRnee*hJ`~CLK2nWE@+kq4VZj z5mRjxK{Oy(6C|P%-$bMTfNl*tH||VKaATqy7y1XdXKPH1CcYABBt~2)5dsMop+u1i z9i}twwDa&gLyHB9!X%S+ZtvWld(U@%-`O_6wtKB{s=whPnWok@rlrOyN8I6RSFj~S zzZJQ~Jf$6)fV~$=WAL2Sddu7b|oK32tTe&WG+$x4~Z0(4gikQgbP*t%rbshYR z`t5PX()*9YuWb~b2#*-fOgNG#uFfa#>kD^sqhyj*cR*w>-=CJJ-qA}$a%jE(blqV- zlUukyq9rv4anRy1Z^QMYp=RD4YUS}j)1HJn>3YwmJCG}WR7~wuvp;$xV`igy{E>+Q z{AH@Qk?3W|c35w)mFIvBrI5Wam7jmrjb{pI2%QOiHhMx&cFQ~Z(Go0Lw(0Im5-MbR zE)wU4SpcqWb~4-!$Qj5*J1;^JjQC>XcsxbcAlJTD%-jRyoKb&47>~)3M?2;40Cb~K z71RKPic!HLFCrrp(BGx4%(Yp4aj-`4z3i-Mx>$0c)ylHKrhS3vt%NeJb4c@8?S68A)+f1K*nZK=J|JN*R=k%X9f1B#v6X8SLuqbx1M3ZVV zbqXwPYFDQ7^Uufv1A=!+6a)vIQ)EJ)drQfqro#j67px+C14NUJUV_^d(c*Z771=sC zj=a5b7u=h;I@c3wi5Zwcyr(EhlC|*UkQ8K37lzR&dfaj1`!2cuUfNU#oE3;pWmlihv=!owLXX-Si}c!)E6t&2SEfu@x>Q?_Q~c!5LEO* zuqsukN-I^97Ne=IHBA#@(l!k-$?jw?vpZM6|LkVnw3{_;=)hrTW@pa)=Rg1XE~l>W zs&|kWUN3cr+=(Uq(gfgbl4+Ta+%p!Hm3?hsgDPmr40F017C^L&xsYJ031dt0lnRMSao9 zKbd!mdvqTBhM-Z!eR36wlyf(50IugYd;sO~$V74WYa(Eshd#bN*8Z%5L5yT4KVS09 z{WKGV&lG5@5;;oh*`usOhHI#K!_M!RD*f}B;S_e(BIZUl{zYdvxt~zgWA612z;7_c zu*SUUSqUmVKbGFUd#(~9L&Zh(*iT3?1-2qs`Yaf8J${%E4P8nmr5#2_W;Bj3G4+($+ zJm@^ZKg7gl2#lGF=G+U@*8Bj?fbbQgFV@+-tb(FS)3Kfym6|2*1TZ_}g-dUjd}j-J z8ZZfLh@}o?C+xw@nA8l=c~2#SiBCyiI1Q9gbpX1E)cu2|c7qcAmTKd+VDcd9g5{lj zGNDFJ!8{i+12g0!7VPXnU4*{Jy;6XQza@7dle@Y#Z!@1J#y*QNS0 zCPsSoj$Ta-ImClh36!j<()=gX2na}4IFjWtee~;>p&yph*F|{v=0M^#zAyFG3iTi4 zyh)9S3%qC}5l!Ii9DFc61CfA%deW(;OF6P&OeH+6 zJsf<9`$Ytx$k9@ELa61_OQXMI>U*eENLAb$@6sRw9A7@eWTzOMe1|;ra<6I4VmiQ1 zppOEA)EJreFdNB@e^v0TM=3Bc=*ivjt$TtU}flo0klmRO5$t z#@b#iAeg#}6#bJw`^h*v>G)o|B2mX{K zY{p!WGoUnjnCkAlIn!_rp}n)bl~5T_y+*+gRGvSu9`QzwW@u@Y{kK`tFuFB?+8dR? zsJ@!f&Qz{xCv3KIPVG?T`P6z9XsuWKR{&zIH8zUt3g5Xivrl{1USsnz*mbZ&n(zoo zL(&q|)Tn8tNGX+)R;m&;m8vRzO8L`2Y4anBMEj>oQIQ%|RjQ_~RJEvzl1hc*fQthK zYK)B?3>cdbKQX&&?_+jmcINhccV^9EY_m2(8EaXyJL^67-t+jr@0@$Cpxx_hL51%2 zB?HU&Tnc#|It#(;PYTyxoU~G}5o&D#Bz`=yxt@5y^!!sSD3)YTrQO4(;V*KjN)aCQ z(SQXeMh}H5pRDDmfQqjjxTsS?q5$X$=v#z^RyiXpeK`VWAUE>&Nqgq&FiP>OHq~74 z09DNMI*S+@7yQ#@WHNYYwd?Xp7&2z@-+C>88dMN;F(~*XS?9_96wTYiU0T=h@Ob?$BfOZn)iHN6+i2|L#oS^ol>8BiMjlNyl5 z8wA5Y8)48&FS|q;I&n2+?L}o;T9)FXbGbtzkjf`Wofpc`t4!RFU8oyYhM~^9f>R;9Z78B}&ZQ;+?6w5qkqX12upAEpPTW9^8vt*7ELrf^Bkq^~2jK33hjR#mAS zxL-?#?@%Z z;2g(j15?6al1!oO664`RCM#~JRjd96wSwFvQq4mhps}tiVqr#=m|l+P;V#W& zvJR8au^ECBwos(}sQ6fj_=c(RAWJ(_d&*HSPFSGc2)l*S_pcCNEax)jcRbJmrNv#X zQX?HLf%@;<(zL%-UU8)?n@3BPA&nNKL@o_yeq#nEDxO&?*`F zowyfEIN8D6_#fBlHT$A^s2@RmIN2jgjZd6vtrk5~r&a$pss>JkdAJ{dk8@8L2-24r z6m1T1bchHsvP`q*C9r@}Qj>26u}K+6CB_z1f^4iX*)F)2pm#ZCm$hrO*l##jZD&zJ z=3yblpS@TYNW1_a576%SWUl-n5~7MWK2=I9dt}X*v&^%{X*j! zHz_FVdDVgTx-FQjy6(=Mm&YEF$6*3?&mv@GaHtAVBp`IkbMw2x30_2>Gl*Je_rAf6ROo5iA zO_0Mwc-_-aZYhG=Nu0CK>kZNUXMG+Ot=-6<1WSBT1uV=(v2XfaA_T;Y(hJ52a0|b|HxSulR zZmPQW-u2V;mdiC+HN~QccEU{U`5=8>8jmcpQLlS>Q&r;|w(BgS!Yr%BKmh7x($#9yY1v(JI7UTX?lh-*;*=X_gSpUCHyqTdSHLst+gnA$_N?`B6D2`G)s#= zLoPyLGkp#*)-cx_VXV2*=>RC=myI#@aC8$@ z7;`16gu?o=BN(}wKddoa+9*KXL&+ZL??}Bt`OxL>hm&XNIgI;$;osl1+*E^V-H|%Y z2wE>!7ZMVc=Ew*6?Q3oI$5yaY`^i0ibh$9eE*8e;I0fR#OUKgQZ~LEnw`}p1D}q{- z9Z8;JJL8*WwXbLasF)|yeT&Wc?JxWNhx9l>Zu95zLsdCnsE_b{=mjew@KR^RbeX8o zg>I0~mzsPFTrlJ%dOYX<|4)#S=&{?M_lf(9EPY+6WWTXu6^>5zOnLx>rm_<-QxY0})~P%f}XjSN`HU@2bH;L7kz@RM81I!maFN8GFlbaFniI|M>(xe&_$b_HK>8 zNsmP3S*XJ6d;as+{8rL#-(^nGm496))( z?F9M?x#}AUN$Sw)XJ);}1$m-rQ3>6%yf|1#+33=8P&+RzQoSpk#Xr>eI`L7pf`6HW z&uv(JMjNUjDV1r5MW+k<1R!*Q+I;u%Jtrx7rSPNApYVH;=*m;47geLwsU*?Hk z@iOlQLwWS(-s;figs+oQV@o*4V+)@fpF6q{zEO5|rqf|ZSj81i``4D*t(%c zdn_?F{VdWv;q$Np0T$m=^nW$!M(=HllnDxR7|oXkae6Btih|?5NSZMIpgVEp&hmj5 zLF!6NDiOm7uy#6Atv_0m@BTV!S{#tLj3=upQ$gtilkP=(y9Fvq$rC8HPz;-8i_4IWT2@I_7bkK--tbKCszR?40EB5_m zA}n8;;7#~&DOsC@VSgOhEZ)>q<%cNhr49zxMfn*sEglI7`|87a;Hj|M4 zp3;ncsaTsdLrVJspJy|f-W^MYam~lAyyuWNao=Q6z5*5km}ctwT%f%kdw3j^rz|v) z*PT6`cKdD1?6yC1ObNYr#i5z;f?ElqUN+oO>3>!VUuH8{^({vB&*wF*x?I-{u8U&w zlY;S=d#Z=-$3z^O<47}6kXmBqH?Pihd=8d_npDUj+K{x$2!U1-pD@~o+-V{xUbLq& z_#8ppc8d#cI`dc!F|)P`2Zw6+Oz}%BZF3u#yGZNyxkG>2Ss4K1rsu58va6b{rngWF z#^*V-$0y%NOlyI07&niH-(5a%C;9G=CM(~imE2}#x2(^uxEvb-2;b#2ba!>|dk1Rc zcPWY~2jjev{^NzM-B&nznrRjubHNP)DV6U1{?r|W)vvK)W3W5D{8AEFiCqp!W6+zp z(~ZOSI)J zthxcU?;WN7mkMF^3U+PucXm||y#(&eDyQQMomT53wqf!$Wb^C9+k?4~Zl=sGVsSVs zev*85HYwGi?!LszKhkXE4xwcUWLk>SIbV=e~>nywV45$4;3@Sje@6WHg@~z6ycl+J(yYW0wD(-E-8+({s)WeKd zbmwrhnf`5)k=f#C)>g-~cX4Y3(|K@dJQxwHeNL3!B&?C6DIja)=C3VYog`ehc1cV= z+)XND1ekJW_HUMHQ|?|7K@gy`a(*eOo(rTJp>aqnw$;h~8gnrqNWpjaR!8nH2gPn( zVS?$J4H%BXrZ+fv76FRfkUR6k8reW9(1hV~q=>;Jz{xc|6s{g}MwOztbHE+{>{w8| zx^esq7Bb+D|FlVWUd&p~02oU#K%!x9PoUiNSB2Cy{#@JPp!}tAI%1|jMD_X<&PNO) z&rl=~+Dq>z2po8#lF(Ff-B#1hK0z3m$lCZ9TB=eE3$YAgXjG+rTZ=2%g zmq{S7vS@2*|1Nrl_QswnG9ePs{$*Cn#;losfzs(gEEZkJDkts@F-YSx?bgl`gG`GV}~^P0?b55}diND?bZ6oBhu5%F9= ze0)}~5wa0;YhhgbthXy_aWosSO1yeTEz2_uk5TvN>tr6iLDmSl^nY{70zApk^8o4%vzLda=T4VCT(f}ijztF{*Ry;KgX>wt2x;z8a(zQ4q5 zdS;EY@>4B#9*c;C=%VX|{TrBInNs7PKHEmff{m4m8QCH}kg{~;yJi-g=la>(JLm>7 z98yYO<=_|g)P^2HF9PW<$1$}-)Y<9I>&|c%{SN*ai)AkBJDJ7qc`88qKDd%%=$ZQ} zLywMl6W7D!5nS+4Dp-3<2#L}|4;K9Db+4EEys#W;^_^nKZDjRS|Jv*s*K|6WH{g=u zhICFGXMtG`C{u1OWQT2kZTRM%%Ft#6%d~PgoHUTIyX=m;*QPr+k}YysWTq9)Zg-x% zf`oS2oA+SgS$R6i=hQTleL7Z%t@c>z$4&*cu?IqKu$V^-+}cs>r$&KMkF=IEpP^f9G|QsrAdOUAIm;K(p8iFf><<>RgtU14ok+4V*iule(@8Q zVRC@QT_H?sT6w{PfQ1fXEuo z;F)&&mlxa3&!_67_2?a&@w)1~!KEOtp72X&?{$YZjrvp9pa_VEMUNs@+!V5iV10Vo zRqb}mpO9O*MK=H!zVgUdd)?um0d*!VKaUx2V0u~uj+T0rGKd}Y zej5p4fMhuY07613CQU=qe8!Lbc1qVeDQDh5f;hJjR<9<6yaMM~st=&tS7(nq^4#v~ zAPbH1^h(~yyqGuAf1%(xMwhG7T~Oj03SEI!dh3Ph@86G^D0(>6%##+ zjr^vIn$P+spOH-}iS(911 z3BYL^34O>N1IRmvEExQ^AJeh(SmR4#IZ*Ucs|7pMF|L^O%UrCJgoA~-Anmek-w`_s z`=6k*t(DnjH|Gtz6k&@s2v*hodH#hyjO_*q6*6Esk^;D(z_VgHE1(cm#aK|HuNB^T zGp#$K-O7taR))XA>IQ0KA0roXJ+15yQczqGsXj4-9x)97g~9^1W){doo^Lj@Pc@sl z7qg~QO_9a+R!78Jt41n>j z%`E#g>M2MqnY{=ix~?fglD42#BJ*oq^wx$&e{FgrWWKll0 zNm`*9+{=Ztgnu6$kE_ftI1vg+fZT{bNegI5oReG4iYQ>@XTBu+WV@l`eObCJS8Ofy zih^HaW^*IbY`C4v)Ympz(H|j&eS|)}k4lB2YlUR2evgj66PikL{K<&z<$m#IxsR;6MQq5d zVC(n8+L1qt{YS7(62^>d3RYPCaVRYy#&kn0S>AKIAdQJp6qLEl@M>wF+$?i`)0)ea zAQCNM^kFqU%qUVipy(V46hX5IE1C;RVT5aYBU?S-VF3u(;5Q$g_MpiHYieT4lpr;UY<9y*rK`Z*dC zBP7I|=(@i#v=q}?M*RYr_|)4hDXC#O^-VSSH`p2b7qhPWc{+YY$NQ9$Pss}wv9o-D zW6}lrb#e!vInrwVc+3vLc{>F>?A`7t()c_)9~}2uA@_#C3*#~`H~julb(sg1vg$nu zP1T}i6%Y+|ZajzZ@p=SsfR0gR8K>kW$E4+e<12+dJo=NG1Hc}$0)U1wFon#<(XEAk z+*K|JlEldJnmNw7MM#Qx!l{BY9ae&@HvBwL=O(WKI9Ep|<@I0s@WXg?V1pz80000 0 { + quotient = int(x / 16) + remainder := int(x - (float64(quotient) * 16)) + + if remainder > 9 { + result = append([]byte{byte(remainder + 55)}, result...) + } else { + for _, c := range strconv.Itoa(int(remainder)) { + result = append([]byte{byte(c)}, result...) + } + } + + x = float64(quotient) } - if status.Success == false { - return errors.WithMessage(errors.New(status.Reason), msg) + if fraction == 0 { + return string(result) } - return nil + result = append(result, '.') + + for fraction > 0 { + fraction = fraction * 16 + integer := int(fraction) + fraction = fraction - float64(integer) + + if integer > 9 { + result = append(result, byte(integer+55)) + } else { + for _, c := range strconv.Itoa(int(integer)) { + result = append(result, byte(c)) + } + } + } + + return string(result) } func genTransId() string { - source := rand.NewSource(time.Now().UnixNano()) - random := rand.New(source) - + random := rand.New(rand.NewSource(time.Now().UnixNano())) e := random.Float64() * math.Pow(2, 32) - ms := time.Now().UnixNano() / (int64(time.Millisecond) / int64(time.Nanosecond)) - return fmt.Sprintf("%s!%s!%s", TransIdPrefix, strings.ToLower(util.FloatToHex(e)), strconv.Itoa(int(ms))) + return fmt.Sprintf("%s!%s!%s", TransIdPrefix, strings.ToLower(FloatToHex(e)), strconv.Itoa(int(ms))) } -func (a *Arlo) get(uri, xCloudId string, header http.Header) (*request.Response, error) { - a.client.AddHeader("xcloudId", xCloudId) - return a.client.Get(uri, header) -} - -func (a *Arlo) put(uri, xCloudId string, body interface{}, header http.Header) (*request.Response, error) { - a.client.AddHeader("xcloudId", xCloudId) - return a.client.Put(uri, body, header) -} - -func (a *Arlo) post(uri, xCloudId string, body interface{}, header http.Header) (*request.Response, error) { - a.client.AddHeader("xcloudId", xCloudId) - return a.client.Post(uri, body, header) -} - -/* -func (a *Arlo) DownloadFile(url, to string) error { - msg := fmt.Sprintf("failed to download file (%s) => (%s)", url, to) - resp, err := a.get(url, "", nil) - if err != nil { - return errors.WithMessage(err, msg) - } - defer resp.Body.Close() - - f, err := os.Create(to) - if err != nil { - return errors.WithMessage(err, msg) - } - - _, err = io.Copy(f, resp.Body) - defer f.Close() - if err != nil { - return errors.WithMessage(err, msg) - } - - return nil -} -*/ - func (a *Arlo) DownloadFile(url string, w io.Writer) error { - msg := fmt.Sprintf("failed to download file (%s)", url) - resp, err := http.Get(url) if err != nil { - return errors.WithMessage(err, msg) + return fmt.Errorf("getting %s: %v", url, err) } - defer resp.Body.Close() - _, err = io.Copy(w, resp.Body) if err != nil { - return errors.WithMessage(err, msg) + return fmt.Errorf("copying body to writer: %v", err) } - return nil } - -func FromUnixMicro(µs int64) time.Time { return time.Unix(0, 1000*µs) } - -func FromUnixMilli(ms int64) time.Time { return time.Unix(0, 1000000*ms) }