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 7866548..0000000 Binary files a/gopher-arlo.png and /dev/null differ diff --git a/internal/request/client.go b/internal/request/client.go deleted file mode 100644 index 986e1f6..0000000 --- a/internal/request/client.go +++ /dev/null @@ -1,162 +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 ( - "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 d33a195..0000000 Binary files a/logo.png and /dev/null differ diff --git a/responses.go b/responses.go index 9aa8468..1c8d923 100644 --- a/responses.go +++ b/responses.go @@ -84,5 +84,6 @@ type RecordingResponse struct { type EventStreamResponse struct { EventStreamPayload - Status string `json:"status,omitempty"` + Status string `json:"status,omitempty"` + RawProperties []byte } diff --git a/types.go b/types.go index c885c2b..5231e02 100644 --- a/types.go +++ b/types.go @@ -251,14 +251,6 @@ type SirenProperties struct { Pattern string `json:"pattern"` } -type BasestationModeProperties struct { - Active string `json:"active"` -} - -type BasestationScheduleProperties struct { - Active bool `json:"active"` -} - type CameraProperties struct { PrivacyActive bool `json:"privacyActive"` Brightness int `json:"brightness,omitempty"` diff --git a/util.go b/util.go index a8fdb0d..81890fa 100644 --- a/util.go +++ b/util.go @@ -25,100 +25,69 @@ import ( "strconv" "strings" "time" - - "github.com/jeffreydwalter/arlo-go/internal/request" - "github.com/jeffreydwalter/arlo-go/internal/util" - - "github.com/pkg/errors" ) -func checkRequest(resp *request.Response, err error, msg string) error { - if err != nil { - return errors.WithMessage(err, msg) - } - defer resp.Body.Close() +func FloatToHex(x float64) string { + var result []byte + quotient := int(x) + fraction := x - float64(quotient) - var status Status - if err := resp.Decode(&status); err != nil { - return err + 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 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) }