Big refactor, moved methods around so they are more logically associated. Also made every device carry a reference to the arlo object. Finally, added some sugar to the http code so xCloudId can be properly set. Half way home, but still miles to go...

This commit is contained in:
Jeff Walter 2018-09-19 14:12:06 -05:00
parent 930ecd3bad
commit 01c4316571
11 changed files with 444 additions and 500 deletions

View File

@ -1,189 +0,0 @@
package arlo
import (
"fmt"
"math"
"math/rand"
"strconv"
"strings"
"time"
"github.com/jeffreydwalter/arlo-golang/internal/util"
"github.com/pkg/errors"
)
// 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 float64 `json:"dateCreated"`
}
type Friend struct {
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Devices DeviceOrder `json:"devices"`
LastModified float64 `json:"lastModified"`
AdminUser bool `json:"adminUser"`
Email string `json:"email"`
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)
body := map[string]string{"email": a.user, "password": a.pass}
resp, err := a.client.Post(LoginUri, body, nil)
if err != nil {
return nil, errors.WithMessage(err, "login request failed")
}
var loginResponse LoginResponse
if err := resp.Decode(&loginResponse); err != nil {
return nil, err
}
if loginResponse.Success {
// Cache the auth token.
a.client.BaseHttpHeader.Add("Authorization", loginResponse.Data.Token)
// Add other important headers.
a.client.BaseHttpHeader.Add("DNT", "1")
a.client.BaseHttpHeader.Add("schemaVersion", "1")
a.client.BaseHttpHeader.Add("Host", "arlo.netgear.com")
a.client.BaseHttpHeader.Add("Referer", "https://arlo.netgear.com/")
// Save the account info with the Arlo struct.
a.Account = loginResponse.Data
if deviceResponse, err := a.GetDevices(); err != nil {
return nil, err
} else {
if !deviceResponse.Success {
return nil, err
}
// 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].arlo = a
a.Basestations[i].Subscribe()
}
}
} else {
return nil, errors.New("failed to login")
}
return a, nil
}
func (a *Arlo) Logout() (*Status, error) {
resp, err := a.client.Put(LogoutUri, nil, nil)
if err != nil {
return nil, errors.WithMessage(err, "logout request failed")
}
var status Status
if err := resp.Decode(&status); err != nil {
return nil, err
}
return &status, nil
}
// UpdateProfile takes a first and last name, and updates the user profile with that information.
func (a *Arlo) UpdateProfile(firstName, lastName string) (*Status, error) {
body := map[string]string{"firstName": firstName, "lastName": lastName}
resp, err := a.client.Put(UserProfileUri, body, nil)
if err != nil {
return nil, errors.WithMessage(err, "failed to update profile")
}
var status Status
if err := resp.Decode(&status); err != nil {
return nil, err
}
return &status, nil
}
func (a *Arlo) UpdatePassword(pass string) (*Status, error) {
body := map[string]string{"currentPassword": a.pass, "newPassword": pass}
resp, err := a.client.Post(UserChangePasswordUri, body, nil)
if err != nil {
return nil, errors.WithMessage(err, "failed to update password")
}
var status Status
if err := resp.Decode(&status); err != nil {
return nil, err
}
if status.Success {
a.pass = pass
}
return &status, nil
}
/*
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(f Friend) (*Status, error) {
resp, err := a.client.Put(UserFriendsUri, f, nil)
if err != nil {
return nil, errors.WithMessage(err, "failed to update friends")
}
var status Status
if err := resp.Decode(&status); err != nil {
return nil, err
}
return &status, nil
}

126
arlo.go
View File

@ -2,6 +2,8 @@ package arlo
import (
"github.com/jeffreydwalter/arlo-golang/internal/request"
"github.com/pkg/errors"
)
type Arlo struct {
@ -14,13 +16,129 @@ type Arlo struct {
}
func newArlo(user string, pass string) *Arlo {
c, _ := request.NewClient(BaseUrl)
arlo := &Arlo{
// Add important headers.
c.BaseHttpHeader.Add("DNT", "1")
c.BaseHttpHeader.Add("schemaVersion", "1")
c.BaseHttpHeader.Add("Host", "arlo.netgear.com")
c.BaseHttpHeader.Add("Referer", "https://arlo.netgear.com/")
return &Arlo{
user: user,
pass: pass,
client: c,
}
return arlo
}
func Login(user string, pass string) (*Arlo, error) {
a := newArlo(user, pass)
body := map[string]string{"email": a.user, "password": a.pass}
resp, err := a.post(LoginUri, "", body, nil)
if err != nil {
return nil, errors.WithMessage(err, "login request failed")
}
var loginResponse LoginResponse
if err := resp.Decode(&loginResponse); err != nil {
return nil, err
}
if loginResponse.Success {
// Cache the auth token.
a.client.BaseHttpHeader.Add("Authorization", loginResponse.Data.Token)
// Save the account info with the arlo struct.
a.Account = loginResponse.Data
// Get the devices, which also caches them on the arlo object.
if _, err := a.GetDevices(); err != nil {
return nil, errors.WithMessage(err, "failed to login")
}
} else {
return nil, errors.New("failed to login")
}
return a, nil
}
func (a *Arlo) Logout() error {
resp, err := a.put(LogoutUri, "", nil, nil)
return checkRequest(*resp, err, "failed to logout")
}
// 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, error) {
resp, err := a.get(DevicesUri, "", 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 {
return nil, err
}
if len(deviceResponse.Data) == 0 {
return nil, errors.New("no devices found")
}
if !deviceResponse.Success {
return nil, errors.New("failed to get devices")
}
for i := range deviceResponse.Data {
deviceResponse.Data[i].arlo = a
}
// 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 {
if err := a.Basestations[i].Unsubscribe(); err != nil {
return nil, errors.WithMessage(err, "failed to get devices")
}
if err := a.Basestations[i].Subscribe(); err != nil {
return nil, errors.WithMessage(err, "failed to get devices")
}
}
return deviceResponse.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)
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)
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)
if err := checkRequest(*resp, err, "failed to update password"); err != nil {
return err
}
a.pass = pass
return nil
}
func (a *Arlo) UpdateFriends(f Friend) error {
resp, err := a.put(UserFriendsUri, "", f, nil)
return checkRequest(*resp, err, "failed to update friends")
}

View File

@ -2,8 +2,6 @@ package arlo
import (
"fmt"
"github.com/pkg/errors"
)
type BaseStationMetadata struct {
@ -35,46 +33,40 @@ type BaseStationMetadata struct {
type Basestation struct {
Device
eventStream *EventStream
arlo *Arlo
}
// Basestations is an array of Basestation objects.
type Basestations []Basestation
func (b *Basestation) Subscribe() (*Status, error) {
func (b *Basestation) Subscribe() error {
b.eventStream = NewEventStream(BaseUrl+fmt.Sprintf(SubscribeUri, b.arlo.Account.Token), b.arlo.client.HttpClient)
b.eventStream.Listen()
transId := GenTransId()
body := NotifyPayload{
body := Payload{
Action: "set",
Resource: fmt.Sprintf("subscriptions/%s_%s", b.UserId, "web"),
Resource: fmt.Sprintf("subscriptions/%s_%s", b.UserId, TransIdPrefix),
PublishResponse: false,
Properties: map[string][]string{"devices": []string{b.DeviceId}},
TransId: transId,
Properties: map[string][1]string{"devices": {b.DeviceId}},
TransId: genTransId(),
From: fmt.Sprintf("%s_%s", b.UserId, TransIdPrefix),
To: b.DeviceId,
}
resp, err := b.arlo.client.Post(fmt.Sprintf(NotifyUri, b.DeviceId), body, nil)
if err != nil {
return nil, errors.WithMessage(err, "failed to subscribe to the event stream")
}
var status Status
if err := resp.Decode(&status); err != nil {
return nil, err
}
return &status, nil
resp, err := b.arlo.post(fmt.Sprintf(NotifyUri, b.DeviceId), b.XCloudId, body, nil)
return checkRequest(*resp, err, "failed to subscribe to the event stream")
}
func (b *Basestation) GetState() (*NotifyResponse, error) {
func (b *Basestation) Unsubscribe() error {
// TODO: Close channel to stop EventStream.
//return errors.New("not implemented")
return nil
}
transId := GenTransId()
func (b *Basestation) GetState() (*EventStreamResponse, error) {
transId := genTransId()
body := NotifyPayload{
b.eventStream.Subscriptions[transId] = make(chan *EventStreamResponse)
body := Payload{
Action: "get",
Resource: "basestation",
PublishResponse: false,
@ -83,30 +75,20 @@ func (b *Basestation) GetState() (*NotifyResponse, error) {
To: b.DeviceId,
}
b.eventStream.Subscriptions[transId] = make(chan *NotifyResponse)
resp, err := b.arlo.client.Post(fmt.Sprintf(NotifyUri, b.DeviceId), body, nil)
if err != nil {
return nil, errors.WithMessage(err, "failed to get basestation state")
}
var status Status
if err := resp.Decode(&status); err != nil {
resp, err := b.arlo.post(fmt.Sprintf(NotifyUri, b.DeviceId), b.XCloudId, body, nil)
if err := checkRequest(*resp, err, "failed to get basestation state"); err != nil {
return nil, err
}
if !status.Success {
return nil, errors.New("failed to get basestation status")
}
return <-b.eventStream.Subscriptions[transId], nil
}
func (b *Basestation) GetCameraState() (*NotifyResponse, error) {
func (b *Basestation) GetAssociatedCamerasState() (*EventStreamResponse, error) {
transId := genTransId()
transId := GenTransId()
b.eventStream.Subscriptions[transId] = make(chan *EventStreamResponse)
body := NotifyPayload{
body := Payload{
Action: "get",
Resource: "cameras",
PublishResponse: false,
@ -115,21 +97,10 @@ func (b *Basestation) GetCameraState() (*NotifyResponse, error) {
To: b.DeviceId,
}
b.eventStream.Subscriptions[transId] = make(chan *NotifyResponse)
resp, err := b.arlo.client.Post(fmt.Sprintf(NotifyUri, b.DeviceId), body, nil)
if err != nil {
return nil, errors.WithMessage(err, "failed to get basestation state")
}
var status Status
if err := resp.Decode(&status); err != nil {
resp, err := b.arlo.post(fmt.Sprintf(NotifyUri, b.DeviceId), b.XCloudId, body, nil)
if err := checkRequest(*resp, err, "failed to get camera state"); err != nil {
return nil, err
}
if !status.Success {
return nil, errors.New("failed to get basestation status")
}
return <-b.eventStream.Subscriptions[transId], nil
}

View File

@ -1,8 +1,87 @@
package arlo
import (
"fmt"
"strings"
"github.com/pkg/errors"
)
// A Camera is a Device of type "camera".
// This type is here just for semantics. Some methods explicitly require a device of a certain type.
type Camera Device
// Cameras is an array of Camera objects.
type Cameras []Camera
// 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" }
func (c *Camera) StartStream() (*StreamResponse, error) {
body := Payload{
Action: "set",
Resource: fmt.Sprintf("cameras/%s", c.DeviceId),
PublishResponse: true,
Properties: map[string]string{
"activityState": "startUserStream",
"cameraId": c.DeviceId,
},
TransId: genTransId(),
From: fmt.Sprintf("%s_%s", c.UserId, TransIdPrefix),
To: c.ParentId,
}
resp, err := c.arlo.post(DeviceStartStreamUri, c.XCloudId, body, nil)
if err != nil {
return nil, errors.WithMessage(err, "failed to start stream")
}
defer resp.Body.Close()
var streamResponse StreamResponse
if err := resp.Decode(&streamResponse); err != nil {
return nil, err
}
if !streamResponse.Success {
return nil, errors.WithMessage(errors.New("status was false"), "failed to start stream")
}
streamResponse.Data.Url = strings.Replace(streamResponse.Data.Url, "rtsp://", "rtsps://", 1)
return &streamResponse, nil
}
// TakeSnapshot causes the camera to record a snapshot.
func (c *Camera) TakeSnapshot() (*StreamResponse, error) {
streamResponse, err := c.StartStream()
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 := c.arlo.post(DeviceTakeSnapshotUri, c.XCloudId, body, nil)
if err := checkRequest(*resp, err, "failed to update device name"); err != nil {
return nil, errors.WithMessage(err, "failed to take snapshot")
}
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 (c *Camera) StartRecording() (*StreamResponse, error) {
streamResponse, err := c.StartStream()
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 := c.arlo.post(DeviceStartRecordUri, c.XCloudId, body, nil)
if err := checkRequest(*resp, err, "failed to update device name"); err != nil {
return nil, errors.WithMessage(err, "failed to start recording")
}
return streamResponse, nil
}

View File

@ -1,34 +1,45 @@
package arlo
import (
"fmt"
"strings"
"github.com/pkg/errors"
)
// A Device is the device data, this can be a camera, basestation, arloq, etc.
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"`
arlo *Arlo // Let's hold a reference to the parent arlo object since it holds the http.Client object and references to all devices.
AnalyticsEnabled bool `json:"analyticsEnabled"`
ArloMobilePlan bool `json:"arloMobilePlan"`
ArloMobilePlanId string `json:"arloMobilePlanId"`
ArloMobilePlanName string `json:"arloMobilePlanName"`
ArloMobilePlanThreshold int `json:"arloMobilePlanThreshold"`
Connectivity Connectivity `json:"connectivity"`
CriticalBatteryState bool `json:"criticalBatteryState"`
DateCreated float64 `json:"dateCreated"`
DeviceId string `json:"deviceId"`
DeviceName string `json:"deviceName"`
DeviceType string `json:"deviceType"`
DisplayOrder uint8 `json:"displayOrder"`
FirmwareVersion string `json:"firmwareVersion"`
InterfaceVersion string `json:"interfaceVersion"`
InterfaceSchemaVer string `json:"interfaceSchemaVer"`
LastImageUploaded string `json:"lastImageUploaded"`
LastModified float64 `json:"lastModified"`
MigrateActivityZone bool `json:"migrateActivityZone"`
MobileCarrier string `json:"mobileCarrier"`
MobileTrialUsed bool `json:"mobileTrialUsed"`
PermissionsFilePath string `json:"permissionsFilePath"`
PermissionsSchemaVer string `json:"permissionsSchemaVer"`
PermissionsVerison string `json:"permissionsVerison"` // WTF? Netgear developers think this is OK... *sigh*
PermissionsVersion string `json:"permissionsVersion"`
PresignedFullFrameSnapshotUrl string `json:"presignedFullFrameSnapshotUrl"`
PresignedLastImageUrl string `json:"presignedLastImageUrl"`
PresignedSnapshotUrl string `json:"presignedSnapshotUrl"`
MediaObjectCount uint8 `json:"mediaObjectCount"`
ModelId string `json:"modelId"`
Owner Owner `json:"owner"`
ParentId string `json:"parentId"`
Properties Properties `json:"properties"`
UniqueId string `json:"uniqueId"`
LastModified float64 `json:"lastModified"`
UserId string `json:"userId"`
UserRole string `json:"userRole"`
InterfaceSchemaVer string `json:"interfaceSchemaVer"`
DeviceId string `json:"deviceId"`
Metadata interface{}
State string `json:"state"`
XCloudId string `json:"xCloudId"`
}
// Devices is an array of Device objects.
@ -59,7 +70,7 @@ func (ds *Devices) Find(deviceId string) *Device {
return nil
}
func (ds *Devices) FindCameras(basestationId string) *Cameras {
func (ds *Devices) FindCameras(basestationId string) Cameras {
cs := new(Cameras)
for _, d := range *ds {
if d.ParentId == basestationId {
@ -67,11 +78,11 @@ func (ds *Devices) FindCameras(basestationId string) *Cameras {
}
}
return cs
return *cs
}
func (d Device) IsBasestation() bool {
return d.DeviceType == DeviceTypeBasestation
return d.DeviceType == DeviceTypeBasestation || d.DeviceId == d.ParentId
}
func (d Device) IsCamera() bool {
@ -82,10 +93,10 @@ func (d Device) IsCamera() bool {
// I did this because some device types, like arloq, don't have a basestation.
// So, when interacting with them you must treat them like a basestation and a camera.
// Cameras also includes devices of this type, so you can get the same data there or cast.
func (ds *Devices) GetBasestations() Basestations {
func (ds Devices) GetBasestations() Basestations {
var basestations Basestations
for _, d := range *ds {
if !d.IsCamera() {
for _, d := range ds {
if d.IsBasestation() || !d.IsCamera() {
basestations = append(basestations, Basestation{Device: d})
}
}
@ -95,170 +106,21 @@ func (ds *Devices) GetBasestations() 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) GetCameras() Cameras {
// Basestations also includes devices of this type, so you can get the same data there or cast.
func (ds Devices) GetCameras() Cameras {
var cameras Cameras
for _, d := range *ds {
if !d.IsBasestation() {
for _, d := range ds {
if d.IsCamera() || !d.IsBasestation() {
cameras = append(cameras, Camera(d))
}
}
return cameras
}
// 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() (*DeviceResponse, error) {
resp, err := a.client.Get(DevicesUri, 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 {
return nil, err
}
if len(deviceResponse.Data) == 0 {
return nil, errors.New("no devices found")
}
return &deviceResponse, nil
}
// UpdateDeviceName sets the name of the given device to the name argument.
func (a *Arlo) UpdateDeviceName(d Device, name string) (*Status, error) {
func (d *Device) UpdateDeviceName(name string) error {
body := map[string]string{"deviceId": d.DeviceId, "deviceName": name, "parentId": d.ParentId}
resp, err := a.client.Put(DeviceRenameUri, body, nil)
if err != nil {
return nil, errors.WithMessage(err, "failed to update device name")
}
defer resp.Body.Close()
var status Status
if err := resp.Decode(&status); err != nil {
return nil, err
}
return &status, nil
return nil, errors.New("device not found")
resp, err := d.arlo.put(DeviceRenameUri, d.XCloudId, body, nil)
return checkRequest(*resp, err, "failed to update device name")
}
// UpdateDisplayOrder sets the display order according to the order defined in the DeviceOrder given.
func (a *Arlo) UpdateDisplayOrder(d DeviceOrder) (*Status, error) {
resp, err := a.client.Post(DeviceDisplayOrderUri, d, nil)
if err != nil {
return nil, errors.WithMessage(err, "failed to update display order")
}
defer resp.Body.Close()
var status Status
if err := resp.Decode(&status); err != nil {
return nil, err
}
return &status, nil
}
// 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" }
func (a *Arlo) StartStream(c Camera) (*StreamResponse, error) {
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,
},
}
resp, err := a.client.Post(DeviceStartStreamUri, body, nil)
if err != nil {
return nil, errors.WithMessage(err, "failed to start stream")
}
defer resp.Body.Close()
var streamResponse StreamResponse
if err := resp.Decode(&streamResponse); err != nil {
return nil, err
}
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")
}
defer resp.Body.Close()
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")
}
defer resp.Body.Close()
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')})
*/

View File

@ -20,7 +20,7 @@ var (
type EventStream struct {
SSEClient *sse.Client
Subscriptions map[string]chan *NotifyResponse
Subscriptions map[string]chan *EventStreamResponse
Events chan *sse.Event
ErrorChan chan error
Registered bool
@ -38,7 +38,7 @@ func NewEventStream(url string, client *http.Client) *EventStream {
return &EventStream{
SSEClient: SSEClient,
Events: make(chan *sse.Event),
Subscriptions: make(map[string]chan *NotifyResponse),
Subscriptions: make(map[string]chan *EventStreamResponse),
ErrorChan: make(chan error, 1),
}
}
@ -62,7 +62,7 @@ func (e *EventStream) Listen() {
*/
if event.Data != nil {
notifyResponse := &NotifyResponse{}
notifyResponse := &EventStreamResponse{}
b := bytes.NewBuffer(event.Data)
err := json.NewDecoder(b).Decode(notifyResponse)
if err != nil {

View File

@ -35,9 +35,9 @@ func NewClient(baseurl string) (*Client, error) {
}
header := make(http.Header)
header.Add("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 11_1_2 like Mac OS X) AppleWebKit/604.3.5 (KHTML, like Gecko) Mobile/15B202 NETGEAR/v1 (iOS Vuezone)")
header.Add("Content-Type", "application/json")
header.Add("Accept", "application/json")
header.Set("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 11_1_2 like Mac OS X) AppleWebKit/604.3.5 (KHTML, like Gecko) Mobile/15B202 NETGEAR/v1 (iOS Vuezone)")
header.Set("Content-Type", "application/json")
header.Set("Accept", "application/json")
return &Client{
BaseURL: u,
@ -89,15 +89,15 @@ func (c *Client) newRequest(method string, uri string, body interface{}, header
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)
//log.Printf("Adding header (%s): (%s - %s)\n\n", u, k, h)
req.Header.Set(k, h)
}
}
for k, v := range header {
for _, h := range v {
//fmt.Printf("Adding header (%s): (%s - %s)\n\n", u, k, h)
req.Header.Add(k, h)
//log.Printf("Adding header (%s): (%s - %s)\n\n", u, k, h)
req.Header.Set(k, h)
}
}
@ -115,8 +115,8 @@ 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\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

@ -70,13 +70,13 @@ func (a *Arlo) GetLibrary(fromDate, toDate time.Time) (*LibraryResponse, error)
}
/*
Delete a single video recording from Arlo.
Delete a single video recording from arlo.
All of the date info and device id you need to pass into this method are given in the results of the GetLibrary() call.
NOTE: {"data": [{"createdDate": r.CreatedDate, "utcCreatedDate": r.UtcCreatedDate, "deviceId": r.DeviceId}]} is all that's really required.
*/
func (a *Arlo) DeleteRecording(r Recording) (*Status, error) {
func (a *Arlo) DeleteRecording(r Recording) (*Error, error) {
body := map[string]Library{"data": {r}}
resp, err := a.client.Post(LibraryRecycleUri, body, nil)
@ -84,7 +84,7 @@ func (a *Arlo) DeleteRecording(r Recording) (*Status, error) {
return nil, errors.WithMessage(err, "failed to delete recording")
}
var status Status
var status Error
if err := resp.Decode(&status); err != nil {
return nil, err
}
@ -93,13 +93,13 @@ func (a *Arlo) DeleteRecording(r Recording) (*Status, error) {
}
/*
Delete a batch of video recordings from Arlo.
Delete a batch of video recordings from arlo.
The GetLibrary() call response json can be passed directly to this method if you'd like to delete the same list of videos you queried for.
NOTE: {"data": [{"createdDate": r.CreatedDate, "utcCreatedDate": r.UtcCreatedDate, "deviceId": r.DeviceId}]} is all that's really required.
*/
func (a *Arlo) BatchDeleteRecordings(l Library) (*Status, error) {
func (a *Arlo) BatchDeleteRecordings(l Library) (*Error, error) {
body := map[string]Library{"data": l}
resp, err := a.client.Post(LibraryRecycleUri, body, nil)
@ -107,7 +107,7 @@ func (a *Arlo) BatchDeleteRecordings(l Library) (*Status, error) {
return nil, errors.WithMessage(err, "failed to delete recordings")
}
var status Status
var status Error
if err := resp.Decode(&status); err != nil {
return nil, err
}

View File

@ -1,39 +1,44 @@
package arlo
// UpdateResponse is an intermediate struct used when parsing data from the UpdateProfile() call.
type Status struct {
Success bool `json:"success"`
}
// LoginResponse is an intermediate struct used when parsing data from the Login() call.
type LoginResponse struct {
Data Account
*Status
Error
}
// DeviceResponse is an intermediate struct used when parsing data from the GetDevices() call.
type DeviceResponse struct {
Data Devices
*Status
Error
}
// LibraryMetaDataResponse is an intermediate struct used when parsing data from the GetLibraryMetaData() call.
type LibraryMetaDataResponse struct {
Data LibraryMetaData
*Status
Error
}
type LibraryResponse struct {
Data Library
*Status
Error
}
type StreamResponse struct {
Data StreamUrl
*Status
Error
}
type RecordingResponse struct {
Data StreamUrl
*Status
Error
}
type EventStreamResponse 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"`
}

View File

@ -26,6 +26,54 @@ type FullName struct {
}
*/
// 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 float64 `json:"dateCreated"`
}
type Friend struct {
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Devices DeviceOrder `json:"devices"`
LastModified float64 `json:"lastModified"`
AdminUser bool `json:"adminUser"`
Email string `json:"email"`
Id string `json:"id"`
}
// Owner is part of the Device data.
type Connectivity struct {
ActiveNetwork string `json:"activeNetwork"`
APN string `json:"apn"`
CarrierFw string `json:"carrierFw"`
Connected bool `json:"connected"`
FWVersion string `json:"fwVersion"`
ICCID string `json:"iccid"`
IMEI string `json:"imei"`
MEPStatus string `json:"mepStatus"`
MSISDN string `json:"msisdn"`
NetworkMode string `json:"networkMode"`
NetworkName string `json:"networkName"`
RFBand int `json:"rfBand"`
Roaming bool `json:"roaming"`
RoamingAllowed bool `json:"roamingAllowed"`
SignalStrength string `json:"signalStrength"`
Type string `json:"type"`
WWANIPAddr string `json:"wwanIpAddr"`
}
// Owner is the owner of a Device data.
type Owner struct {
FirstName string `json:"firstName"`
@ -49,8 +97,8 @@ type StreamUrl struct {
Url string `json:"url"`
}
// NotifyPayload represents the message that will be sent to the Arlo servers via the Notify API.
type NotifyPayload struct {
// Payload represents the message that will be sent to the arlo servers via the Notify API.
type Payload struct {
Action string `json:"action,omitempty"`
Resource string `json:"resource,omitempty"`
PublishResponse bool `json:"publishResponse"`
@ -60,38 +108,14 @@ type NotifyPayload struct {
To string `json:"to"`
}
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"`
type Data struct {
Message string `json:"message,omitempty"`
Reason string `json:"reason,omitempty"`
Error string `json:"error,omitempty"`
}
/*
{"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"}}
*/
// map[data:map[message:The device does not exist. reason:No such device. error:2217] success:false]
type Error struct {
Data `json:"Data,omitempty"`
Success bool `json:"success"`
}

74
util.go Normal file
View File

@ -0,0 +1,74 @@
package arlo
import (
"fmt"
"math"
"math/rand"
"net/http"
"strconv"
"strings"
"time"
"github.com/jeffreydwalter/arlo-golang/internal/request"
"github.com/jeffreydwalter/arlo-golang/internal/util"
"github.com/pkg/errors"
)
func checkRequest(resp request.Response, err error, msg string) error {
if err != nil {
return errors.WithMessage(err, msg)
}
defer resp.Body.Close()
var status Error
if err := resp.Decode(&status); err != nil {
return err
}
if resp.StatusCode != 200 {
return errors.WithMessage(errors.New(fmt.Sprintf("http request failed: %s (%d)", resp.Status, resp.StatusCode)), msg)
}
if status.Success == false {
return errors.WithMessage(errors.New(status.Reason), msg)
}
return nil
}
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 (a *Arlo) get(uri, xCloudId string, header http.Header) (*request.Response, error) {
if len(xCloudId) > 0 {
a.client.BaseHttpHeader.Set("xcloudId", xCloudId)
}
return a.client.Get(uri, header)
}
func (a *Arlo) put(uri, xCloudId string, body interface{}, header http.Header) (*request.Response, error) {
if len(xCloudId) > 0 {
a.client.BaseHttpHeader.Set("xcloudId", xCloudId)
}
return a.client.Put(uri, body, header)
}
func (a *Arlo) post(uri, xCloudId string, body interface{}, header http.Header) (*request.Response, error) {
if len(xCloudId) > 0 {
a.client.BaseHttpHeader.Set("xcloudId", xCloudId)
}
return a.client.Post(uri, body, header)
}