From 50bc5bf56ea06635a76a148655181c7e3938ce86 Mon Sep 17 00:00:00 2001 From: Jeff Walter Date: Thu, 30 Nov 2017 16:50:40 -0600 Subject: [PATCH] (WIP) Arlo client written in Go. --- Gopkg.lock | 21 +++ Gopkg.toml | 30 ++++ arloclient.go | 288 ++++++++++++++++++++++++++++++++++++ arloclient_test.go | 1 + const.go | 29 ++++ internal/request/request.go | 189 +++++++++++++++++++++++ types.go | 115 ++++++++++++++ 7 files changed, 673 insertions(+) create mode 100644 Gopkg.lock create mode 100644 Gopkg.toml create mode 100644 arloclient.go create mode 100644 arloclient_test.go create mode 100644 const.go create mode 100644 internal/request/request.go create mode 100644 types.go diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..755de40 --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,21 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + branch = "master" + name = "github.com/mitchellh/mapstructure" + packages = ["."] + revision = "06020f85339e21b2478f756a78e295255ffa4d6a" + +[[projects]] + name = "github.com/pkg/errors" + packages = ["."] + revision = "645ef00459ed84a119197bfb8d8205042c6df63d" + version = "v0.8.0" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "f9932cc0e893e6f71176657aa0d2f567ec244ca71e930f3d95235e1d8b349a1c" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..acda3d6 --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,30 @@ + +# Gopkg.toml example +# +# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" + + +[[constraint]] + branch = "master" + name = "github.com/mitchellh/mapstructure" + +[[constraint]] + name = "github.com/pkg/errors" + version = "0.8.0" diff --git a/arloclient.go b/arloclient.go new file mode 100644 index 0000000..bfbd753 --- /dev/null +++ b/arloclient.go @@ -0,0 +1,288 @@ +package arloclient + +import ( + "log" + "time" + + "github.com/jeffreydwalter/arloclient/internal/request" + + "github.com/mitchellh/mapstructure" + "github.com/pkg/errors" +) + +type Arlo struct { + user string + pass string + client *request.Client + account Account +} + +func NewArlo(user string, pass string) (*Arlo, error) { + + c, _ := request.NewClient(BaseUrl) + arlo := &Arlo{ + user: user, + pass: pass, + client: c, + } + + if _, err := arlo.Login(); err != nil { + return nil, errors.WithMessage(err, "failed to create arlo object") + } + + return arlo, nil +} + +func (a *Arlo) Login() (*Account, error) { + + resp, err := a.client.Post(LoginUri, Credentials{Email: a.user, Password: a.pass}, nil) + if err != nil { + return nil, errors.WithMessage(err, "failed to login") + } + + var loginResponse LoginResponse + if err := mapstructure.Decode(resp.Data, &loginResponse); err != nil { + return nil, errors.Wrap(err, "failed to create loginresponse object") + } + + if !loginResponse.Success { + return nil, errors.New("request was unsuccessful") + } + + // Cache the auth token. + a.client.BaseHttpHeader.Add("Authorization", loginResponse.Data.Token) + + // Save the account info with the Arlo struct. + a.account = loginResponse.Data + + return &loginResponse.Data, nil +} + +func (a *Arlo) Logout() (*request.Response, error) { + + return a.client.Put(LogoutUri, nil, nil) +} + +func (a *Arlo) GetDevices() (*Devices, error) { + + resp, err := a.client.Get(DevicesUri, nil) + + if err != nil { + return nil, errors.WithMessage(err, "failed to get devices") + } + + var deviceResponse DeviceResponse + if err := mapstructure.Decode(resp.Data, &deviceResponse); err != nil { + return nil, errors.Wrap(err, "failed to create deviceresponse object") + } + + if !deviceResponse.Success { + return nil, errors.New("request was unsuccessful") + } + + return &deviceResponse.Data, nil +} + +func (a *Arlo) GetLibraryMetaData(fromDate, toDate time.Time) (*LibraryMetaData, error) { + + resp, err := a.client.Post(LibraryMetadataUri, Duration{fromDate.Format("20060102"), toDate.Format("20060102")}, nil) + + if err != nil { + return nil, errors.WithMessage(err, "failed to get library metadata") + } + + log.Printf("GETLIBRARYMETADATA: %v", resp.Data) + + var libraryMetaDataResponse LibraryMetaDataResponse + if err := mapstructure.Decode(resp.Data, &libraryMetaDataResponse); err != nil { + return nil, errors.WithMessage(err, "failed to create librarymetadataresponse object") + } + + if !libraryMetaDataResponse.Success { + return nil, errors.New("request was unsuccessful") + } + + return &libraryMetaDataResponse.Data, nil +} + +func (a *Arlo) UpdateProfile(firstName, lastName string) (*UserProfile, error) { + + resp, err := a.client.Put(UserProfileUri, FullName{firstName, lastName}, nil) + + if err != nil { + return nil, err + } + + var userProfileResponse UserProfileResponse + if err := mapstructure.Decode(resp.Data, &userProfileResponse); err != nil { + return nil, err + } + + if !userProfileResponse.Success { + return nil, err + } + + return &userProfileResponse.Data, nil +} + +func (a *Arlo) UpdatePassword(password string) error { + + _, err := a.client.Post(UserChangePasswordUri, PasswordPair{a.pass, password}, nil) + if err != nil { + a.pass = password + } + return err +} + +/* +## +# This is an example of the json you would pass in the body to UpdateFriends(): +#{ +# "firstName":"Some", +# "lastName":"Body", +# "devices":{ +# "XXXXXXXXXXXXX":"Camera 1", +# "XXXXXXXXXXXXX":"Camera 2 ", +# "XXXXXXXXXXXXX":"Camera 3" +# }, +# "lastModified":1463977440911, +# "adminUser":true, +# "email":"user@example.com", +# "id":"XXX-XXXXXXX" +#} +## +func (a *Arlo) UpdateFriends(body): +return a.client.Put('https://arlo.netgear.com/hmsweb/users/friends', body) + +func (a *Arlo) UpdateDeviceName(device, name): +return a.client.Put('https://arlo.netgear.com/hmsweb/users/devices/renameDevice', {'deviceId':device.get('deviceId'), 'deviceName':name, 'parentId':device.get('parentId')}) + +## +# This is an example of the json you would pass in the body to UpdateDisplayOrder() of your devices in the UI. +# +# XXXXXXXXXXXXX is the device id of each camera. You can get this from GetDevices(). +#{ +# "devices":{ +# "XXXXXXXXXXXXX":1, +# "XXXXXXXXXXXXX":2, +# "XXXXXXXXXXXXX":3 +# } +#} +## +func (a *Arlo) UpdateDisplayOrder(body): +return a.client.Post('https://arlo.netgear.com/hmsweb/users/devices/displayOrder', body) + +## +# This call returns the following: +# presignedContentUrl is a link to the actual video in Amazon AWS. +# presignedThumbnailUrl is a link to the thumbnail .jpg of the actual video in Amazon AWS. +# +#[ +# { +# "mediaDurationSecond": 30, +# "contentType": "video/mp4", +# "name": "XXXXXXXXXXXXX", +# "presignedContentUrl": "https://arlos3-prod-z2.s3.amazonaws.com/XXXXXXX_XXXX_XXXX_XXXX_XXXXXXXXXXXXX/XXX-XXXXXXX/XXXXXXXXXXXXX/recordings/XXXXXXXXXXXXX.mp4?AWSAccessKeyId=XXXXXXXXXXXXXXXXXXXX&Expires=1472968703&Signature=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", +# "lastModified": 1472881430181, +# "localCreatedDate": XXXXXXXXXXXXX, +# "presignedThumbnailUrl": "https://arlos3-prod-z2.s3.amazonaws.com/XXXXXXX_XXXX_XXXX_XXXX_XXXXXXXXXXXXX/XXX-XXXXXXX/XXXXXXXXXXXXX/recordings/XXXXXXXXXXXXX_thumb.jpg?AWSAccessKeyId=XXXXXXXXXXXXXXXXXXXX&Expires=1472968703&Signature=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", +# "reason": "motionRecord", +# "deviceId": "XXXXXXXXXXXXX", +# "createdBy": "XXXXXXXXXXXXX", +# "createdDate": "20160903", +# "timeZone": "America/Chicago", +# "ownerId": "XXX-XXXXXXX", +# "utcCreatedDate": XXXXXXXXXXXXX, +# "currentState": "new", +# "mediaDuration": "00:00:30" +# } +#] +## +func (a *Arlo) GetLibrary(from_date, to_date): +return a.client.Post('https://arlo.netgear.com/hmsweb/users/library', {'dateFrom':from_date, 'dateTo':to_date}) + +## +# 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. +# +## +func (a *Arlo) DeleteRecording(camera, created_date, utc_created_date): +return a.client.Post('https://arlo.netgear.com/hmsweb/users/library/recycle', {'data':[{'createdDate':created_date,'utcCreatedDate':utc_created_date,'deviceId':camera.get('deviceId')}]}) + +## +# 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. +# If you want to delete some other batch of videos, then you need to send an array of objects representing each video you want to delete. +# +#[ +# { +# "createdDate":"20160904", +# "utcCreatedDate":1473010280395, +# "deviceId":"XXXXXXXXXXXXX" +# }, +# { +# "createdDate":"20160904", +# "utcCreatedDate":1473010280395, +# "deviceId":"XXXXXXXXXXXXX" +# } +#] +## +func (a *Arlo) BatchDeleteRecordings(recording_metadata): +return a.client.Post('https://arlo.netgear.com/hmsweb/users/library/recycle', {'data':recording_metadata}) + +## +# Returns the whole video from the presignedContentUrl. +# +# Obviously, this function is generic and could be used to download anything. :) +## +func (a *Arlo) GetRecording(url, chunk_size=4096): +video = '' +r = requests.get(url, stream=True) +r.raise_for_status() + +for chunk in r.iter_content(chunk_size): +if chunk: video += chunk +return video + + +## +# This function 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: +#{ "url":"rtmps://vzwow09-z2-prod.vz.netgear.com:80/vzmodulelive?egressToken=b1b4b675_ac03_4182_9844_043e02a44f71&userAgent=web&cameraId=48B4597VD8FF5_1473010750131" } +# +## +func (a *Arlo) StartStream(camera): +return a.client.Post('https://arlo.netgear.com/hmsweb/users/devices/startStream', {"to":camera.get('parentId'),"from":self.user_id+"_web","resource":"cameras/"+camera.get('deviceId'),"action":"set","publishResponse":True,"transId":self.genTransId(),"properties":{"activityState":"startUserStream","cameraId":camera.get('deviceId')}}, headers={"xcloudId":camera.get('xCloudId')}) + +## +# This function causes the camera to record a snapshot. +# +# You can get the timezone from GetDevices(). +## +func (a *Arlo) TakeSnapshot(camera): +stream_url = self.StartStream(camera) +a.client.Post('https://arlo.netgear.com/hmsweb/users/devices/takeSnapshot', {'xcloudId':camera.get('xCloudId'),'parentId':camera.get('parentId'),'deviceId':camera.get('deviceId'),'olsonTimeZone':camera.get('properties', {}).get('olsonTimeZone')}, headers={"xcloudId":camera.get('xCloudId')}) +return stream_url; + +## +# This function causes the camera to start recording. +# +# You can get the timezone from GetDevices(). +## +func (a *Arlo) StartRecording(camera): +stream_url = self.StartStream(camera) +a.client.Post('https://arlo.netgear.com/hmsweb/users/devices/startRecord', {'xcloudId':camera.get('xCloudId'),'parentId':camera.get('parentId'),'deviceId':camera.get('deviceId'),'olsonTimeZone':camera.get('properties', {}).get('olsonTimeZone')}, headers={"xcloudId":camera.get('xCloudId')}) +return stream_url + +## +# This function causes the camera to stop recording. +# +# You can get the timezone from GetDevices(). +## +func (a *Arlo) StopRecording(camera): +return a.client.Post('https://arlo.netgear.com/hmsweb/users/devices/stopRecord', {'xcloudId':camera.get('xCloudId'),'parentId':camera.get('parentId'),'deviceId':camera.get('deviceId'),'olsonTimeZone':camera.get('properties', {}).get('olsonTimeZone')}, headers={"xcloudId":camera.get('xCloudId')}) +*/ diff --git a/arloclient_test.go b/arloclient_test.go new file mode 100644 index 0000000..fac0d50 --- /dev/null +++ b/arloclient_test.go @@ -0,0 +1 @@ +package arloclient diff --git a/const.go b/const.go new file mode 100644 index 0000000..26ffff9 --- /dev/null +++ b/const.go @@ -0,0 +1,29 @@ +package arloclient + +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" + ResetUri = "/users/library/reset" + ServiceLevelUri = "/users/serviceLevel" + OffersUri = "/users/payment/offers" + UserProfileUri = "/users/profile" + UserChangePasswordUri = "/users/changePassword" + UserSessionUri = "/users/session" + UserFriendsUri = "/users/friends" + UserLocationsUri = "/users/locations" + UserLocationUri = "/users/locations/%s" + LibraryUri = "/users/library" + LibraryRecycleUri = "/users/library/recycle" + LibraryMetadataUri = "/users/library/metadata" + 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" +) diff --git a/internal/request/request.go b/internal/request/request.go new file mode 100644 index 0000000..ae1485e --- /dev/null +++ b/internal/request/request.go @@ -0,0 +1,189 @@ +package request + +import ( + "bytes" + "encoding/json" + "io" + "io/ioutil" + "log" + "mime" + "net/http" + "net/http/cookiejar" + "net/url" + + "github.com/pkg/errors" +) + +type Client struct { + BaseURL *url.URL + BaseHttpHeader http.Header + httpClient http.Client +} + +type Request struct { + http.Request +} +type Response struct { + http.Response + Data interface{} +} + +func NewClient(baseurl string) (*Client, error) { + var err error + var jar *cookiejar.Jar + + options := cookiejar.Options{} + + 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.Add("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36") + header.Add("Content-Type", "application/json") + header.Add("Accept", "application/json") + + return &Client{ + BaseURL: u, + BaseHttpHeader: header, + httpClient: http.Client{Jar: jar}, + }, nil +} + +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 GetContentType(ct string) (string, error) { + mediaType, _, err := mime.ParseMediaType(ct) + + if err != nil { + return "", errors.Wrap(err, "failed to get content type") + } + return mediaType, nil +} + +/* +func (resp *Response) Parse(schema interface{}) (interface{}, error){ + mediatype, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return nil, err + } + log.Printf("CONTENT TYPE %s\n", mediatype) + + switch mediatype { + case "application/json": + log.Println("DECODING JSON: %s", json.Valid(resp.Data)) + if err := json.Unmarshal(resp.Data, schema); err != nil { + log.Println("GOT AN ERROR") + return nil, err + } + } + + return schema, nil +} +*/ + +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("JSON: %v", 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") + } + + for k, v := range c.BaseHttpHeader { + for _, h := range v { + //fmt.Printf("Adding header (%s): (%s - %s)\n\n", u, k, h) + req.Header.Add(k, h) + } + } + + for k, v := range header { + for _, h := range v { + //fmt.Printf("Adding header (%s): (%s - %s)\n\n", u, k, h) + req.Header.Add(k, h) + } + } + + return &Request{ + Request: *req, + }, nil +} + +func (c *Client) newResponse(resp *http.Response) (*Response, error) { + + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // log.Printf("DATA: %v", string(data)) + + var d interface{} + mediaType, err := GetContentType(resp.Header.Get("Content-Type")) + if err != nil { + return nil, errors.WithMessage(err, "failed to create response object") + } + + switch mediaType { + case "application/json": + err = json.Unmarshal([]byte(data), &d) + if err != nil { + return nil, errors.Wrap(err, "failed to create response object") + } + } + + return &Response{ + Response: *resp, + Data: d, + }, nil +} + +func (c *Client) do(req *Request) (*Response, error) { + + //fmt.Printf("\n\nCOOKIES (%s): %v\n\n", req.URL, c.httpClient.Jar.Cookies(req.URL)) + //fmt.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") + } + defer resp.Body.Close() + + return c.newResponse(resp) +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..a789cc9 --- /dev/null +++ b/types.go @@ -0,0 +1,115 @@ +package arloclient + +// Credentials is the login credential data. +type Credentials struct { + Email string `json:"email"` + Password string `json:"password"` +} + +// Duration holds two dates used when you need to specify a date range in the format "20060102". +type Duration struct { + DateFrom string `json:"dateFrom""` + DateTo string `json:"dateTo"` +} + +// PasswordPair is used when updating the account password. +type PasswordPair struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` +} + +// FullName is used when updating the account username. +type FullName struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` +} + +// Account is the account data. +type Account struct { + UserId string `json:"userId"` + Email string `json:"email"` + Token string `json:"token"` + PaymentId string `json:"paymentId"` + Authenticated uint32 `json:"authenticated"` + AccountStatus string `json:"accountStatus"` + SerialNumber string `json:"serialNumber"` + CountryCode string `json:"countryCode"` + TocUpdate bool `json:"tocUpdate"` + PolicyUpdate bool `json:"policyUpdate"` + ValidEmail bool `json:"validEmail"` + Arlo bool `json:"arlo"` + DateCreated uint64 `json:"dateCreated"` +} + +// Owner is the owner of a Device data. +type Owner struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + OwnerId string `json:"ownerId"` +} + +// Properties is the Device properties data. +type Properties struct { + ModelId string `json:"modelId"` + OlsonTimeZone string `json:"olsonTimeZone"` + HwVersion string `json:"hwVersion"` +} + +// Device is the device data. +type Device struct { + DeviceType string `json:"deviceType"` + XCloudId string `json:"xCloudId"` + DisplayOrder uint8 `json:"displayOrder"` + State string `json:"state"` + ModelId string `json:"modelId"` + InterfaceVersion string `json:"interfaceVersion"` + UserId string `json:"userId"` + DeviceName string `json:"deviceName"` + FirmwareVersion string `json:"firmwareVersion"` + MediaObjectCount uint8 `json:"mediaObjectCount"` + DateCreated uint64 `json:"dateCreated"` + Owner Owner `json:"owner"` + Properties Properties `json:"properties"` + UniqueId string `json:"uniqueId"` + LastModified float64 `json:"lastModified"` + UserRole string `json:"userRole"` + InterfaceSchemaVer string `json:"interfaceSchemaVer"` + DeviceId string `json:"deviceId"` +} + +// Devices is an array of Device objects. +type Devices []Device + +// LibraryMetaData is the library meta data. +type LibraryMetaData struct { + // TODO: Fill this out. +} + +// UserProfile is the user profile data. +type UserProfile struct { + // TODO: Fill this out. +} + +// LoginResponse is an intermediate struct used when parsing data from the Login() call. +type LoginResponse struct { + Data Account + Success bool `json:"success"` +} + +// DeviceResponse is an intermediate struct used when parsing data from the GetDevices() call. +type DeviceResponse struct { + Data Devices + Success bool `json:"success"` +} + +// LibraryMetaDataResponse is an intermediate struct used when parsing data from the GetLibraryMetaData() call. +type LibraryMetaDataResponse struct { + Data LibraryMetaData + Success bool `json:"success"` +} + +// UserProfile is an intermediate struct used when parsing data from the UpdateProfile() call. +type UserProfileResponse struct { + Data UserProfile + Success bool `json:"success"` +}