From c9ca29b60ac3427c4769e75ac6f5179875bc708b Mon Sep 17 00:00:00 2001 From: Jeff Walter Date: Tue, 11 Dec 2018 17:48:51 -0600 Subject: [PATCH] Lots of changes... Fixed issue with timeout of event stream. Added new APIs. Cleaned up existing schemas and APIs. --- README.md | 8 +- arlo.go | 73 +++++++-- basestation.go | 12 +- camera.go | 305 ++++++++++++++++++++++++++++++++++--- const.go | 159 +++++++++++++++---- devices.go | 3 +- events_stream.go | 13 +- internal/request/client.go | 10 +- library.go | 107 ++++++++++++- lights.go | 19 +++ responses.go | 37 ++++- types.go | 47 ++++-- util.go | 43 +++++- 13 files changed, 730 insertions(+), 106 deletions(-) create mode 100644 lights.go diff --git a/README.md b/README.md index 3bc87c8..9383afa 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# arlo-golang +# arlo-go ![](gopher-arlo.png) > Go package for interacting with Netgear's Arlo camera system. @@ -33,7 +33,7 @@ Please read the [Issue Guidelines and Policies](https://github.com/jeffreydwalte ## Install ```bash # Install latest stable package -$ go get github.com/jeffreydwalter/arlo-golang +$ go get github.com/jeffreydwalter/arlo-go # Note: This package uses the `go dep` package for dependency management. If you plan on contributing to this package, you will be required to use [dep](https://github.com/golang/dep). Setting it up is outside the scope of this README, but if you want to contribute and aren't familiar with `dep`, I'm happy to get you. ``` @@ -52,7 +52,7 @@ import ( "sync" "time" - "github.com/jeffreydwalter/arlo-golang" + "github.com/jeffreydwalter/arlo-go" ) const ( @@ -122,4 +122,4 @@ func main() { } ``` -** (coming soon) For more code examples check out the [wiki](https://github.com/jeffreydwalter/arlo-golang/wiki)** +** (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 ff40ea0..b9dd95a 100644 --- a/arlo.go +++ b/arlo.go @@ -17,10 +17,12 @@ package arlo import ( + "fmt" "net/http" "sync" + "time" - "github.com/jeffreydwalter/arlo-golang/internal/request" + "github.com/jeffreydwalter/arlo-go/internal/request" "github.com/pkg/errors" ) @@ -57,7 +59,7 @@ 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(LoginUri, "", body, nil) + resp, err := arlo.post(LoginV2Uri, "", body, nil) if err != nil { return nil, errors.WithMessage(err, "failed to login") } @@ -91,31 +93,50 @@ func (a *Arlo) Logout() error { return checkRequest(resp, err, "failed to logout") } +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() + + var response SessionResponse + if err := resp.Decode(&response); err != nil { + return nil, err + } + + if response.Success == false { + return nil, errors.WithMessage(errors.New(response.Reason), msg) + } + 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(DevicesUri, "", nil) + 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() - var deviceResponse DeviceResponse - if err := resp.Decode(&deviceResponse); err != nil { + var response DeviceResponse + if err := resp.Decode(&response); err != nil { return nil, err } - if !deviceResponse.Success { + if !response.Success { return nil, errors.New("failed to get devices") } - if len(deviceResponse.Data) == 0 { + if len(response.Data) == 0 { return nil, errors.New("no devices found") } // Cache a pointer to the arlo object with each device. - for i := range deviceResponse.Data { - deviceResponse.Data[i].arlo = a + for i := range response.Data { + response.Data[i].arlo = a } // Disconnect all of the basestations from the EventStream. @@ -127,8 +148,8 @@ func (a *Arlo) GetDevices() (devices *Devices, err error) { a.rwmutex.Lock() // Cache the devices as their respective types. - a.Cameras = *deviceResponse.Data.GetCameras() - a.Basestations = *deviceResponse.Data.GetBasestations() + a.Cameras = *response.Data.GetCameras() + a.Basestations = *response.Data.GetBasestations() a.rwmutex.Unlock() // subscribe each basestation to the EventStream. @@ -138,25 +159,45 @@ func (a *Arlo) GetDevices() (devices *Devices, err error) { } } - return &deviceResponse.Data, nil + 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() + + var response UserProfileResponse + if err := resp.Decode(&response); err != nil { + return nil, err + } + + if !response.Success { + return nil, errors.New("failed to get user profile") + } + + 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(DeviceDisplayOrderUri, "", d, nil) + 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(UserProfileUri, "", body, nil) + 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(UserChangePasswordUri, "", body, nil) + resp, err := a.post(UpdatePasswordUri, "", body, nil) if err := checkRequest(resp, err, "failed to update password"); err != nil { return err } @@ -167,6 +208,6 @@ func (a *Arlo) UpdatePassword(pass string) error { } func (a *Arlo) UpdateFriends(f Friend) error { - resp, err := a.put(UserFriendsUri, "", f, nil) + resp, err := a.put(FriendsUri, "", f, nil) return checkRequest(resp, err, "failed to update friends") } diff --git a/basestation.go b/basestation.go index 2c0fac4..55617be 100644 --- a/basestation.go +++ b/basestation.go @@ -18,6 +18,7 @@ package arlo import ( "fmt" + "net/http" "time" "github.com/pkg/errors" @@ -53,7 +54,9 @@ func (b *Basestation) makeEventStreamRequest(payload EventStreamPayload, msg str payload.TransId = transId if err := b.IsConnected(); err != nil { - return nil, errors.WithMessage(err, msg) + //if err := b.Subscribe(); err != nil { + return nil, errors.WithMessage(errors.WithMessage(err, msg), "failed to reconnect to event stream") + //} } subscriber := make(subscriber) @@ -100,7 +103,7 @@ func (b *Basestation) IsConnected() error { } func (b *Basestation) Subscribe() error { - b.eventStream = newEventStream(BaseUrl+fmt.Sprintf(SubscribeUri, b.arlo.Account.Token), b.arlo.client.HttpClient) + b.eventStream = newEventStream(BaseUrl+fmt.Sprintf(NotifyResponsesPushServiceUri, b.arlo.Account.Token), &http.Client{Jar: b.arlo.client.HttpClient.Jar}) forLoop: for { @@ -137,6 +140,11 @@ forLoop: 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") +} + func (b *Basestation) Disconnect() error { // disconnect channel to stop event stream. if b.eventStream != nil { diff --git a/camera.go b/camera.go index a332997..f7fa291 100644 --- a/camera.go +++ b/camera.go @@ -19,6 +19,7 @@ package arlo import ( "fmt" "strings" + "time" "github.com/pkg/errors" ) @@ -225,7 +226,177 @@ func (c *Camera) DisableAudioAlerts(sensitivity int) (response *EventStreamRespo // 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 { - resp, err := c.arlo.get(fmt.Sprintf(PushToTalkUri, c.UniqueId), c.XCloudId, nil) + /* + 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") } @@ -262,10 +433,10 @@ func (c *Camera) SetAlertNotificationMethods(action string, email, push bool) (r } // StartStream returns a json object containing the rtmps url to the requested video stream. -// You will need the to install a library to handle streaming of this protocol: https://pypi.python.org/pypi/python-librtmp -// -// The request to /users/devices/startStream returns: -// NOTE: { "url":"rtsp://vzwow09-z2-prod.vz.netgear.com:80/vzmodulelive?egressToken=b1b4b675_ac03_4182_9844_043e02a44f71&userAgent=web&cameraId=48B4597VD8FF5_1473010750131" } +// 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", @@ -282,7 +453,7 @@ func (c *Camera) StartStream() (url string, err error) { msg := "failed to start stream" - resp, err := c.arlo.post(DeviceStartStreamUri, c.XCloudId, payload, nil) + resp, err := c.arlo.post(StartStreamUri, c.XCloudId, payload, nil) if err != nil { return "", errors.WithMessage(err, msg) } @@ -297,27 +468,79 @@ func (c *Camera) StartStream() (url string, err error) { return "", errors.WithMessage(errors.New("status was false"), msg) } - response.URL = strings.Replace(response.URL, "rtsp://", "rtsps://", 1) + response.Data.URL = strings.Replace(response.Data.URL, "rtsp://", "rtsps://", 1) - return response.URL, nil + return response.Data.URL, nil } -// TakeSnapshot causes the camera to record a snapshot. -func (c *Camera) TakeSnapshot() (url string, err error) { - msg := "failed to take snapshot" +// 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). - url, err = c.StartStream() - if err != nil { - return "", errors.WithMessage(err, msg) - } +// 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) { - body := map[string]string{"deviceId": c.DeviceId, "parentId": c.ParentId, "xcloudId": c.XCloudId, "olsonTimeZone": c.Properties.OlsonTimeZone} - resp, err := c.arlo.post(DeviceTakeSnapshotUri, c.XCloudId, body, nil) - if err := checkRequest(resp, err, "failed to update device name"); err != nil { - return "", errors.WithMessage(err, msg) - } + return nil, errors.New("TakeSnapshot not implemented") + /* + msg := "failed to take snapshot" - return url, nil + 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 @@ -331,10 +554,46 @@ func (c *Camera) StartRecording() (url string, err error) { } body := map[string]string{"deviceId": c.DeviceId, "parentId": c.ParentId, "xcloudId": c.XCloudId, "olsonTimeZone": c.Properties.OlsonTimeZone} - resp, err := c.arlo.post(DeviceStartRecordUri, c.XCloudId, body, nil) - if err := checkRequest(resp, err, "failed to update device name"); err != nil { + 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 +} diff --git a/const.go b/const.go index 5e64dfe..b55273e 100644 --- a/const.go +++ b/const.go @@ -17,35 +17,136 @@ package arlo const ( - BaseUrl = "https://arlo.netgear.com/hmsweb" - LoginUri = "/login/v2" - LogoutUri = "/logout" - SubscribeUri = "/client/subscribe?token=%s" - UnsubscribeUri = "/client/unsubscribe" - NotifyUri = "/users/devices/notify/%s" - ServiceLevelUri = "/users/serviceLevel" - OffersUri = "/users/payment/offers" - UserProfileUri = "/users/profile" - PushToTalkUri = "/users/devices/%s/pushtotalk" - UserChangePasswordUri = "/users/changePassword" - UserSessionUri = "/users/session" - UserFriendsUri = "/users/friends" - UserLocationsUri = "/users/locations" - UserLocationUri = "/users/locations/%s" - LibraryUri = "/users/library" - LibraryMetadataUri = "/users/library/metadata" - LibraryRecycleUri = "/users/library/recycle" - LibraryResetUri = "/users/library/reset" - DevicesUri = "/users/devices" - DeviceRenameUri = "/users/devices/renameDevice" - DeviceDisplayOrderUri = "/users/devices/displayOrder" - DeviceTakeSnapshotUri = "/users/devices/takeSnapshot" - DeviceStartRecordUri = "/users/devices/startRecord" - DeviceStopRecordUri = "/users/devices/stopRecord" - DeviceStartStreamUri = "/users/devices/startStream" - + DeviceTypeArloQ = "arloq" + DeviceTypeArloBridge = "arlobridge" DeviceTypeBasestation = "basestation" DeviceTypeCamera = "camera" - DeviceTypeArloQ = "arloq" - TransIdPrefix = "web" + DeviceTypeLights = "lights" + DeviceTypeSiren = "siren" + + TransIdPrefix = "web" + BaseUrl = "https://arlo.netgear.com/hmsweb" + + // TODO: Implement all of the following urls. There are many here I don't have devices for. :/ + ActiveAutomationUri = "/users/devices/automation/active" + AllMediaUri = "/users/music/all" + AnalyticFeedbackUri = "/users/library/%s/feedback" + AssignBetaPlanUri = "/users/assign/smartfeatures" + AssignDeviceToServicePlanUri = "/users/devices/provision" + AutomationDefinitionsUri = "/users/automation/definitions?uniqueIds=all" + AutomationModeUri = "/users/locations/%uniqueId/modes" + AutomationScheduleUri = "/users/locations/%uniqueId/schedules" + BuyUri = "http:/www.arlo.com/en-us/products/default.aspx?utm_source=app_desktop&p=all&cid=app" + CameraOrderUri = "/users/devices/v2/displayOrder" + CancelPlanUri = "/users/payment/plans/%paymentId/cancel" + CancelQuotationUri = "/users/payment/quotations/%paymentId/cancel/v1" + CapabilitiesUri = "/resources/capabilities/en/en_%t|i1000.json?t=%s" + ChangeMobileOffersUri = "/users/payment/offers/dataplans/change/v5" + ChangePlanUri = "/users/%paymentId/changeplan" + CheckAccountUri = "/checkAccountUsage" + CheckEmailUri = "/checkEmailUsage" + ClaimDeviceUri = "/users/devices/claimDevice" + CommunityUri = "http:/www.arlo.com/community?utm_source=app_desktop&locale=en" + ConfirmUserIdUri = "/users/resend/confirm/email" + CountryCodesUri = "/static/countrycodes" + CreatePaymentAccountUri = "/users/payment/accounts" + CreatePlanUri = "/users/payment/plans/%paymentId" + DeleteAccountUri = "/users/closeAccount" + DeviceFeaturesUri = "/users/devices/updateFeatures/feature/%uniqueId" + DeviceProvisioningUri = "/users/devices/states" + DeviceStatisticsUri = "/users/devices/%uniqueId/data/statistics?t=%s" + DeviceSupportUri = "/devicesupport" + DeviceSupportV2Uri = "/devicesupport/v2" + DeviceUri = "/users/devices/%deviceId" + DeviceZoneUri = "/users/devices/%uniqueId/activityzones/%zoneId" + DeviceZonesUri = "/users/devices/%uniqueId/activityzones" + DevicesUpdateFeaturesUri = "/users/devices/updateFeatures/feature" + DevicesUri = "/users/devices/?t=%s" + DonateRecordUri = "/users/library/%uniqueId/donate" + EditAutomationModeUri = "/users/locations/%uniqueId/modes/%mode/devices/%i" + EditUri = "/users/media" + EmergencyCallDetailsUri = "/users/emergency/%emergencyId/call" + EmergencyLocationSaveUri = "/users/emergency/locations/%emergencyId" + EmergencyLocationsUri = "/users/emergency/locations" + EventsUri = "/users/devices/%uniqueId/events?fromDate=%s&toDate=%s" + FavoriteUri = "/users/library/favorite" + FieldLengthsUri = "/static/fieldLengths" + FriendsDeleteUri = "/users/friends/remove" + FriendsUri = "/users/friends" + FullFrameSnapshotUri = "/users/devices/fullFrameSnapshot" + GPLUri = "https:/vzs3-prod-common.s3.amazonaws.com/license/GPLv1.html" + HtmlChangeOffersUri = "/users/payment/offers/html/v5/change" + HtmlOffersUri = "/users/payment/offers/html/v5" + HtmlPrelimQuotationUri = "/users/payment/offers/quotation/html/v5" + HtmlQuotationUri = "/users/payment/confirmation/%paymentId" + LibFeedbackUri = "/library/feedback" + LibraryStateUri = "/users/library/state/v1" + LocateDevicesUri = "/locateDevice?discoveryToken=%s" + LocationByZipUri = "/users/payment/postalcodelookup" + LocationUri = "/users/locations" + LoginUri = "/login" + LoginV2Uri = "/login/v2" + LogoutUri = "/logout" + MetadataUri = "/users/library/metadata/v2" + MigrateZonesUri = "/users/devices/%uniqueId/activityzones/migrate" + MobileOffersUri = "/users/payment/offers/dataplans/v5" + ModifyBillingUri = "/users/payment/billing/%paymentId" + NotifyResponsesPushServiceUri = "/client/subscribe?token=%s" + NotifyUri = "/users/devices/notify/%s" + OffersDetailsUri = "/users/payment/offersdetail" + OffersDvrChangeUri = "/users/payment/offers/arloq/html/v5/change" + OffersDvrUri = "/users/payment/offers/arloq/html/v5" + OffersUri = "/users/payment/offers/v3" + PaymentBillingUri = "/users/payment/billing/%paymentId" + PaymentRenewUri = "/users/payment/autoRenew/%paymentId" + PaymentTermsLinkUri = "/paymentTermsAndConditions?t=%s" + PlacemeterUri = "" + PlaylistMetaUri = "/users/devices/%uniqueId/playlist/metadata" + PlaylistUri = "/users/devices/%s/playlist?fromDate=%s&toDate=%s" + PolicyUri = "/policy/v1/?t=%s" + PreferencesUri = "/users/preferences" + ProfileUri = "/users/profile" + PttNotifyUri = "/users/devices/notify/%parentId" + PttUri = "/users/devices/%s/pushtotalk" + RMAValidationUri = "/users/devices/%restrictedDevice/apvalidation" + RecordingsUri = "/users/library" + RecycleUri = "/users/library/recycle" + RegisterUserUri = "/register" + RemoveDeviceUri = "/users/devices/v2/removeDevice" + RenameDeviceUri = "/users/devices/v2/renameDevice" + RenewPlanUri = "/users/payment/plans/%paymentId/renew" + RenewQuotationUri = "/users/payment/quotations/%?/renew" + RequestPasswordResetUri = "/requestPasswordReset" + ResetCountUri = "/users/library/reset?uniqueId=%s" + ResetPasswordUri = "/resetPassword" + RestartDeviceUri = "/users/devices/restart" + SSORegisterUri = "/ssoregister" + SecretQuestionsUri = "/static/secretquestions" + ServicePlanUri = "/users/serviceLevel/v3" + SessionUri = "/users/session" + SetAutomationModeUri = "/users/locations/%uniqueId/modes/%mode" + ShareUri = "/users/library/share" + SmartAlertsUri = "/users/devices/%uniqueId/smartalerts" + SmartConfigUri = "/user/smarthome/config" + StartRecordUri = "/users/devices/startRecord" + StartStreamUri = "/users/devices/startStream" + StatesCodesUri = "/static/usstatescodes" + StopRecordUri = "/users/devices/stopRecord" + StopStreamUri = "/users/devices/stopStream" + StorageQuotaUri = "/users/quota" + SupportUri = "http:/www.arlo.com/support?utm_source=app_desktop&cc=en" + TakeSnapshotUri = "/users/devices/takeSnapshot" + TempUnitUri = "/users/devices/%uniqueId/tempUnit" + TermsLinkUri = "/termsAndConditionsLink?t=%s" + TermsUri = "/termsAndConditions/?t=%s" + TimeZonesUri = "/static/timezones" + UnsubscribeUri = "/client/unsubscribe" + UpdateNameUri = "/user" + UpdatePasswordUri = "/users/changePassword" + UpdateUserIdUri = "/users/changeEmail" + UserFrameSnapshotUri = "/users/devices/userSnapshot" + UsersEmailsUri = "/users/emails" + ValidateCouponUri = "/users/payment/coupondetails" + ValidateResetUri = "/validatePasswordReset/%?" + WakeupUri = "/users/devices/wakeup/%deviceId?t=%s" ) diff --git a/devices.go b/devices.go index 8d0c862..84bf996 100644 --- a/devices.go +++ b/devices.go @@ -135,8 +135,7 @@ func (ds Devices) GetCameras() *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(DeviceRenameUri, d.XCloudId, body, nil) + 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 e946236..7f60814 100644 --- a/events_stream.go +++ b/events_stream.go @@ -75,10 +75,13 @@ func (e *eventStream) disconnect() { } func (e *eventStream) listen() (connected chan bool) { - connected = make(chan bool) 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 @@ -87,13 +90,14 @@ func (e *eventStream) listen() (connected chan bool) { for { select { case event := <-e.Events: + //fmt.Println("Got event message.") /* - fmt.Println("Got event message.") + fmt.Print(".") fmt.Printf("EVENT: %s\n", event.Event) - fmt.Printf("DATA: %s\n", event.URL) + fmt.Printf("DATA: %s\n", event.Data) */ - if event.Data != nil { + if event != nil && event.Data != nil { notifyResponse := &EventStreamResponse{} b := bytes.NewBuffer(event.Data) err := json.NewDecoder(b).Decode(notifyResponse) @@ -102,6 +106,7 @@ func (e *eventStream) listen() (connected chan bool) { 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" { diff --git a/internal/request/client.go b/internal/request/client.go index 9d1882d..986e1f6 100644 --- a/internal/request/client.go +++ b/internal/request/client.go @@ -20,11 +20,11 @@ import ( "bytes" "encoding/json" "io" - "log" "net/http" "net/http/cookiejar" "net/url" "sync" + "time" "github.com/pkg/errors" @@ -61,7 +61,7 @@ func NewClient(baseURL string, baseHeaders http.Header) (*Client, error) { return &Client{ BaseURL: u, BaseHeaders: &header, - HttpClient: &http.Client{Jar: jar}, + HttpClient: &http.Client{Jar: jar, Timeout: 30 * time.Second}, }, nil } @@ -105,7 +105,7 @@ func (c *Client) newRequest(method string, uri string, body interface{}, header return nil, errors.Wrap(err, "failed to create request object") } } - log.Printf("\n\nBODY (%s): %s\n\n", uri, buf) + // log.Printf("\n\nBODY (%s): %s\n\n", uri, buf) u := c.BaseURL.String() + uri req, err := http.NewRequest(method, u, buf) @@ -145,8 +145,8 @@ func (c *Client) newResponse(resp *http.Response) (*Response, error) { 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) + // 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 { diff --git a/library.go b/library.go index b31b190..2470dec 100644 --- a/library.go +++ b/library.go @@ -57,7 +57,7 @@ func (a *Arlo) GetLibraryMetaData(fromDate, toDate time.Time) (libraryMetaData * msg := "failed to get library metadata" body := map[string]string{"dateFrom": fromDate.Format("20060102"), "dateTo": toDate.Format("20060102")} - resp, err := a.post(LibraryMetadataUri, "", body, nil) + resp, err := a.post(MetadataUri, "", body, nil) if err != nil { return nil, errors.WithMessage(err, msg) } @@ -79,7 +79,7 @@ func (a *Arlo) GetLibrary(fromDate, toDate time.Time) (library *Library, err err msg := "failed to get library" body := map[string]string{"dateFrom": fromDate.Format("20060102"), "dateTo": toDate.Format("20060102")} - resp, err := a.post(LibraryUri, "", body, nil) + resp, err := a.post(RecordingsUri, "", body, nil) if err != nil { return nil, errors.WithMessage(err, msg) } @@ -105,9 +105,8 @@ func (a *Arlo) GetLibrary(fromDate, toDate time.Time) (library *Library, err err 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(LibraryRecycleUri, "", body, nil) + resp, err := a.post(RecycleUri, "", body, nil) return checkRequest(resp, err, "failed to delete recording") } @@ -119,8 +118,104 @@ func (a *Arlo) DeleteRecording(r *Recording) error { 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(LibraryRecycleUri, "", body, nil) + 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 + }) + }, +*/ diff --git a/lights.go b/lights.go new file mode 100644 index 0000000..8a7df37 --- /dev/null +++ b/lights.go @@ -0,0 +1,19 @@ +/* + * 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 + +// TODO: Add support for "lights" type of devices. diff --git a/responses.go b/responses.go index a4458d6..9aa8468 100644 --- a/responses.go +++ b/responses.go @@ -16,12 +16,36 @@ package arlo +// URL is part of the Status message fragment returned by most calls to the Arlo API. +// URL is only populated when Success is false. +type Data struct { + Message string `json:"message,omitempty"` + Reason string `json:"reason,omitempty"` + Error string `json:"error,omitempty"` +} + +// Status is the message fragment returned from most http calls to the Arlo API. +type Status struct { + Data `json:"URL,omitempty"` + Success bool `json:"success"` +} + // LoginResponse is an intermediate struct used when parsing data from the Login() call. type LoginResponse struct { Data Account Status } +type SessionResponse struct { + Data Session + Status +} + +type UserProfileResponse struct { + Data UserProfile + Status +} + // DeviceResponse is an intermediate struct used when parsing data from the GetDevices() call. type DeviceResponse struct { Data Devices @@ -39,13 +63,22 @@ type LibraryResponse struct { Status } -type StreamResponse struct { +type CvrPlaylistResponse struct { + Data CvrPlaylist + Status +} + +type Stream struct { URL string `json:"url"` +} + +type StreamResponse struct { + Data Stream Status } type RecordingResponse struct { - URL string `json:"url"` + Data Stream Status } diff --git a/types.go b/types.go index ac2b5cd..3335799 100644 --- a/types.go +++ b/types.go @@ -59,6 +59,29 @@ type Account struct { DateCreated int64 `json:"dateCreated"` } +type AppStore struct { + Enforce bool `json:"enforce"` + LatestVersion string `json:"latestVersion"` + updateLink string `json:"updateLink"` +} + +type Session struct { + AccountStatus string `json:"accountStatus"` + + AppStore `json:"appStore"` + Account +} + +type UserProfile struct { + Type string `json:"_type"` + AcceptedPolicy int `json:"acceptedPolicy"` + Country string `json:"country"` + CurrentPolicy int `json:"currentPolicy"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + ValidEmail bool `json:"validEmail"` +} + // Friend is the account data for non-primary account holders designated as friends. type Friend struct { FirstName string `json:"firstName"` @@ -252,16 +275,20 @@ type EventStreamPayload struct { To string `json:"to"` } -// URL is part of the Status message fragment returned by most calls to the Arlo API. -// URL is only populated when Success is false. -type Data struct { - Message string `json:"message,omitempty"` - Reason string `json:"reason,omitempty"` - Error string `json:"error,omitempty"` +type PlaylistItem struct { + TZ string `json:"tz"` + D string `json:"d"` + URL string `json:"url"` + SURL string `json:"sUrl"` + S string `json:"s"` + U int64 `json:"u"` } -// Status is the message fragment returned from most http calls to the Arlo API. -type Status struct { - Data `json:"URL,omitempty"` - Success bool `json:"success"` +type CvrPlaylist struct { + ToDate string `json:"toDate"` + Playlist map[string]map[string][]PlaylistItem `json:"playlist"` + UserId string `json:"userId"` + FromDate string `json:"fromDate"` + DeviceId string `json:"deviceId"` + UniqueId string `json:"uniqueId"` } diff --git a/util.go b/util.go index 27079b6..d5b167f 100644 --- a/util.go +++ b/util.go @@ -22,13 +22,12 @@ import ( "math" "math/rand" "net/http" - "os" "strconv" "strings" "time" - "github.com/jeffreydwalter/arlo-golang/internal/request" - "github.com/jeffreydwalter/arlo-golang/internal/util" + "github.com/jeffreydwalter/arlo-go/internal/request" + "github.com/jeffreydwalter/arlo-go/internal/util" "github.com/pkg/errors" ) @@ -78,6 +77,7 @@ func (a *Arlo) post(uri, xCloudId string, body interface{}, header http.Header) 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) @@ -99,3 +99,40 @@ func (a *Arlo) DownloadFile(url, to string) error { return nil } +*/ + +func (a *Arlo) DownloadFile(url string, w io.Writer) error { + msg := fmt.Sprintf("failed to download file (%s)", url) + resp, err := a.get(url, "", nil) + if err != nil { + return errors.WithMessage(err, msg) + } + defer resp.Body.Close() + + _, err = io.Copy(w, resp.Body) + if err != nil { + return errors.WithMessage(err, msg) + } + + return nil +} + +func UnixMicro(t time.Time) int64 { + ns := t.UnixNano() + if ns < 0 { + return (ns - 999) / 1000 + } + return ns / 1000 +} + +func UnixMilli(t time.Time) int64 { + ns := t.UnixNano() + if ns < 0 { + return (ns - 999999) / 1000000 + } + return ns / 1000000 +} + +func FromUnixMicro(µs int64) time.Time { return time.Unix(0, 1000*µs) } + +func FromUnixMilli(ms int64) time.Time { return time.Unix(0, 1000000*ms) }