Lots of changes... Fixed issue with timeout of event stream. Added new APIs. Cleaned up existing schemas and APIs.

This commit is contained in:
Jeff Walter 2018-12-11 17:48:51 -06:00
parent c29dd1066d
commit c9ca29b60a
13 changed files with 730 additions and 106 deletions

View File

@ -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)**

73
arlo.go
View File

@ -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")
}

View File

@ -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 {

305
camera.go
View File

@ -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
}

159
const.go
View File

@ -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"
)

View File

@ -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")
}

View File

@ -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" {

View File

@ -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 {

View File

@ -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
})
},
*/

19
lights.go Normal file
View File

@ -0,0 +1,19 @@
/*
* Copyright (c) 2018 Jeffrey Walter <jeffreydwalter@gmail.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
* documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
* Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
* WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package arlo
// TODO: Add support for "lights" type of devices.

View File

@ -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
}

View File

@ -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"`
}

43
util.go
View File

@ -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) }