diff --git a/.gitignore b/.gitignore index a1338d6..2f8364c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,9 @@ # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 .glide/ + +# Project-local files +*.idea + +# Go vendor libraries +vendor/**/ diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/Gopkg.lock b/Gopkg.lock index 755de40..dded0ae 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -1,21 +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" +[[projects]] + name = "github.com/r3labs/sse" + packages = ["."] + revision = "ab73c814bbdece537f16e92302cd99d1618d0e0d" + version = "1.0.1" + [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "f9932cc0e893e6f71176657aa0d2f567ec244ca71e930f3d95235e1d8b349a1c" + inputs-digest = "559855ebed7f1c0bf0bea2b6f750822d2eb67595d23a2f9006a8302a22b74e47" solver-name = "gps-cdcl" solver-version = 1 diff --git a/account.go b/account.go index ff15573..85cd6b7 100644 --- a/account.go +++ b/account.go @@ -1,6 +1,15 @@ -package arlo +package arlo_golang import ( + "fmt" + "log" + "math" + "math/rand" + "strconv" + "strings" + "time" + + "github.com/jeffreydwalter/arlo-golang/internal/util" "github.com/pkg/errors" ) @@ -31,6 +40,18 @@ type Friend struct { Id string `json:"id"` } +func GenTransId() string { + + source := rand.NewSource(time.Now().UnixNano()) + random := rand.New(source) + + e := random.Float64() * math.Pow(2, 32) + + ms := time.Now().UnixNano() / (int64(time.Millisecond) / int64(time.Nanosecond)) + + return fmt.Sprintf("%s!%s!%s", TransIdPrefix, strings.ToLower(util.FloatToHex(e)), strconv.Itoa(int(ms))) +} + func Login(user string, pass string) (*Arlo, error) { a := newArlo(user, pass) @@ -52,7 +73,7 @@ func Login(user string, pass string) (*Arlo, error) { a.client.BaseHttpHeader.Add("Authorization", loginResponse.Data.Token) // Save the account info with the Arlo struct. - a.Account = &loginResponse.Data + a.Account = loginResponse.Data if deviceResponse, err := a.GetDevices(); err != nil { return nil, err @@ -61,12 +82,18 @@ func Login(user string, pass string) (*Arlo, error) { return nil, err } - // Cache the devices as their respective types. - a.Basestations = deviceResponse.Data.Basestations() - a.Cameras = deviceResponse.Data.Cameras() - // Set the XCloudId header for future requests. You can override this on a per-request basis if needed. a.client.BaseHttpHeader.Add("xCloudId", deviceResponse.Data[0].XCloudId) + + // Cache the devices as their respective types. + a.Cameras = deviceResponse.Data.GetCameras() + a.Basestations = deviceResponse.Data.GetBasestations() + // Connect each basestation to the EventStream. + for i := range a.Basestations { + a.Basestations[i].connect(a) + } + + log.Printf("HERE: %v", util.PrettyPrint(a.Basestations)) } } else { return nil, errors.New("failed to login") diff --git a/arlo.go b/arlo.go index 099e3e9..f58ba12 100644 --- a/arlo.go +++ b/arlo.go @@ -1,16 +1,16 @@ -package arlo +package arlo_golang import ( - "github.com/jeffreydwalter/arlo/internal/request" + "github.com/jeffreydwalter/arlo-golang/internal/request" ) type Arlo struct { user string pass string client *request.Client - Account *Account - Basestations *Basestations - Cameras *Cameras + Account Account + Basestations Basestations + Cameras Cameras } func newArlo(user string, pass string) *Arlo { @@ -24,33 +24,3 @@ func newArlo(user string, pass string) *Arlo { return arlo } - -/* -## -# 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/arlo_test.go b/arlo_test.go index d0aa6f2..7c06dbd 100644 --- a/arlo_test.go +++ b/arlo_test.go @@ -1 +1 @@ -package arlo +package arlo_golang diff --git a/basestation.go b/basestation.go new file mode 100644 index 0000000..50b2452 --- /dev/null +++ b/basestation.go @@ -0,0 +1,108 @@ +package arlo_golang + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/jeffreydwalter/arlo-golang/internal/util" + "github.com/pkg/errors" +) + +type BaseStationMetadata struct { + InterfaceVersion int `json:"interfaceVersion"` + ApiVersion int `json:"apiVersion"` + State string `json:"state"` + SwVersion string `json:"swVersion"` + HwVersion string `json:"hwVersion"` + ModelId string `json:"modelId"` + Capabilities []string `json:"capabilities"` + McsEnabled bool `json:"mcsEnabled"` + AutoUpdateEnabled bool `json:"autoUpdateEnabled"` + TimeZone string `json:"timeZone"` + OlsonTimeZone string `json:"olsonTimeZone"` + UploadBandwidthSaturated bool `json:"uploadBandwidthSaturated"` + AntiFlicker map[string]int `json:"antiFlicker"` + LowBatteryAlert map[string]bool `json:"lowBatteryAlert"` + LowSignalAlert map[string]bool `json:"lowSignalAlert"` + Claimed bool `json:"claimed"` + TimeSyncState string `json:"timeSyncState"` + Connectivity []struct { + Type string `json:"type"` + Connected bool `json:"connected"` + } `json:"connectivity"` +} + +// A Basestation is a Device that's not type "camera" (basestation, arloq, arloqs, etc.). +// This type is here just for semantics. Some methods explicitly require a device of a certain type. +type Basestation struct { + Device + eventStream *EventStream +} + +// Basestations is an array of Basestation objects. +type Basestations []Basestation + +func (b *Basestation) connect(a *Arlo) { + b.eventStream = NewEventStream(BaseUrl+fmt.Sprintf(SubscribeUri, a.Account.Token), util.HeaderToMap(*a.client.BaseHttpHeader)) + b.eventStream.Listen() +} + +/* + 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) GetBasestationState(b Basestation) (*NotifyResponse, error) { + + transId := GenTransId() + + body := NotifyPayload{ + Action: "get", + Resource: "basestation", + PublishResponse: false, + Properties: map[string]string{}, + TransId: transId, + From: fmt.Sprintf("%s_%s", b.UserId, TransIdPrefix), + To: b.DeviceId, + } + + b.eventStream.Subscriptions[transId] = new(Subscriber) + + for b.eventStream.Connected == false { + fmt.Println("Not connected yet.") + time.Sleep(1000 * time.Millisecond) + } + fmt.Println("Connected now.") + + resp, err := a.client.Post(fmt.Sprintf(NotifyUri, b.DeviceId), body, nil) + if err != nil { + return nil, errors.WithMessage(err, "failed to start stream") + } + + ep := &NotifyResponse{} + err = json.NewDecoder(resp.Body).Decode(ep) + if err != nil { + return nil, errors.WithMessage(err, "failed to decode body") + } + + for { + fmt.Println("Subscribing to the eventstream.") + select { + case notifyResponse := <-*b.eventStream.Subscriptions[transId]: + fmt.Println("Recieved a response from the subscription.") + return ¬ifyResponse, nil + } + } +} diff --git a/camera.go b/camera.go new file mode 100644 index 0000000..3d7140c --- /dev/null +++ b/camera.go @@ -0,0 +1,8 @@ +package arlo_golang + +// A Camera is a Device of type "camera". +// This type is here just for semantics. Some methods explicitly require a device of a certain type. +type Camera Device + +// Cameras is an array of Camera objects. +type Cameras []Camera diff --git a/const.go b/const.go index 194e6be..9bc02a1 100644 --- a/const.go +++ b/const.go @@ -1,6 +1,8 @@ -package arlo +package arlo_golang const ( + TransIdPrefix = "web" + BaseUrl = "https://arlo.netgear.com/hmsweb" LoginUri = "/login/v2" LogoutUri = "/logout" @@ -26,4 +28,8 @@ const ( DeviceStartRecordUri = "/users/devices/startRecord" DeviceStopRecordUri = "/users/devices/stopRecord" DeviceStartStreamUri = "/users/devices/startStream" + + DeviceTypeBasestation = "basestation" + DeviceTypeCamera = "camera" + DeviceTypeArloQ = "arloq" ) diff --git a/devices.go b/devices.go index 90d23b3..a4aa532 100644 --- a/devices.go +++ b/devices.go @@ -1,6 +1,9 @@ -package arlo +package arlo_golang import ( + "fmt" + "strings" + "github.com/pkg/errors" ) @@ -25,25 +28,12 @@ type Device struct { UserRole string `json:"userRole"` InterfaceSchemaVer string `json:"interfaceSchemaVer"` DeviceId string `json:"deviceId"` + Metadata interface{} } // Devices is an array of Device objects. type Devices []Device -// A Basestation is a Device that's not type "camera" (basestation, arloq, arloqs, etc.). -// This type is here just for semantics. Some methods explicitly require a device of a certain type. -type Basestation Device - -// Basestations is an array of Basestation objects. -type Basestations []Basestation - -// A Camera is a Device of type "camera". -// This type is here just for semantics. Some methods explicitly require a device of a certain type. -type Camera Device - -// Cameras is an array of Camera objects. -type Cameras []Camera - // A DeviceOrder holds a map of device ids and a numeric index. The numeric index is the device order. // Device order is mainly used by the UI to determine which order to show the devices. /* @@ -69,32 +59,51 @@ func (ds *Devices) Find(deviceId string) *Device { return nil } -// Basestations returns a Basestations object containing all devices that are NOT type "camera". -// I did this because some device types, like arloq, don't have a basestation. -// So, when interacting with them you must treat them like a basestation and a camera. -// Cameras also includes decvices of this type, so you can get the same data there or cast. -func (ds *Devices) Basestations() *Basestations { - var basestations Basestations +func (ds *Devices) FindCameras(basestationId string) *Cameras { + cs := new(Cameras) for _, d := range *ds { - if d.DeviceType != "camera" { - basestations = append(basestations, Basestation(d)) + if d.ParentId == basestationId { + *cs = append(*cs, Camera(d)) } } - return &basestations + + return cs } -// Cameras returns a Cameras object containing all devices that are of type "camera". +func (d Device) IsBasestation() bool { + return d.DeviceType == DeviceTypeBasestation +} + +func (d Device) IsCamera() bool { + return d.DeviceType == DeviceTypeCamera +} + +// GetBasestations returns a Basestations object containing all devices that are NOT type "camera". +// I did this because some device types, like arloq, don't have a basestation. +// So, when interacting with them you must treat them like a basestation and a camera. +// Cameras also includes devices of this type, so you can get the same data there or cast. +func (ds *Devices) GetBasestations() Basestations { + var basestations Basestations + for _, d := range *ds { + if !d.IsCamera() { + basestations = append(basestations, Basestation{Device: d}) + } + } + return basestations +} + +// GetCameras returns a Cameras object containing all devices that are of type "camera". // I did this because some device types, like arloq, don't have a basestation. // So, when interacting with them you must treat them like a basestation and a camera. // Basestations also includes decvices of this type, so you can get the same data there or cast. -func (ds *Devices) Cameras() *Cameras { +func (ds *Devices) GetCameras() Cameras { var cameras Cameras for _, d := range *ds { - if d.DeviceType != "basestation" { + if !d.IsBasestation() { cameras = append(cameras, Camera(d)) } } - return &cameras + return cameras } // GetDevices returns an array of all devices. @@ -159,30 +168,91 @@ func (a *Arlo) UpdateDisplayOrder(d DeviceOrder) (*Status, error) { // 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":"rtmps://vzwow09-z2-prod.vz.netgear.com:80/vzmodulelive?egressToken=b1b4b675_ac03_4182_9844_043e02a44f71&userAgent=web&cameraId=48B4597VD8FF5_1473010750131" } -func (a *Arlo) StartStream(c Camera) (*StartStreamResponse, error) { +// NOTE: { "url":"rtsp://vzwow09-z2-prod.vz.netgear.com:80/vzmodulelive?egressToken=b1b4b675_ac03_4182_9844_043e02a44f71&userAgent=web&cameraId=48B4597VD8FF5_1473010750131" } +func (a *Arlo) StartStream(c Camera) (*StreamResponse, error) { - var n Notification - n.To = c.ParentId - n.From = c.UserId - n.Resource = "cameras/" + c.DeviceId - n.Action = "set" - n.PublishResponse = true - n.TransId = "" - n.Properties.ActivityState = "startUserStream" - n.Properties.CameraId = c.DeviceId + body := map[string]interface{}{ + "to": c.ParentId, + "from": fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix), + "resource": fmt.Sprintf("cameras/%s", c.DeviceId), + "action": "set", + "publishResponse": true, + "transId": GenTransId(), + "properties": map[string]string{ + "activityState": "startUserStream", + "cameraId": c.DeviceId, + }, + } - // {"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')} - resp, err := a.client.Post(DeviceStartStreamUri, n, nil) + resp, err := a.client.Post(DeviceStartStreamUri, body, nil) if err != nil { return nil, errors.WithMessage(err, "failed to start stream") } - var startStreamResponse StartStreamResponse - if err := resp.Decode(&startStreamResponse); err != nil { + var streamResponse StreamResponse + if err := resp.Decode(&streamResponse); err != nil { return nil, err } - return &startStreamResponse, nil + streamResponse.Data.Url = strings.Replace(streamResponse.Data.Url, "rtsp://", "rtsps://", 1) + + return &streamResponse, nil } + +// TakeSnapshot causes the camera to record a snapshot. +func (a *Arlo) TakeSnapshot(c Camera) (*StreamResponse, error) { + + stream, err := a.StartStream(c) + if err != nil { + return nil, errors.WithMessage(err, "failed to take snapshot") + } + + body := map[string]string{"deviceId": c.DeviceId, "parentId": c.ParentId, "xcloudId": c.XCloudId, "olsonTimeZone": c.Properties.OlsonTimeZone} + resp, err := a.client.Post(DeviceTakeSnapshotUri, body, nil) + if err != nil { + return nil, errors.WithMessage(err, "failed to take snapshot") + } + + var status Status + if err := resp.Decode(&status); err != nil { + return nil, err + } + + streamResponse := StreamResponse{stream.Data, &status} + return &streamResponse, nil +} + +// StartRecording causes the camera to start recording and returns a url that you must start reading from using ffmpeg +// or something similar. +func (a *Arlo) StartRecording(c Camera) (*StreamResponse, error) { + + stream, err := a.StartStream(c) + if err != nil { + return nil, errors.WithMessage(err, "failed to start recording") + } + + body := map[string]string{"deviceId": c.DeviceId, "parentId": c.ParentId, "xcloudId": c.XCloudId, "olsonTimeZone": c.Properties.OlsonTimeZone} + resp, err := a.client.Post(DeviceStartRecordUri, body, nil) + if err != nil { + return nil, errors.WithMessage(err, "failed to start recording") + } + + var status Status + if err := resp.Decode(&status); err != nil { + return nil, err + } + + streamResponse := StreamResponse{stream.Data, &status} + return &streamResponse, nil +} + +/* +## +# 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/events_stream.go b/events_stream.go new file mode 100644 index 0000000..4e52047 --- /dev/null +++ b/events_stream.go @@ -0,0 +1,147 @@ +package arlo_golang + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "sync" + + "github.com/pkg/errors" + + "github.com/r3labs/sse" +) + +var FAILED_TO_PUBLISH = errors.New("Failed to publish") + +var FAILED_TO_DECODE_JSON = errors.New("Failed to decode JSON") + +var FAILED_TO_SUBSCRIBE = errors.New("Failed to subscribe to SSEClient") + +type Subscriber chan NotifyResponse + +type EventStream struct { + Registered bool + Connected bool + SSEClient *sse.Client + Events chan *sse.Event + Subscriptions map[string]*Subscriber + ErrorChan chan error + Responses map[string]NotifyResponse + Verbose bool + + sync.Mutex +} + +func NewEventStream(url string, headers map[string]string) *EventStream { + + SSEClient := sse.NewClient(url) + SSEClient.Headers = headers + + return &EventStream{ + SSEClient: SSEClient, + Events: make(chan *sse.Event), + Subscriptions: map[string]*Subscriber{}, + ErrorChan: make(chan error, 1), + } +} + +func (e *EventStream) Listen() { + + go func() { + err := e.SSEClient.SubscribeChan("", e.Events) + if err != nil { + fmt.Println(FAILED_TO_SUBSCRIBE) + e.ErrorChan <- FAILED_TO_SUBSCRIBE + } + }() + + for event := range e.Events { + fmt.Println("Got event message here.") + fmt.Printf("EVENT: %s\n", event.Event) + fmt.Printf("DATA: %s\n", event.Data) + + if event.Data != nil { + notifyResponse := &NotifyResponse{} + b := bytes.NewBuffer(event.Data) + err := json.NewDecoder(b).Decode(notifyResponse) + if err != nil { + e.ErrorChan <- errors.WithMessage(err, "failed to decode JSON") + break + } + + if notifyResponse.Status == "connected" { + e.Connected = true + fmt.Println("Connected.") + break + } + } + } + + go func() { + for event := range e.Events { + fmt.Println("Got event message.") + fmt.Printf("EVENT: %X\n", event.Event) + fmt.Printf("DATA: %X\n", event.Data) + + if event.Data != nil { + notifyResponse := &NotifyResponse{} + b := bytes.NewBuffer(event.Data) + err := json.NewDecoder(b).Decode(notifyResponse) + if err != nil { + e.ErrorChan <- FAILED_TO_DECODE_JSON + break + } + + if notifyResponse.Status == "connected" { + fmt.Println("Connected.") + e.Connected = true + } else if notifyResponse.Status == "disconnected" { + fmt.Println("Disconnected.") + e.Connected = false + } else { + fmt.Printf("Message for transId: %s", notifyResponse.TransId) + if subscriber, ok := e.Subscriptions[notifyResponse.TransId]; ok { + e.Lock() + *subscriber <- *notifyResponse + close(*subscriber) + delete(e.Subscriptions, notifyResponse.TransId) + e.Unlock() + } else { + // Throw away the message. + fmt.Println("Throwing away message.") + } + } + } + } + }() + /* + go func() { + + fmt.Println("go func to recieve a subscription.") + for { + fmt.Println("go func for loop to recieve a subscription.") + select { + case s := <-e.Subscriptions: + if resp, ok := e.Responses[s.transId]; ok { + fmt.Println("Recieved a subscription, sending response.") + s.ResponseChan <- resp + e.Lock() + delete(e.Responses, s.transId) + e.Unlock() + } else { + fmt.Println("Recieved a subscription error, sending error response.") + e.ErrorChan <- FAILED_TO_PUBLISH + break + } + } + } + }() + */ +} + +func (e *EventStream) verbose(params ...interface{}) { + if e.Verbose { + log.Println(params...) + } +} diff --git a/internal/request/client.go b/internal/request/client.go index d20e184..20446a4 100644 --- a/internal/request/client.go +++ b/internal/request/client.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "io" - "log" "net/http" "net/http/cookiejar" "net/url" @@ -14,8 +13,8 @@ import ( type Client struct { BaseURL *url.URL - BaseHttpHeader http.Header - httpClient http.Client + BaseHttpHeader *http.Header + HttpClient *http.Client } func NewClient(baseurl string) (*Client, error) { @@ -40,8 +39,8 @@ func NewClient(baseurl string) (*Client, error) { return &Client{ BaseURL: u, - BaseHttpHeader: header, - httpClient: http.Client{Jar: jar}, + BaseHttpHeader: &header, + HttpClient: &http.Client{Jar: jar}, }, nil } @@ -79,14 +78,14 @@ func (c *Client) newRequest(method string, uri string, body interface{}, header return nil, errors.Wrap(err, "failed to create request object") } } - log.Printf("JSON: %v", buf) + // 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 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) @@ -114,15 +113,15 @@ func (c *Client) newResponse(resp *http.Response) (*Response, error) { 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\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) + resp, err := c.HttpClient.Do(&req.Request) if err != nil { return nil, errors.Wrap(err, "failed to execute http request") } - if resp.StatusCode >= 400 { + if resp.StatusCode >= http.StatusBadRequest { defer resp.Body.Close() return nil, errors.New("http request failed with status: " + resp.Status) } diff --git a/internal/util/util.go b/internal/util/util.go index 8b8b386..58f9932 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -3,6 +3,8 @@ package util import ( "encoding/json" "fmt" + "net/http" + "strconv" ) func PrettyPrint(data interface{}) string { @@ -13,13 +15,64 @@ func PrettyPrint(data interface{}) string { return fmt.Sprint(string(j)) } -const TransIdPrefix = "web" +func FloatToHex(x float64) string { + var result []byte + quotient := int(x) + fraction := x - float64(quotient) -func GenTransId(transType string) { - /* - func divmod(numerator, denominator int64) (quotient, remainder int64) { - quotient = numerator / denominator // integer division, decimals are truncated - remainder = numerator % denominator - return - }*/ + for quotient > 0 { + quotient = int(x / 16) + remainder := int(x - (float64(quotient) * 16)) + + if remainder > 9 { + result = append([]byte{byte(remainder + 55)}, result...) + } else { + for _, c := range strconv.Itoa(int(remainder)) { + result = append([]byte{byte(c)}, result...) + } + } + + x = float64(quotient) + } + + if fraction == 0 { + return string(result) + } + + result = append(result, '.') + + for fraction > 0 { + fraction = fraction * 16 + integer := int(fraction) + fraction = fraction - float64(integer) + + if integer > 9 { + result = append(result, byte(integer+55)) + } else { + for _, c := range strconv.Itoa(int(integer)) { + result = append(result, byte(c)) + } + } + } + + return string(result) +} + +func HeaderToArray(header http.Header) (res []string) { + for name, values := range header { + for _, value := range values { + res = append(res, fmt.Sprintf("%s: %s", name, value)) + } + } + return +} + +func HeaderToMap(header http.Header) map[string]string { + h := make(map[string]string) + for name, values := range header { + for _, value := range values { + h[name] = value + } + } + return h } diff --git a/library.go b/library.go index 2e4d099..7bc36e1 100644 --- a/library.go +++ b/library.go @@ -1,4 +1,4 @@ -package arlo +package arlo_golang import ( "time" diff --git a/responses.go b/responses.go index 1804346..fe536a2 100644 --- a/responses.go +++ b/responses.go @@ -1,4 +1,4 @@ -package arlo +package arlo_golang // UpdateResponse is an intermediate struct used when parsing data from the UpdateProfile() call. type Status struct { @@ -28,7 +28,12 @@ type LibraryResponse struct { *Status } -type StartStreamResponse struct { +type StreamResponse struct { + Data StreamUrl + *Status +} + +type RecordingResponse struct { Data StreamUrl *Status } diff --git a/types.go b/types.go index faccafb..bcf1478 100644 --- a/types.go +++ b/types.go @@ -1,4 +1,4 @@ -package arlo +package arlo_golang /* // Credentials is the login credential data. @@ -45,45 +45,53 @@ type Favorite struct { Favorite uint8 `json:"Favorite"` } -/* -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"` - ParentId string `json:"parentId"` - UserId string `json:"userId"` - DeviceName string `json:"deviceName"` - FirmwareVersion string `json:"firmwareVersion"` - MediaObjectCount uint8 `json:"mediaObjectCount"` - DateCreated float64 `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"` -} -*/ - type StreamUrl struct { Url string `json:"url"` } -type NotificationProperties struct { - ActivityState string `json:"activityState"` - CameraId string `json:"cameraId"` +// NotifyPayload represents the message that will be sent to the Arlo servers via the Notify API. +type NotifyPayload struct { + Action string `json:"action,omitempty"` + Resource string `json:"resource,omitempty"` + PublishResponse bool `json:"publishResponse,omitempty"` + Properties interface{} `json:"properties,omitempty"` + TransId string `json:"transId"` + From string `json:"from"` + To string `json:"to"` } -type Notification struct { - To string `json:"to"` - From string `json:"from"` - Resource string `json:"resource"` - Action string `json:"action"` - PublishResponse bool `json:"publishResourcec"` - TransId string `json:"transId"` - Properties NotificationProperties `json:"properties"` +type NotifyResponse struct { + Action string `json:"action,omitempty"` + Resource string `json:"resource,omitempty"` + Properties interface{} `json:"properties,omitempty"` + TransId string `json:"transId"` + From string `json:"from"` + To string `json:"to"` + Status string `json:"status"` } + +/* +{"status":"connected"} + +{"resource":"subscriptions/336-4764296_web","transId":"web!f94fbae4.46e6e!1520148142862","action":"is","from":"48935B7SA9847","to":"336-4764296_web","properties":{"devices":["48935B7SA9847"],"url":"https://vzweb05-prod.vz.netgear.com/hmsweb/publish/48935B7SA9847/336-4764296/c16ec5b2-f914-4140-aa5d-880feda292a0"}} + +{"resource":"cameras/48B45974D8E54","properties":{"batteryLevel":15},"transId":"48935B7SA9847!cfa2b5ed!1520148143870","from":"48935B7SA9847","action":"is"} + +{"resource":"basestation","properties":{"interfaceVersion":3,"apiVersion":1,"state":"idle","swVersion":"1.9.8.0_16666","hwVersion":"VMB3010r2","modelId":"VMB3010","capabilities":["gateway"],"mcsEnabled":true,"autoUpdateEnabled":true,"timeZone":"CST6CDT,M3.2.0,M11.1.0","olsonTimeZone":"America/Chicago","uploadBandwidthSaturated":false,"antiFlicker":{"mode":0,"autoDefault":1},"lowBatteryAlert":{"enabled":true},"lowSignalAlert":{"enabled":false},"claimed":true,"timeSyncState":"synchronized","connectivity":[{"type":"ethernet","connected":true}]},"action":"is","transId":"web!ffe75798.f6dca!1520148144127","to":"336-4764296_web","from":"48935B7SA9847"} + + +{"resource":"basestation","properties":{"interfaceVersion":3,"apiVersion":1,"state":"idle","swVersion":"1.9.8.0_16666","hwVersion":"VMB3010r2","modelId":"VMB3010","capabilities":["gateway"],"mcsEnabled":true,"autoUpdateEnabled":true,"timeZone":"CST6CDT,M3.2.0,M11.1.0","olsonTimeZone":"America/Chicago","uploadBandwidthSaturated":false,"antiFlicker":{"mode":0,"autoDefault":1},"lowBatteryAlert":{"enabled":true},"lowSignalAlert":{"enabled":false},"claimed":true,"timeSyncState":"synchronized","connectivity":[{"type":"ethernet","connected":true}]},"action":"is","transId":"web!ffe75798.f6dca!1520148144127","to":"336-4764296_web","from":"48935B7SA9847"} + +{"resource":"cameras","properties":[{"interfaceVersion":3,"serialNumber":"48B45974D8E54","batteryLevel":15,"signalStrength":4,"brightness":0,"mirror":true,"flip":true,"powerSaveMode":3,"capabilities":["H.264Streaming","JPEGSnapshot","SignalStrength","Privacy","Standby",{"Resolutions":[{"text":"1080p","x":1920,"y":1088},{"text":"720p","x":1280,"y":720},{"text":"480p","x":848,"y":480},{"text":"360p","x":640,"y":352},{"text":"240p","x":416,"y":240}]},{"TimedStreamDuration":{"min":5,"max":120,"default":10}},{"TriggerEndStreamDuration":{"min":5,"max":300,"default":300}},{"Actions":[{"recordVideo":[{"StopActions":["timeout","triggerEndDetected"]}]},"sendEmailAlert","pushNotification"]},{"Triggers":[{"type":"pirMotionActive","sensitivity":{"type":"integer","min":1,"max":100,"step":1,"default":80}}]}],"zoom":{"topleftx":0,"toplefty":0,"bottomrightx":1280,"bottomrighty":720},"mic":{"mute":false,"volume":100},"speaker":{"mute":false,"volume":100},"streamingMode":"eventBased","continuousStreamState":"inactive","motion":{"sensitivity":5,"zones":[]},"resolution":{"width":1280,"height":720},"idleLedEnable":true,"privacyActive":false,"standbyActive":false,"connectionState":"available","activityState":"idle","swVersion":"1.2.16720","hwVersion":"H7","modelId":"VMC3030","motionSetupModeEnabled":false,"motionSetupModeSensitivity":80,"motionDetected":false,"audioDetected":false,"hasStreamed":true,"olsonTimeZone":"America/Chicago","name":"","nightVisionMode":1},{"interfaceVersion":3,"serialNumber":"48B4597FD9B8E","batteryLevel":0,"signalStrength":4,"brightness":0,"mirror":false,"flip":false,"powerSaveMode":3,"capabilities":["H.264Streaming","JPEGSnapshot","SignalStrength","Privacy","Standby",{"Resolutions":[{"text":"1080p","x":1920,"y":1088},{"text":"720p","x":1280,"y":720},{"text":"480p","x":848,"y":480},{"text":"360p","x":640,"y":352},{"text":"240p","x":416,"y":240}]},{"TimedStreamDuration":{"min":5,"max":120,"default":10}},{"TriggerEndStreamDuration":{"min":5,"max":300,"default":300}},{"Actions":[{"recordVideo":[{"StopActions":["timeout","triggerEndDetected"]}]},"sendEmailAlert","pushNotification"]},{"Triggers":[{"type":"pirMotionActive","sensitivity":{"type":"integer","min":1,"max":100,"step":1,"default":80}}]}],"zoom":{"topleftx":0,"toplefty":0,"bottomrightx":1280,"bottomrighty":720},"mic":{"mute":false,"volume":100},"speaker":{"mute":false,"volume":100},"streamingMode":"eventBased","continuousStreamState":"inactive","motion":{"sensitivity":5,"zones":[]},"resolution":{"width":1280,"height":720},"idleLedEnable":true,"privacyActive":false,"standbyActive":false,"connectionState":"batteryCritical","activityState":"idle","swVersion":"1.2.16720","hwVersion":"H7","modelId":"VMC3030","motionSetupModeEnabled":false,"motionSetupModeSensitivity":80,"motionDetected":false,"audioDetected":false,"hasStreamed":true,"olsonTimeZone":"America/Chicago","name":"","nightVisionMode":1},{"interfaceVersion":3,"serialNumber":"48B4597VD8FF5","batteryLevel":0,"signalStrength":4,"brightness":2,"mirror":true,"flip":true,"powerSaveMode":3,"capabilities":["H.264Streaming","JPEGSnapshot","SignalStrength","Privacy","Standby",{"Resolutions":[{"text":"1080p","x":1920,"y":1088},{"text":"720p","x":1280,"y":720},{"text":"480p","x":848,"y":480},{"text":"360p","x":640,"y":352},{"text":"240p","x":416,"y":240}]},{"TimedStreamDuration":{"min":5,"max":120,"default":10}},{"TriggerEndStreamDuration":{"min":5,"max":300,"default":300}},{"Actions":[{"recordVideo":[{"StopActions":["timeout","triggerEndDetected"]}]},"sendEmailAlert","pushNotification"]},{"Triggers":[{"type":"pirMotionActive","sensitivity":{"type":"integer","min":1,"max":100,"step":1,"default":80}}]}],"zoom":{"topleftx":0,"toplefty":0,"bottomrightx":1280,"bottomrighty":720},"mic":{"mute":false,"volume":100},"speaker":{"mute":false,"volume":100},"streamingMode":"eventBased","continuousStreamState":"inactive","motion":{"sensitivity":5,"zones":[]},"resolution":{"width":1280,"height":720},"idleLedEnable":true,"privacyActive":false,"standbyActive":false,"connectionState":"batteryCritical","activityState":"idle","swVersion":"1.2.16720","hwVersion":"H7","modelId":"VMC3030","motionSetupModeEnabled":false,"motionSetupModeSensitivity":80,"motionDetected":false,"audioDetected":false,"hasStreamed":true,"olsonTimeZone":"America/Chicago","name":"","nightVisionMode":1}],"action":"is","transId":"web!2dc849b8.9ffc2!1520148144127","to":"336-4764296_web","from":"48935B7SA9847"} + +{"resource":"modes","properties":{"active":"mode1","modes":[{"name":"","type":"disarmed","rules":[],"id":"mode0"},{"name":"","type":"armed","rules":["rule5","rule3","rule0"],"id":"mode1"},{"name":"*****_DEFAULT_MODE_ARMED_*****","rules":["rule1"],"id":"mode2"},{"name":"Test Mode","rules":["rule6"],"id":"mode3"}]},"action":"is","transId":"web!bbb0ff1f.3c85f!1520148144127","to":"336-4764296_web","from":"48935B7SA9847"} + + +{"resource":"rules","properties":{"rules":[{"name":"Push notification if Front Door detects motion","protected":true,"triggers":[{"deviceId":"48B45974D8E54","sensitivity":80,"type":"pirMotionActive"}],"actions":[{"type":"recordVideo","deviceId":"48B45974D8E54","stopCondition":{"type":"timeout","timeout":120}},{"type":"pushNotification"}],"id":"rule0"},{"name":"Record camera (Back Patio) on motion.","protected":false,"triggers":[{"type":"pirMotionActive","deviceId":"48B4597VD8FF5","sensitivity":80}],"actions":[{"deviceId":"48B4597VD8FF5","type":"recordVideo","stopCondition":{"type":"timeout","timeout":10}},{"type":"pushNotification"}],"id":"rule1"},{"name":"Push notification if Inside detects motion","protected":true,"triggers":[{"deviceId":"48B4597FD9B8E","sensitivity":90,"type":"pirMotionActive"}],"actions":[{"deviceId":"48B4597FD9B8E","type":"recordVideo","stopCondition":{"type":"timeout","timeout":120}}],"id":"rule3"},{"name":"Push notification if Back Patio detects motion","protected":true,"triggers":[{"deviceId":"48B4597VD8FF5","sensitivity":100,"type":"pirMotionActive"}],"actions":[{"deviceId":"48B4597VD8FF5","type":"recordVideo","stopCondition":{"type":"timeout","timeout":30}},{"type":"pushNotification"}],"id":"rule5"},{"name":"Push notification & Email alert if Back Patio detects motion","protected":false,"triggers":[{"type":"pirMotionActive","deviceId":"48B4597VD8FF5","sensitivity":80}],"actions":[{"type":"sendEmailAlert","recipients":["__OWNER_EMAIL__"]},{"type":"pushNotification"}],"id":"rule6"}]},"action":"is","transId":"web!bff59099.cbd6d!1520148144127","to":"336-4764296_web","from":"48935B7SA9847"} + + +{"resource":"subscriptions/336-4764296_web","transId":"web!ddda6350.ba92c!1520148172685","action":"is","from":"48935B7SA9847","to":"336-4764296_web","properties":{"devices":["48935B7SA9847"],"url":"https://vzweb05-prod.vz.netgear.com/hmsweb/publish/48935B7SA9847/336-4764296/37da66eb-023f-4965-bb8b-480687881b65"}} + + +{"resource":"subscriptions/336-4764296_web","transId":"web!d5739e5.077af!1520148202738","action":"is","from":"48935B7SA9847","to":"336-4764296_web","properties":{"devices":["48935B7SA9847"],"url":"https://vzweb05-prod.vz.netgear.com/hmsweb/publish/48935B7SA9847/336-4764296/7d9cc5d7-a908-4f22-aaaa-dbb70c8616d6"}} +*/