From 1668674720404d6debf93df1c47c987d78e8e529 Mon Sep 17 00:00:00 2001 From: David Newhall II Date: Sun, 10 Jan 2021 21:01:29 -0800 Subject: [PATCH] lint fixes --- Gopkg.lock | 53 ------------------- Gopkg.toml | 38 -------------- LICENSE | 2 +- cameras.go | 122 +++++++++++++++++++++++++++++++------------ cameras_test.go | 11 ++++ events.go | 79 ++++++++++++++++++++-------- events_types.go | 77 ++++++++++++++------------- fake_data_test.go | 24 +++++---- files.go | 59 ++++++++++++++------- files_types.go | 19 ++++--- go.mod | 8 +++ go.sum | 12 +++++ ptz.go | 15 ++++-- ptz_types.go | 8 +-- schedules.go | 3 +- schedules_test.go | 8 +++ schedules_types.go | 8 ++- securityspy.go | 83 ++++++++++++++++++++--------- securityspy_test.go | 93 +++++++++++++++++++++++++-------- securityspy_types.go | 17 +++--- 20 files changed, 452 insertions(+), 287 deletions(-) delete mode 100644 Gopkg.lock delete mode 100644 Gopkg.toml create mode 100644 go.mod create mode 100644 go.sum diff --git a/Gopkg.lock b/Gopkg.lock deleted file mode 100644 index c0a23e5..0000000 --- a/Gopkg.lock +++ /dev/null @@ -1,53 +0,0 @@ -# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. - - -[[projects]] - digest = "1:ffe9824d294da03b391f44e1ae8281281b4afc1bdaa9588c9097785e3af10cec" - name = "github.com/davecgh/go-spew" - packages = ["spew"] - pruneopts = "UT" - revision = "8991bc29aa16c548c550c7ff78260e27b9ab7c73" - version = "v1.1.1" - -[[projects]] - digest = "1:cf31692c14422fa27c83a05292eb5cbe0fb2775972e8f1f8446a71549bd8980b" - name = "github.com/pkg/errors" - packages = ["."] - pruneopts = "UT" - revision = "ba968bfe8b2f7e042a574c888954fccecfa385b4" - version = "v0.8.1" - -[[projects]] - digest = "1:0028cb19b2e4c3112225cd871870f2d9cf49b9b4276531f03438a88e94be86fe" - name = "github.com/pmezard/go-difflib" - packages = ["difflib"] - pruneopts = "UT" - revision = "792786c7400a136282c1664665ae0a8db921c6c2" - version = "v1.0.0" - -[[projects]] - digest = "1:972c2427413d41a1e06ca4897e8528e5a1622894050e2f527b38ddf0f343f759" - name = "github.com/stretchr/testify" - packages = ["assert"] - pruneopts = "UT" - revision = "ffdc059bfe9ce6a4e144ba849dbedead332c6053" - version = "v1.3.0" - -[[projects]] - digest = "1:b936b904b1cfa23889f61635efcbc2ef3ea600c864378db0b164c29ff1cd762b" - name = "golift.io/ffmpeg" - packages = ["."] - pruneopts = "UT" - revision = "82a404c8777fe9ac6fcb0d73cc4e489321a63fe4" - version = "v1.0.0" - -[solve-meta] - analyzer-name = "dep" - analyzer-version = 1 - input-imports = [ - "github.com/pkg/errors", - "github.com/stretchr/testify/assert", - "golift.io/ffmpeg", - ] - solver-name = "gps-cdcl" - solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml deleted file mode 100644 index 28ba310..0000000 --- a/Gopkg.toml +++ /dev/null @@ -1,38 +0,0 @@ -# Gopkg.toml example -# -# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html -# 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" -# -# [prune] -# non-go = false -# go-tests = true -# unused-packages = true - - -[[constraint]] - name = "github.com/pkg/errors" - version = "0.8.1" - -[[constraint]] - name = "github.com/stretchr/testify" - version = "1.3.0" - -[prune] - go-tests = true - unused-packages = true diff --git a/LICENSE b/LICENSE index 4ca14cc..2bbcbd9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 David Newhall II +Copyright (c) 2019-2021 David Newhall II Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/cameras.go b/cameras.go index c771f2d..f158dfd 100644 --- a/cameras.go +++ b/cameras.go @@ -1,9 +1,11 @@ package securityspy import ( + "fmt" "image" "image/jpeg" "io" + "io/ioutil" "net/http" "net/url" "os" @@ -11,7 +13,6 @@ import ( "strings" "time" - "github.com/pkg/errors" "golift.io/ffmpeg" ) @@ -20,6 +21,7 @@ func (c *Cameras) All() (cams []*Camera) { for _, cam := range c.server.systemInfo.CameraList.Cameras { cams = append(cams, c.setupCam(cam)) } + return } @@ -30,6 +32,7 @@ func (c *Cameras) ByNum(number int) *Camera { return c.setupCam(cam) } } + return nil } @@ -40,6 +43,7 @@ func (c *Cameras) ByName(name string) *Camera { return c.setupCam(cam) } } + return nil } @@ -54,16 +58,24 @@ func (c *Camera) StreamVideo(ops *VidOps, length time.Duration, maxsize int64) ( Size: maxsize, // max file size (always goes over). use 2000000 for 2.5MB Copy: true, // Always copy securityspy RTSP urls. }) + params := c.makeRequestParams(ops) + if c.server.Password != "" { params.Set("auth", c.server.Password) } + params.Set("codec", "h264") // This is kinda crude, but will handle 99%. url := strings.Replace(c.server.URL, "http", "rtsp", 1) + "++stream" + // RTSP doesn't rewally work with HTTPS, and FFMPEG doesn't care about the cert. args, video, err := f.GetVideo(url+"?"+params.Encode(), c.Name) - return video, errors.Wrap(err, strings.Replace(args, "\n", " ", -1)) + if err != nil { + return nil, fmt.Errorf("%w: %s", err, strings.ReplaceAll(args, "\n", " ")) + } + + return video, nil } // SaveVideo saves a segment of video from a camera to a file using FFMPEG. @@ -71,6 +83,7 @@ func (c *Camera) SaveVideo(ops *VidOps, length time.Duration, maxsize int64, out if _, err := os.Stat(outputFile); !os.IsNotExist(err) { return ErrorPathExists } + f := ffmpeg.Get(&ffmpeg.Config{ FFMPEG: Encoder, Time: int(length.Seconds()), @@ -80,43 +93,54 @@ func (c *Camera) SaveVideo(ops *VidOps, length time.Duration, maxsize int64, out }) params := c.makeRequestParams(ops) + if c.server.Password != "" { params.Set("auth", c.server.Password) } + params.Set("codec", "h264") // This is kinda crude, but will handle 99%. + url := strings.Replace(c.server.URL, "http", "rtsp", 1) + "++stream" + _, out, err := f.SaveVideo(url+"?"+params.Encode(), outputFile, c.Name) - return errors.Wrap(err, strings.Replace(out, "\n", " ", -1)) + if err != nil { + return fmt.Errorf("%w: %s", err, strings.ReplaceAll(out, "\n", " ")) + } + + return nil } // StreamMJPG makes a web request to retrieve a motion JPEG stream. // Returns an io.ReadCloser that will (hopefully) never end. func (c *Camera) StreamMJPG(ops *VidOps) (io.ReadCloser, error) { - resp, err := c.server.api.secReq("++video", c.makeRequestParams(ops), c.server.getClient(DefaultTimeout)) + resp, err := c.server.api.secReq("++video", c.makeRequestParams(ops), c.server.getClient()) if err != nil { return nil, err } + return resp.Body, nil } // StreamH264 makes a web request to retrieve an H264 stream. // Returns an io.ReadCloser that will (hopefully) never end. func (c *Camera) StreamH264(ops *VidOps) (io.ReadCloser, error) { - resp, err := c.server.api.secReq("++stream", c.makeRequestParams(ops), c.server.getClient(DefaultTimeout)) + resp, err := c.server.api.secReq("++stream", c.makeRequestParams(ops), c.server.getClient()) if err != nil { return nil, err } + return resp.Body, nil } // StreamG711 makes a web request to retrieve an G711 audio stream. // Returns an io.ReadCloser that will (hopefully) never end. func (c *Camera) StreamG711() (io.ReadCloser, error) { - resp, err := c.server.api.secReq("++audio", c.makeRequestParams(nil), c.server.getClient(DefaultTimeout)) + resp, err := c.server.api.secReq("++audio", c.makeRequestParams(nil), c.server.getClient()) if err != nil { return nil, err } + return resp.Body, nil } @@ -127,39 +151,46 @@ func (c *Camera) PostG711(audio io.ReadCloser) error { if audio == nil { return nil } - httpClient := c.server.api.getClient(DefaultTimeout) // use the api interface so it can be overridden. - req, err := http.NewRequest("POST", c.server.URL+"++audio", nil) + + req, err := http.NewRequest(http.MethodPost, c.server.URL+"++audio", nil) if err != nil { _ = audio.Close() - return errors.Wrap(err, "http.NewRequest()") + return fmt.Errorf("http.NewRequest(): %w", err) } else if c.server.Password != "" { req.URL.RawQuery = "auth=" + c.server.Password } + req.Header.Add("Content-Type", "audio/g711-ulaw") req.Body = audio // req.Body is automatically closed. - resp, err := httpClient.Do(req) + + resp, err := c.server.Client.Do(req) if err != nil { - return errors.Wrap(err, "http.Do(req)") + return fmt.Errorf("http.Do(req): %w", err) } - return resp.Body.Close() + defer resp.Body.Close() + + _, err = ioutil.ReadAll(resp.Body) + + return fmt.Errorf("ioutil.ReadAll(body): %w", err) } // GetJPEG returns an images from a camera. // VidOps defines the image size. ops.FPS is ignored. func (c *Camera) GetJPEG(ops *VidOps) (image.Image, error) { ops.FPS = -1 // not used for single image - resp, err := c.server.api.secReq("++image", c.makeRequestParams(ops), c.server.getClient(DefaultTimeout)) + + resp, err := c.server.api.secReq("++image", c.makeRequestParams(ops), c.server.getClient()) if err != nil { return nil, err } - defer func() { - _ = resp.Body.Close() - }() + defer resp.Body.Close() + jpgImage, err := jpeg.Decode(resp.Body) if err != nil { - return nil, err + return nil, fmt.Errorf("decoding jpeg: %w", err) } - return jpgImage, err + + return jpgImage, nil } // SaveJPEG gets a picture from a camera and puts it in a file (path). @@ -169,24 +200,31 @@ func (c *Camera) SaveJPEG(ops *VidOps, path string) error { if _, err := os.Stat(path); !os.IsNotExist(err) { return ErrorPathExists } + jpgImage, err := c.GetJPEG(ops) if err != nil { - return err + return fmt.Errorf("getting jpeg: %w", err) } + f, err := os.Create(path) if err != nil { - return err + return fmt.Errorf("os.Create: %w", err) + } + defer f.Close() + + err = jpeg.Encode(f, jpgImage, nil) + if err != nil { + return fmt.Errorf("encoding jpeg: %w", err) } - defer func() { - _ = f.Close() - }() - return jpeg.Encode(f, jpgImage, nil) + + return nil } // ToggleContinuous arms (true) or disarms (false) a camera's continuous capture mode. func (c *Camera) ToggleContinuous(arm CameraArmMode) error { params := make(url.Values) params.Set("arm", string(arm)) + return c.server.api.simpleReq("++ssControlContinuous", params, c.Number) } @@ -194,6 +232,7 @@ func (c *Camera) ToggleContinuous(arm CameraArmMode) error { func (c *Camera) ToggleMotion(arm CameraArmMode) error { params := make(url.Values) params.Set("arm", string(arm)) + return c.server.api.simpleReq("++ssControlMotionCapture", params, c.Number) } @@ -201,6 +240,7 @@ func (c *Camera) ToggleMotion(arm CameraArmMode) error { func (c *Camera) ToggleActions(arm CameraArmMode) error { params := make(url.Values) params.Set("arm", string(arm)) + return c.server.api.simpleReq("++ssControlActions", params, c.Number) } @@ -212,21 +252,23 @@ func (c *Camera) TriggerMotion() error { // SetSchedule configures a camera mode's primary schedule. // Get a list of schedules IDs you can use here from server.Info.Schedules. -// CameraModes are constants with names that start with CameraMode* +// CameraModes are constants with names that start with CameraMode*. func (c *Camera) SetSchedule(mode CameraMode, scheduleID int) error { params := make(url.Values) params.Set("mode", string(mode)) params.Set("id", strconv.Itoa(scheduleID)) + return c.server.api.simpleReq("++ssSetSchedule", params, c.Number) } // SetScheduleOverride temporarily overrides a camera mode's current schedule. // Get a list of overrides IDs you can use here from server.Info.ScheduleOverrides. -// CameraModes are constants with names that start with CameraMode* +// CameraModes are constants with names that start with CameraMode*. func (c *Camera) SetScheduleOverride(mode CameraMode, overrideID int) error { params := make(url.Values) params.Set("mode", string(mode)) - params.Set("id", string(overrideID)) + params.Set("id", strconv.Itoa(overrideID)) + return c.server.api.simpleReq("++ssSetOverride", params, c.Number) } @@ -243,33 +285,47 @@ func (c *Cameras) setupCam(cam *Camera) *Camera { cam.ScheduleOverrideA.Name = c.server.Info.ScheduleOverrides[cam.ScheduleOverrideA.ID] cam.ScheduleOverrideCC.Name = c.server.Info.ScheduleOverrides[cam.ScheduleOverrideCC.ID] cam.ScheduleOverrideMC.Name = c.server.Info.ScheduleOverrides[cam.ScheduleOverrideMC.ID] + return cam } -// makeRequestParams converts passed in ops to url.Values +const ( + maxQuality = 100 + maxFPS = 60 +) + +// makeRequestParams converts passed in ops to url.Values. func (c *Camera) makeRequestParams(ops *VidOps) url.Values { params := make(url.Values) params.Set("cameraNum", strconv.Itoa(c.Number)) + if ops == nil { return params } + if ops.Width != 0 { params.Set("width", strconv.Itoa(ops.Width)) } + if ops.Height != 0 { params.Set("height", strconv.Itoa(ops.Height)) } - if ops.Quality > 100 { - ops.Quality = 100 + + if ops.Quality > maxQuality { + ops.Quality = maxQuality } + if ops.Quality > 0 { params.Set("quality", strconv.Itoa(ops.Quality)) } + + if ops.FPS > maxFPS { + ops.FPS = maxFPS + } + if ops.FPS > 0 { - if ops.FPS > 60 { - ops.FPS = 60 - } params.Set("req_fps", strconv.Itoa(ops.FPS)) } + return params } diff --git a/cameras_test.go b/cameras_test.go index 6297713..d2e1f40 100644 --- a/cameras_test.go +++ b/cameras_test.go @@ -10,7 +10,9 @@ import ( func TestUnmarshalXMLCameraSchedule(t *testing.T) { t.Parallel() assert := assert.New(t) + var s CameraSchedule + err := xml.Unmarshal([]byte("3"), &s) assert.Nil(err, "valid data must not produce an error") assert.Equal(3, s.ID, "the data was not unmarshalled properly") @@ -19,11 +21,14 @@ func TestUnmarshalXMLCameraSchedule(t *testing.T) { func TestAll(t *testing.T) { t.Parallel() assert := assert.New(t) + server, _ := GetServer(&Config{Username: "user", Password: "pass", URL: "http://127.0.0.1:5678", VerifySSL: true}) fake := &fakeAPI{} fake.SecReqXMLReturns([]byte(testSystemInfo), nil) // Pass in a test XML payload. + server.api = fake assert.Nil(server.Refresh(), "there must no error when loading fake data") // load the fake testSystemInfo data. + cams := server.Cameras.All() assert.EqualValues(2, len(cams), "the data contains two cameras, two cameras must be returned") } @@ -31,11 +36,14 @@ func TestAll(t *testing.T) { func TestByNum(t *testing.T) { t.Parallel() assert := assert.New(t) + server, _ := GetServer(&Config{Username: "user", Password: "pass", URL: "http://127.0.0.1:5678", VerifySSL: true}) fake := &fakeAPI{} fake.SecReqXMLReturns([]byte(testSystemInfo), nil) // Pass in a test XML payload. + server.api = fake assert.Nil(server.Refresh(), "there must no error when loading fake data") // load the fake testSystemInfo data. + cam := server.Cameras.ByNum(1) assert.EqualValues("Porch", cam.Name, "camera 1 is Porch in the test data") assert.Nil(server.Cameras.ByNum(99), "a non-existent camera must return nil") @@ -44,11 +52,14 @@ func TestByNum(t *testing.T) { func TestByName(t *testing.T) { t.Parallel() assert := assert.New(t) + server, _ := GetServer(&Config{Username: "user", Password: "pass", URL: "http://127.0.0.1:5678", VerifySSL: true}) fake := &fakeAPI{} fake.SecReqXMLReturns([]byte(testSystemInfo), nil) // Pass in a test XML payload. + server.api = fake assert.Nil(server.Refresh(), "there must no error when loading fake data") // load the fake testSystemInfo data. + cam := server.Cameras.ByName("Porch") assert.EqualValues(1, cam.Number, "camera 1 is Porch in the test data") assert.Nil(server.Cameras.ByName("not here"), "a non-existent camera must return nil") diff --git a/events.go b/events.go index b2a948b..e36ccde 100644 --- a/events.go +++ b/events.go @@ -13,10 +13,11 @@ import ( // String provides a description of an event. func (e *Event) String() string { - txt, ok := EventNames[e.Type] - if !ok { + txt := EventName(e.Type) + if txt == "" { return UnknownEventText } + return txt } @@ -28,12 +29,15 @@ func (e *Events) BindFunc(event EventType, callBack func(Event)) { if callBack == nil { return } + e.binds.Lock() defer e.binds.Unlock() + if val, ok := e.eventBinds[event]; ok { e.eventBinds[event] = append(val, callBack) return } + e.eventBinds[event] = []func(Event){callBack} } @@ -44,12 +48,15 @@ func (e *Events) BindChan(event EventType, channel chan Event) { if channel == nil { return } + e.chans.Lock() defer e.chans.Unlock() + if val, ok := e.eventChans[event]; ok { e.eventChans[event] = append(val, channel) return } + e.eventChans[event] = []chan Event{channel} } @@ -59,17 +66,22 @@ func (e *Events) BindChan(event EventType, channel chan Event) { // Stop writing to the channels with Custom() before calling Stop(). func (e *Events) Stop(closeChans bool) { defer func() { e.Running = false }() + if e.Running { e.custom(eventStreamStop, -1, -1, "") // signal + if e.stream != nil { _ = e.stream.Close() e.stream = nil } + close(e.eventChan) } + if !closeChans { return } + for _, chans := range e.eventChans { for i := range chans { close(chans[i]) @@ -81,10 +93,12 @@ func (e *Events) Stop(closeChans bool) { func (e *Events) UnbindAll() { e.binds.Lock() e.chans.Lock() + defer func() { e.binds.Unlock() e.chans.Unlock() }() + e.eventBinds = make(map[EventType][]func(Event)) e.eventChans = make(map[EventType][]chan Event) } @@ -93,14 +107,16 @@ func (e *Events) UnbindAll() { func (e *Events) UnbindChan(event EventType) { e.chans.Lock() defer e.chans.Unlock() + delete(e.eventChans, event) } // UnbindFunc removes all bound callbacks for a particular event. -// EventType is a set of constants that begin with Event* +// EventType is a set of constants that begin with Event*. func (e *Events) UnbindFunc(event EventType) { e.binds.Lock() defer e.binds.Unlock() + delete(e.eventBinds, event) } @@ -110,7 +126,8 @@ func (e *Events) UnbindFunc(event EventType) { // call this. Call Stop() to close the connection when you're done with it. func (e *Events) Watch(retryInterval time.Duration, refreshOnConfigChange bool) { e.Running = true - e.eventChan = make(chan Event, 1000) // allow 1000 events to buffer + e.eventChan = make(chan Event, EventBuffer) // allow 1000 events to buffer + go e.eventStreamSelector(refreshOnConfigChange, retryInterval) go e.eventStreamScanner() } @@ -126,6 +143,7 @@ func (e *Events) custom(t EventType, id int, cam int, msg string) { if !e.Running { return } + e.eventChan <- Event{ Time: time.Now().Round(time.Second), When: time.Now().Round(time.Second), @@ -141,15 +159,18 @@ func (e *Events) custom(t EventType, id int, cam int, msg string) { // eventStreamScanner connects to the securityspy event stream and fires events into a channel. func (e *Events) eventStreamScanner() { defer e.custom(EventStreamDisconnect, -10000, -1, "Connection Closed") + if err := e.eventStreamConnect(); err != nil { return } - e.custom(EventStreamConnect, -9999, -1, EventNames[EventStreamConnect]) + + e.custom(EventStreamConnect, -9999, -1, EventName(EventStreamConnect)) scanner := bufio.NewScanner(e.stream) scanner.Split(scanLinesCR) + for scanner.Scan() { // Constantly scan for new events, then report them to the event channel. - if text := scanner.Text(); strings.Count(text, " ") > 2 { + if text := scanner.Text(); strings.Count(text, " ") > 2 { // nolint:gomnd e.eventChan <- e.UnmarshalEvent(text) } } @@ -161,13 +182,14 @@ func (e *Events) eventStreamConnect() error { _ = e.stream.Close() e.stream = nil } - httpClient := e.server.api.getClient(0) // timeout=0 - httpParams := url.Values{"version": []string{"3"}} - resp, err := e.server.api.secReq("++eventStream", httpParams, httpClient) + + resp, err := e.server.api.secReq("++eventStream", url.Values{"version": []string{"3"}}, e.server.Client) if err != nil { return err } + e.stream = resp.Body + return nil } @@ -175,11 +197,11 @@ func (e *Events) eventStreamConnect() error { // Fires bound event call back functions. // Also reconnects to the event stream if the connection fails. // There is a "loop" that occurs among the eventStream* methods. -// Stop() properly handles the shutdown of the loop, so if can be safely restarted w/ Watch() +// Stop() properly handles the shutdown of the loop, so if can be safely restarted w/ Watch(). func (e *Events) eventStreamSelector(refreshOnConfigChange bool, retryInterval time.Duration) { Loop: for event := range e.eventChan { - switch event.Type { + switch event.Type { //nolint:exhaustive case eventStreamStop: break Loop // Stop() called. case EventConfigChange: @@ -193,6 +215,7 @@ Loop: e.eventStreamScanner() }() } + // All events run binds. e.binds.RLock() event.callBacks(e.eventBinds) @@ -208,7 +231,8 @@ func (e *Events) serverRefresh() { e.custom(EventWatcherRefreshFail, -9997, -1, err.Error()) return } - e.custom(EventWatcherRefreshed, -9998, -1, EventNames[EventWatcherRefreshed]) + + e.custom(EventWatcherRefreshed, -9998, -1, EventName(EventWatcherRefreshed)) } // UnmarshalEvent turns raw text into an Event that can fire callbacks. @@ -216,7 +240,8 @@ func (e *Events) serverRefresh() { /* [TIME] is specified in the order year, month, day, hour, minute, second and is always 14 characters long * [EVENT NUMBER] increases by 1 for each subsequent event * [CAMERA NUMBER] specifies the camera that this event relates to, for example CAM15 for camera number 15 - * [EVENT] describes the event: ARM_C, DISARM_C, ARM_M, DISARM_M, ARM_A, DISARM_A, ERROR, CONFIGCHANGE, MOTION, OFFLINE, ONLINE + * [EVENT] describes the event: ARM_C, DISARM_C, ARM_M, DISARM_M, ARM_A, DISARM_A, ERROR, + CONFIGCHANGE, MOTION, OFFLINE, ONLINE Example Event Stream Flow: (old, v4) 20190114200911 104519 CAM2 MOTION @@ -239,12 +264,14 @@ func (e *Events) serverRefresh() { 20190927092055 7 3 DISARM_M 20190927092056 8 3 OFFLINE */ func (e *Events) UnmarshalEvent(text string) Event { - var err error - parts := strings.SplitN(text, " ", 4) - newEvent := Event{Msg: parts[3], ID: -1, Time: time.Now()} + var ( + err error + parts = strings.SplitN(text, " ", 4) + newEvent = Event{Msg: parts[3], ID: -1, Time: time.Now()} + // Parse the time stamp; append the Offset from ++systemInfo to get the right time-location. + eventTime = fmt.Sprintf("%v%+03.0f", parts[0], e.server.Info.GmtOffset.Hours()) + ) - // Parse the time stamp; append the Offset from ++systemInfo to get the right time-location. - eventTime := fmt.Sprintf("%v%+03.0f", parts[0], e.server.Info.GmtOffset.Hours()) if newEvent.When, err = time.ParseInLocation(EventTimeFormat+"-07", eventTime, time.Local); err != nil { newEvent.When = time.Now() newEvent.Errors = append(newEvent.Errors, ErrorDateParseFail) @@ -252,7 +279,7 @@ func (e *Events) UnmarshalEvent(text string) Event { // Parse the ID if newEvent.ID, err = strconv.Atoi(parts[1]); err != nil { - newEvent.ID = -2 + newEvent.ID = BadID newEvent.Errors = append(newEvent.Errors, ErrorIDParseFail) } @@ -268,9 +295,10 @@ func (e *Events) UnmarshalEvent(text string) Event { // Parse and convert the type string to EventType. parts = strings.Split(newEvent.Msg, " ") + newEvent.Type = EventType(parts[0]) // Check if the type we just converted is a known event. - if _, ok := EventNames[newEvent.Type]; !ok { + if name := EventName(newEvent.Type); name == "" { newEvent.Errors = append(newEvent.Errors, ErrorUnknownEvent) newEvent.Type = EventUnknownEvent } @@ -279,20 +307,24 @@ func (e *Events) UnmarshalEvent(text string) Event { if newEvent.Type == EventTriggerAction || newEvent.Type == EventTriggerMotion && len(parts) == 2 { b, _ := strconv.Atoi(parts[1]) msg := "" + // Check if this bitmask contains any of our known reasons. for flag, txt := range Reasons { if b&int(flag) != 0 { if msg != "" { msg += ", " } + msg += txt } } + newEvent.Msg += " - Reasons: " + msg if msg == "" { newEvent.Msg += UnknownReasonText } } + return newEvent } @@ -305,17 +337,19 @@ func (e *Event) callBacks(binds map[EventType][]func(Event)) { } } } + if _, ok := binds[e.Type]; ok { callbacks(binds[e.Type]) } else if _, ok := binds[EventUnknownEvent]; ok && e.Type != EventUnknownEvent { callbacks(binds[EventUnknownEvent]) } + if _, ok := binds[EventAllEvents]; ok { callbacks(binds[EventAllEvents]) } } -// eventChans is run for each event to notify external channels +// eventChans is run for each event to notify external channels. func (e *Event) eventChans(chans map[EventType][]chan Event) { for _, t := range []EventType{e.Type, EventAllEvents} { if chans, ok := chans[t]; ok { @@ -331,14 +365,17 @@ func scanLinesCR(data []byte, atEOF bool) (advance int, token []byte, err error) if atEOF && len(data) == 0 { return 0, nil, ErrorDisconnect } + if i := bytes.IndexByte(data, '\r'); i >= 0 { // We have a full CR-terminated line. return i + 1, data[0:i], nil } + // If we're at EOF, we have a final, non-terminated line. Return it. if atEOF { return len(data), data, io.ErrShortBuffer } + // Request more data. return 0, nil, nil } diff --git a/events_types.go b/events_types.go index 64c6da5..438a966 100644 --- a/events_types.go +++ b/events_types.go @@ -1,39 +1,42 @@ package securityspy import ( + "fmt" "io" "sync" "time" - - "github.com/pkg/errors" ) // This is a list of errors returned by the Events methods. var ( // ErrorUnknownEvent never really returns, but will fire if SecuritySpy // adds new events this library doesn't know about. - ErrorUnknownEvent = errors.New("unknown event") + ErrorUnknownEvent = fmt.Errorf("unknown event") // ErrorCAMParseFail will return if the camera number in an event stream does not exist. // If you see this, run Refresh() more often, or fix your flaky camera connection. - ErrorCAMParseFail = errors.New("CAM parse failed") + ErrorCAMParseFail = fmt.Errorf("CAM parse failed") // ErrorIDParseFail will return if the camera number provided by the event stream is not a number. // This should never happen, but future versions of SecuritySpy could trigger this if formats change. - ErrorIDParseFail = errors.New("ID parse failed") + ErrorIDParseFail = fmt.Errorf("ID parse failed") // ErrorCAMMissing like the errors above should never return. // This is triggered by a corrupted event format. - ErrorCAMMissing = errors.New("camera number missing") + ErrorCAMMissing = fmt.Errorf("camera number missing") // ErrorDateParseFail will only trigger if the time stamp format for events changes. - ErrorDateParseFail = errors.New("timestamp parse failed") + ErrorDateParseFail = fmt.Errorf("timestamp parse failed") // ErrorDisconnect becomes the msg in a custom event when the SecSpy event stream is disconnected. - ErrorDisconnect = errors.New("server connection closed") + ErrorDisconnect = fmt.Errorf("server connection closed") ) const ( + // BadID happens when the ID cannot be parsed. + BadID = -2 + // EventBuffer is the channel buffer size for securityspy events. + EventBuffer = 10000 // UnknownEventText should only appear if SecuritySpy adds new event types. UnknownEventText = "Unknown Event" // UnknownReasonText should only appear if SecuritySpy adds new motion detection reasons. @@ -105,38 +108,40 @@ const ( eventStreamStop EventType = "STOP" ) -// EventNames contains the human readable names for each event. -var EventNames = map[EventType]string{ - EventArmContinuous: "Continuous Capture Armed", - EventDisarmContinuous: "Continuous Capture Disarmed", - EventArmMotion: "Motion Capture Armed", - EventDisarmMotion: "Motion Capture Disarmed", - EventArmActions: "Actions Armed", - EventDisarmActions: "Actions Disarmed", - EventSecSpyError: "SecuritySpy Error", - EventConfigChange: "Configuration Change", - EventMotionDetected: "Motion Detected", // Legacy (v4) - EventOffline: "Camera Offline", - EventOnline: "Camera Online", - EventClassify: "Classification", - EventTriggerMotion: "Triggered Motion", - EventTriggerAction: "Triggered Action", - EventFileWritten: "File Written", - EventKeepAlive: "Stream Keep Alive", - // The following belong to the library, not securityspy. - EventStreamDisconnect: "Event Stream Disconnected", - EventStreamConnect: "Event Stream Connected", - EventUnknownEvent: UnknownEventText, - EventAllEvents: "Any Event", - EventWatcherRefreshed: "SystemInfo Refresh Success", - EventWatcherRefreshFail: "SystemInfo Refresh Failure", - EventStreamCustom: "Custom Event", +// EventName returns the human readable names for each event. +func EventName(e EventType) string { + return map[EventType]string{ + EventArmContinuous: "Continuous Capture Armed", + EventDisarmContinuous: "Continuous Capture Disarmed", + EventArmMotion: "Motion Capture Armed", + EventDisarmMotion: "Motion Capture Disarmed", + EventArmActions: "Actions Armed", + EventDisarmActions: "Actions Disarmed", + EventSecSpyError: "SecuritySpy Error", + EventConfigChange: "Configuration Change", + EventMotionDetected: "Motion Detected", // Legacy (v4) + EventOffline: "Camera Offline", + EventOnline: "Camera Online", + EventClassify: "Classification", + EventTriggerMotion: "Triggered Motion", + EventTriggerAction: "Triggered Action", + EventFileWritten: "File Written", + EventKeepAlive: "Stream Keep Alive", + // The following belong to the library, not securityspy. + EventStreamDisconnect: "Event Stream Disconnected", + EventStreamConnect: "Event Stream Connected", + EventUnknownEvent: UnknownEventText, + EventAllEvents: "Any Event", + EventWatcherRefreshed: "SystemInfo Refresh Success", + EventWatcherRefreshFail: "SystemInfo Refresh Failure", + EventStreamCustom: "Custom Event", + }[e] } -// TriggerEvent represent the "Reason" a motion or action trigger occurred. v5+ +// TriggerEvent represent the "Reason" a motion or action trigger occurred. v5+ only. type TriggerEvent int -// These are the trigger reasons SecuritySpy exposes. v5+ +// These are the trigger reasons SecuritySpy exposes. v5+ only. const ( TriggerByMotion = TriggerEvent(1) << iota TriggerByAudio diff --git a/fake_data_test.go b/fake_data_test.go index 55a549e..c1db245 100644 --- a/fake_data_test.go +++ b/fake_data_test.go @@ -3,7 +3,8 @@ package securityspy // Test data for Test methods. // This is all copied directly from ++systemInfo, ++scripts and ++sounds -var testServerInfo = ` +const ( + testServerInfo = ` SecuritySpy 4.2.10 C02L1333A8J2FkXIZC2O @@ -26,7 +27,7 @@ var testServerInfo = ` 24 ` -var testCameraOne = ` + testCameraOne = ` 1 yes 2304 @@ -86,7 +87,7 @@ var testCameraOne = ` 63167 ` -var testCameraTwo = ` + testCameraTwo = ` 2 yes 2592 @@ -146,7 +147,7 @@ var testCameraTwo = ` 62975 ` -var testScheduleList = ` + testScheduleList = ` Unarmed 24/7 0 @@ -173,14 +174,14 @@ var testScheduleList = ` ` -var testSchedulePresetList = ` + testSchedulePresetList = ` MyFirstPreset 1930238093 ` -var testScheduleOverrideList = ` + testScheduleOverrideList = ` None 0 @@ -203,11 +204,11 @@ var testScheduleOverrideList = ` ` -var testSystemInfo = `` + testServerInfo + - `` + testCameraOne + testCameraTwo + `` + - testScheduleList + testScheduleOverrideList + testSchedulePresetList + `` + testSystemInfo = `` + testServerInfo + + `` + testCameraOne + testCameraTwo + `` + + testScheduleList + testScheduleOverrideList + testSchedulePresetList + `` -var testSoundsList = ` + testSoundsList = ` Beeps.aif Bell ring.aif @@ -231,7 +232,7 @@ var testSoundsList = ` Tink.aiff ` -var testScriptsList = ` + testScriptsList = ` Web-i Activate Relay 1.scpt Web-i Activate Relay 2.scpt @@ -250,3 +251,4 @@ var testScriptsList = ` WebRelay Activate Relay 7.scpt WebRelay Activate Relay 8.scpt ` +) diff --git a/files.go b/files.go index 1939e8b..960a039 100644 --- a/files.go +++ b/files.go @@ -5,14 +5,13 @@ package securityspy import ( "encoding/xml" + "fmt" "io" "net/url" "os" "strconv" "strings" "time" - - "github.com/pkg/errors" ) /* Files interface methods follow. */ @@ -41,31 +40,37 @@ func (f *Files) GetCCVideos(cameraNums []int, from, to time.Time) ([]*File, erro return f.getFiles(cameraNums, from, to, "ccFilesCheck", "") } +const fileParts = 2 + // GetFile returns a file based on the name. It makes a lot of assumptions about file paths. // Not all methods work with this. Avoid it if possible. This allows Get() and Save() to work // for an arbitrary file name. func (f *Files) GetFile(name string) (*File, error) { // 01-18-2019 10-17-53 M Porch.m4v => ++getfile/0/2019-01-18/01-18-2019+10-17-53+M+Porch.m4v var err error + file := &File{ Title: name, server: f.server, GmtOffset: f.server.Info.GmtOffset.Duration, } - if fileExtSplit := strings.Split(name, "."); len(fileExtSplit) != 2 { + + if fileExtSplit := strings.Split(name, "."); len(fileExtSplit) != fileParts { return file, ErrorInvalidName - } else if nameDateSplit := strings.Split(fileExtSplit[0], " "); len(fileExtSplit) < 2 { + } else if nameDateSplit := strings.Split(fileExtSplit[0], " "); len(fileExtSplit) < fileParts { return file, ErrorInvalidName - } else if file.Updated, err = time.Parse(fileDateFormat, nameDateSplit[0]); err != nil { + } else if file.Updated, err = time.Parse(FileDateFormat, nameDateSplit[0]); err != nil { return file, ErrorInvalidName } else if file.Camera = f.server.Cameras.ByName(nameDateSplit[len(nameDateSplit)-1]); file.Camera == nil { return file, ErrorCAMMissing } else if file.Link.Type = "video/quicktime"; fileExtSplit[1] == "jpg" { file.Link.Type = "image/jpeg" } + file.CameraNum = file.Camera.Number file.Link.HREF = "++getfile/" + strconv.Itoa(file.CameraNum) + "/" + - file.Updated.Format(downloadDateFormat) + "/" + url.QueryEscape(name) + file.Updated.Format(DownloadDateFormat) + "/" + url.QueryEscape(name) + return file, nil } @@ -77,23 +82,25 @@ func (f *File) Save(path string) (int64, error) { if _, err := os.Stat(path); !os.IsNotExist(err) { return 0, ErrorPathExists } + body, err := f.Get(true) if err != nil { return 0, err } - defer func() { - _ = body.Close() - }() + defer body.Close() + newFile, err := os.Create(path) if err != nil { - return 0, err + return 0, fmt.Errorf("os.Create(): %w", err) } + defer newFile.Close() + size, err := io.Copy(newFile, body) if err != nil { - _ = newFile.Close() return size, nil } - return size, newFile.Close() + + return size, nil } // Get opens a file from a SecuritySpy download href and returns the http.Body io.ReadCloser. @@ -102,13 +109,16 @@ func (f *File) Save(path string) (int64, error) { func (f *File) Get(highBandwidth bool) (io.ReadCloser, error) { // use high bandwidth (full size) file download. uri := strings.Replace(f.Link.HREF, "++getfile/", "++getfilelb/", 1) + if highBandwidth { uri = strings.Replace(f.Link.HREF, "++getfile/", "++getfilehb/", 1) } - resp, err := f.server.api.secReq(uri, make(url.Values), f.server.getClient(DefaultTimeout)) + + resp, err := f.server.api.secReq(uri, make(url.Values), f.server.Client) if err != nil { return nil, err } + return resp.Body, nil } @@ -116,14 +126,18 @@ func (f *File) Get(highBandwidth bool) (io.ReadCloser, error) { // getFiles is a helper function to do all the work for GetVideos, GetPhotos & GetAll. func (f *Files) getFiles(cameraNums []int, from, to time.Time, fileTypes, continuation string) ([]*File, error) { - var entries []*File - var feed fileFeed - params := makeFilesParams(cameraNums, from, to, fileTypes, continuation) + var ( + entries = []*File{} + feed fileFeed + params = makeFilesParams(cameraNums, from, to, fileTypes, continuation) + ) + if xmldata, err := f.server.api.secReqXML("++download", params); err != nil { return nil, err } else if err := xml.Unmarshal(xmldata, &feed); err != nil { - return nil, errors.Wrap(err, "xml.Unmarshal(++download)") + return nil, fmt.Errorf("xml.Unmarshal(++download): %w", err) } + for i := range feed.Entries { // Add the camera, server and file interfaces to every file entry. feed.Entries[i].Camera = f.server.Cameras.ByNum(feed.Entries[i].CameraNum) @@ -131,14 +145,17 @@ func (f *Files) getFiles(cameraNums []int, from, to time.Time, fileTypes, contin feed.Entries[i].GmtOffset = feed.GmtOffset.Duration entries = append(entries, feed.Entries[i]) } + // ++download automatically paginates. Follow the continuation. if feed.Continuation != "" && feed.Continuation != "FFFFFFFFFFFFFFFF" { moreFiles, err := f.getFiles(cameraNums, from, to, fileTypes, feed.Continuation) + if entries = append(entries, moreFiles...); err != nil { // We got some files, but one of the pages returned an error. return entries, err } } + return entries, nil } @@ -146,16 +163,20 @@ func (f *Files) getFiles(cameraNums []int, from, to time.Time, fileTypes, contin func makeFilesParams(cameraNums []int, from time.Time, to time.Time, fileTypes string, continuation string) url.Values { params := make(url.Values) params.Set("results", "1000") - params.Set("date1", from.Format(downloadDateFormat)) - params.Set("date2", to.Format(downloadDateFormat)) + params.Set("date1", from.Format(DownloadDateFormat)) + params.Set("date2", to.Format(DownloadDateFormat)) + for _, fileType := range strings.Split(fileTypes, "&") { params.Set(fileType, "1") } + for _, num := range cameraNums { params.Add("cameraNum", strconv.Itoa(num)) } + if continuation != "" { params.Set("continuation", continuation) } + return params } diff --git a/files_types.go b/files_types.go index de21b2d..5f5f8ab 100644 --- a/files_types.go +++ b/files_types.go @@ -2,30 +2,29 @@ package securityspy import ( "encoding/xml" + "fmt" "time" - - "github.com/pkg/errors" ) -var ( - // downloadDateFormat is the format the SecuritySpy ++download method accepts. +const ( + // DownloadDateFormat is the format the SecuritySpy ++download method accepts. // This matches the ++download inputs AND the folder names files are saved into. // The file1/file2 inputs this gets passed into are actually undocuemnted and were // created specifically for programmtic SDK access (ie. this library). - downloadDateFormat = "2006-01-02" - // Arbitrary date format used for saved files we hope doesn't change. + DownloadDateFormat = "2006-01-02" + // FileDateFormat is an arbitrary date format used for saved files; we hope doesn't change. // This is used in the actual name of files that are saved. No where else. // The GetFile() method uses this to construct arbitrary file download paths. - fileDateFormat = "01-02-2006" + FileDateFormat = "01-02-2006" ) // Errors returned by the Files type methods. var ( // ErrorPathExists returns when a requested write path already exists. - ErrorPathExists = errors.New("cannot overwrite existing path") + ErrorPathExists = fmt.Errorf("cannot overwrite existing path") // ErrorInvalidName returns when requesting a file download and the filename is invalid. - ErrorInvalidName = errors.New("invalid file name") + ErrorInvalidName = fmt.Errorf("invalid file name") ) // Files powers the Files interface. @@ -34,7 +33,7 @@ type Files struct { server *Server } -// fileFeed represents the XML data from ++download +// fileFeed represents the XML data from ++download api path. type fileFeed struct { XMLName xml.Name `xml:"feed"` BSL string `xml:"bsl,attr"` // http://www.bensoftware.com/ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..aa46fd7 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module golift.io/securityspy + +go 1.15 + +require ( + github.com/stretchr/testify v1.6.1 + golift.io/ffmpeg v1.0.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e4c5dae --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golift.io/ffmpeg v1.0.1 h1:uZZ/HEH9bydD6vjiGdwso80U8Wd3F6o/s4HN/de+N78= +golift.io/ffmpeg v1.0.1/go.mod h1:CtNdNCyVbHuMhA4RPR27UVG5ukLl1vlZjZpgNWhg+VQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ptz.go b/ptz.go index 36e8a9f..5cdc0b5 100644 --- a/ptz.go +++ b/ptz.go @@ -2,10 +2,9 @@ package securityspy import ( "encoding/xml" + "fmt" "net/url" "strconv" - - "github.com/pkg/errors" ) // Home sends a camera to the home position. @@ -58,6 +57,7 @@ func (z *PTZ) Zoom(in bool) error { if in { return z.ptzReq(ptzCommandZoomIn) } + return z.ptzReq(ptzCommandZoomOut) } @@ -80,8 +80,9 @@ func (z *PTZ) Preset(preset PTZpreset) error { return z.ptzReq(ptzCommandSavePreset7) case PTZpreset8: return z.ptzReq(ptzCommandSavePreset8) + default: + return ErrorPTZRange } - return ErrorPTZRange } // PresetSave instructs a preset to be permanently saved. good luck! @@ -103,8 +104,9 @@ func (z *PTZ) PresetSave(preset PTZpreset) error { return z.ptzReq(ptzCommandPreset7) case PTZpreset8: return z.ptzReq(ptzCommandPreset8) + default: + return ErrorPTZRange } - return ErrorPTZRange } // Stop instructs a camera to stop moving, assuming it supports continuous movement. @@ -118,6 +120,7 @@ func (z *PTZ) Stop() error { func (z *PTZ) ptzReq(command ptzCommand) error { params := make(url.Values) params.Set("command", strconv.Itoa(int(command))) + return z.camera.server.api.simpleReq("++ptz/command", params, z.camera.Number) } @@ -125,12 +128,14 @@ func (z *PTZ) ptzReq(command ptzCommand) error { // This isn't a method you should ever call directly; it is only used during data initialization. func (z *PTZ) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { if err := d.DecodeElement(&z.rawCaps, &start); err != nil { - return errors.Wrap(err, "ptz caps") + return fmt.Errorf("ptz caps: %w", err) } + z.HasPanTilt = z.rawCaps&ptzPanTilt == ptzPanTilt z.HasHome = z.rawCaps&ptzHome == ptzHome z.HasZoom = z.rawCaps&ptzZoom == ptzZoom z.HasPresets = z.rawCaps&ptzPresets == ptzPresets z.Continuous = z.rawCaps&ptzContinuous == ptzContinuous + return nil } diff --git a/ptz_types.go b/ptz_types.go index b0b13c4..391ec52 100644 --- a/ptz_types.go +++ b/ptz_types.go @@ -1,14 +1,14 @@ package securityspy -import "github.com/pkg/errors" +import "fmt" var ( // ErrorPTZNotOK is returned for any command that has a successful web request, // but the reply does not end with the word OK. - ErrorPTZNotOK = errors.New("PTZ command not OK") + ErrorPTZNotOK = fmt.Errorf("PTZ command not OK") // ErrorPTZRange returns when a PTZ preset outside of 1-8 is provided. - ErrorPTZRange = errors.New("PTZ preset out of range 1-8") + ErrorPTZRange = fmt.Errorf("PTZ preset out of range 1-8") ) // PTZ are what "things" a camera can do. Use the bound methods to interact @@ -23,7 +23,7 @@ type PTZ struct { Continuous bool // true if the camera supports continuous movement. } -// PTZpreset locks our presets to a max of 8 +// PTZpreset locks our presets to a max of 8. type PTZpreset rune // Presets are 1 through 8. Use these constants as inputs to the PTZ methods. diff --git a/schedules.go b/schedules.go index 0a92ebf..a9c0378 100644 --- a/schedules.go +++ b/schedules.go @@ -14,9 +14,10 @@ import ( */ // SetSchedulePreset invokes a schedule preset. This [may/will] affect all camera arm modes. -// Find preset IDs you can pass into this method at server.Info.SchedulePresets +// Find preset IDs you can pass into this method at server.Info.SchedulePresets. func (s *Server) SetSchedulePreset(presetID int) error { params := make(url.Values) params.Set("id", strconv.Itoa(presetID)) + return s.api.simpleReq("++ssSetPreset", params, -1) } diff --git a/schedules_test.go b/schedules_test.go index 7eaf1c5..7a12a2e 100644 --- a/schedules_test.go +++ b/schedules_test.go @@ -10,12 +10,15 @@ import ( func TestSetSchedulePreset(t *testing.T) { t.Parallel() + assert := assert.New(t) server, _ := GetServer(&Config{Username: "user", Password: "pass", URL: "http://127.0.0.1:5678", VerifySSL: false}) fake := &fakeAPI{} // create a fake api interface that provides introspection methods. server.api = fake // override our internal api interface with a fake interface. + assert.Nil(server.SetSchedulePreset(1), "this method must not return an error during testing") assert.Equal(1, fake.SimpleReqCallCount(), "this method must call simpleReq() exactly once") + cmd, params, cameraNum := fake.SimpleReqArgsForCall(0) // check the web request parameters assert.Equal(url.Values{"id": []string{"1"}}, params, "the presetID sent to securityspy was incorrect") assert.Equal("++ssSetPreset", cmd, "the wrong command was used to invoke a securityspy schedule preset") @@ -24,14 +27,19 @@ func TestSetSchedulePreset(t *testing.T) { func TestUnmarshalXMLscheduleContainer(t *testing.T) { t.Parallel() + assert := assert.New(t) + var s scheduleContainer + err := xml.Unmarshal([]byte(testScheduleList), &s) assert.Nil(err, "valid data must not produce an error") assert.Equal("Armed 24/7", s[1], "the scheduleContainer data did not unmarshal properly") assert.Equal(6, len(s)) + err = xml.Unmarshal([]byte(""), &s) assert.NotNil(err, "invalid data must produce an error") + err = xml.Unmarshal([]byte(""), &s) assert.NotNil(err, "invalid data must produce an error") } diff --git a/schedules_types.go b/schedules_types.go index 914df30..c67fa31 100644 --- a/schedules_types.go +++ b/schedules_types.go @@ -2,6 +2,7 @@ package securityspy import ( "encoding/xml" + "fmt" ) // CameraMode is a set of constants to deal with three specific camera modes. @@ -27,15 +28,18 @@ func (m *scheduleContainer) UnmarshalXML(d *xml.Decoder, start xml.StartElement) Name string `xml:"name"` ID int `xml:"id"` } + token, err := d.Token() if err != nil { - return err + return fmt.Errorf("bad XML token: %w", err) } + switch e := token.(type) { case xml.StartElement: if err = d.DecodeElement(&schedule, &e); err != nil { - return err + return fmt.Errorf("XML decode: %w", err) } + (*m)[schedule.ID] = schedule.Name case xml.EndElement: if e == start.End() { diff --git a/securityspy.go b/securityspy.go index 1914c25..b1b8e44 100644 --- a/securityspy.go +++ b/securityspy.go @@ -6,14 +6,13 @@ import ( "crypto/tls" "encoding/base64" "encoding/xml" + "fmt" "io/ioutil" "net/http" "net/url" "strconv" "strings" "time" - - "github.com/pkg/errors" ) // GetServer returns an iterface to interact with SecuritySpy. @@ -24,9 +23,11 @@ func GetServer(c *Config) (*Server, error) { Config: c, systemInfo: &systemInfo{Server: &ServerInfo{}}, } + if !strings.HasSuffix(server.URL, "/") { server.URL += "/" } + if server.Username != "" && server.Password != "" { server.Password = base64.URLEncoding.EncodeToString([]byte(server.Username + ":" + server.Password)) } @@ -35,10 +36,12 @@ func GetServer(c *Config) (*Server, error) { server.api = server server.Info = server.systemInfo.Server server.Files = &Files{server: server} - server.Events = &Events{server: server, + server.Events = &Events{ + server: server, eventBinds: make(map[EventType][]func(Event)), eventChans: make(map[EventType][]chan Event), } + return server, server.Refresh() } @@ -47,10 +50,11 @@ func GetServer(c *Config) (*Server, error) { func (s *Server) Refresh() error { s.Info.Lock() defer s.Info.Unlock() + if xmldata, err := s.api.secReqXML("++systemInfo", nil); err != nil { return err - } else if err := xml.Unmarshal(xmldata, s.systemInfo); err != nil { - return errors.Wrap(err, "xml.Unmarshal(++systemInfo)") + } else if err := xml.Unmarshal(xmldata, &s.systemInfo); err != nil { + return fmt.Errorf("xml.Unmarshal(++systemInfo): %w", err) } s.Info.Refreshed = time.Now() @@ -59,11 +63,13 @@ func (s *Server) Refresh() error { s.Info.SchedulePresets = s.systemInfo.SchedulePresets s.Info.ScheduleOverrides = s.systemInfo.ScheduleOverrides s.Cameras = &Cameras{server: s} + // Collect the camera names and numbers for user convenience. for _, cam := range s.systemInfo.CameraList.Cameras { s.Cameras.Names = append(s.Cameras.Names, cam.Name) s.Cameras.Numbers = append(s.Cameras.Numbers, cam.Number) } + return nil } @@ -73,11 +79,13 @@ func (s *Server) GetScripts() ([]string, error) { var val struct { Names []string `xml:"name"` } + if xmldata, err := s.api.secReqXML("++scripts", nil); err != nil { return nil, err } else if err := xml.Unmarshal(xmldata, &val); err != nil { - return nil, errors.Wrap(err, "xml.Unmarshal(++scripts)") + return nil, fmt.Errorf("xml.Unmarshal(++scripts): %w", err) } + return val.Names, nil } @@ -87,75 +95,100 @@ func (s *Server) GetSounds() ([]string, error) { var val struct { Names []string `xml:"name"` } + if xmldata, err := s.api.secReqXML("++sounds", nil); err != nil { return nil, err } else if err := xml.Unmarshal(xmldata, &val); err != nil { - return nil, errors.Wrap(err, "xml.Unmarshal(++sounds)") + return nil, fmt.Errorf("xml.Unmarshal(++sounds): %w", err) } + return val.Names, nil } /* INTERFACE HELPER METHODS FOLLOW */ -func (s *Server) getClient(timeout time.Duration) (httpClient *http.Client) { - return &http.Client{ - Timeout: timeout, - Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: !s.VerifySSL}}, +func (s *Server) getClient() (httpClient *http.Client) { + if s.Client != nil { + return s.Client + } + + timeout := s.Config.Timeout + if timeout == 0 { + timeout = defaultTimeout + } + + s.Client = &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: !s.VerifySSL}, //nolint:gosec + }, } + + return s.Client } -// secReq is a helper function that formats the http request to SecuritySpy +// secReq is a helper function that formats the http request to SecuritySpy. func (s *Server) secReq(apiPath string, params url.Values, httpClient *http.Client) (*http.Response, error) { + if httpClient == nil { + httpClient = s.getClient() + } + if params == nil { params = make(url.Values) } + if s.Password != "" { params.Set("auth", s.Password) } - req, err := http.NewRequest("GET", s.URL+apiPath, nil) + + req, err := http.NewRequest(http.MethodGet, s.URL+apiPath, nil) if err != nil { - return nil, errors.Wrap(err, "http.NewRequest()") + return nil, fmt.Errorf("http.NewRequest(): %w", err) } + if a := apiPath; !strings.HasPrefix(a, "++getfile") && !strings.HasPrefix(a, "++event") && !strings.HasPrefix(a, "++image") && !strings.HasPrefix(a, "++audio") && !strings.HasPrefix(a, "++stream") && !strings.HasPrefix(a, "++video") { params.Set("format", "xml") req.Header.Add("Accept", "application/xml") } + req.URL.RawQuery = params.Encode() + return httpClient.Do(req) } // secReqXML returns raw http body, so it can be unmarshaled into an xml struct. func (s *Server) secReqXML(apiPath string, params url.Values) ([]byte, error) { - resp, err := s.api.secReq(apiPath, params, s.getClient(DefaultTimeout)) + resp, err := s.api.secReq(apiPath, params, s.Client) if err != nil { return nil, err } - defer func() { - _ = resp.Body.Close() - }() + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { - return nil, errors.Errorf("request failed (%v): %v (status: %v/%v)", - s.Username, s.URL+apiPath, resp.StatusCode, resp.Status) + return nil, fmt.Errorf("request failed (%v): %v (status: %v/%v): %w", + s.Username, s.URL+apiPath, resp.StatusCode, resp.Status, err) } + return ioutil.ReadAll(resp.Body) } // simpleReq performes HTTP req, checks for OK at end of output. func (s *Server) simpleReq(apiURI string, params url.Values, cameraNum int) error { - if cameraNum != -1 { + if cameraNum >= 0 { params.Set("cameraNum", strconv.Itoa(cameraNum)) } - resp, err := s.api.secReq(apiURI, params, s.getClient(DefaultTimeout)) + + resp, err := s.api.secReq(apiURI, params, s.Client) if err != nil { return err } - defer func() { - _ = resp.Body.Close() - }() + defer resp.Body.Close() + if body, err := ioutil.ReadAll(resp.Body); err != nil || !strings.HasSuffix(string(body), "OK") { return ErrorCmdNotOK } + return nil } diff --git a/securityspy_test.go b/securityspy_test.go index 3cc7dd2..df2b061 100644 --- a/securityspy_test.go +++ b/securityspy_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/base64" "encoding/xml" + "fmt" "io/ioutil" "net" "net/http" @@ -14,18 +15,21 @@ import ( "testing" "time" - "github.com/pkg/errors" "github.com/stretchr/testify/assert" ) +var errTest = fmt.Errorf("error goes here") + func TestGetServer(t *testing.T) { t.Parallel() + assert := assert.New(t) URL := "http://127.0.0.1:5678" user := "user123" pass := "pass456" b64 := base64.URLEncoding.EncodeToString([]byte(user + ":" + pass)) server, err := GetServer(&Config{Username: user, Password: pass, URL: URL, VerifySSL: true}) + assert.NotNil(err, "there is no server at the address provided so an error must exist") assert.NotNil(server, "server must not be nil. even wiuth an error it must be returned") assert.NotNil(server.systemInfo, "systemInfo pointer must be created by GetServer") @@ -45,86 +49,107 @@ func TestGetServer(t *testing.T) { func TestRefresh(t *testing.T) { t.Parallel() + assert := assert.New(t) server, _ := GetServer(&Config{Username: "user", Password: "pass", URL: "http://127.0.0.1:5678", VerifySSL: false}) - fake := &fakeAPI{} // create a fake api interface that provides introspection methods. - server.api = fake // override our internal api interface with a fake interface. + fake := &fakeAPI{} // create a fake api interface that provides introspection methods. + server.api = fake // override our internal api interface with a fake interface. + fake.SecReqXMLReturns([]byte(testSystemInfo), nil) // Pass in a test XML payload. assert.Nil(server.Refresh(), "an error must not be returned while testing with an overridden api interface") // Make sure Refresh() did all the things it is supposed to do. - assert.EqualValues(server.systemInfo.Schedules, server.Info.ServerSchedules, "unexported server schedules must be copied into exported Info struct") - assert.EqualValues(server.systemInfo.SchedulePresets, server.Info.SchedulePresets, "unexported schedule presets must be copied into exported Info struct") - assert.EqualValues(server.systemInfo.ScheduleOverrides, server.Info.ScheduleOverrides, "unexported schedule overrides must be copied into exported Info struct") + assert.EqualValues(server.systemInfo.Schedules, server.Info.ServerSchedules, + "unexported server schedules must be copied into exported Info struct") + assert.EqualValues(server.systemInfo.SchedulePresets, server.Info.SchedulePresets, + "unexported schedule presets must be copied into exported Info struct") + assert.EqualValues(server.systemInfo.ScheduleOverrides, server.Info.ScheduleOverrides, + "unexported schedule overrides must be copied into exported Info struct") assert.Equal(server, server.Cameras.server, "server struct pointer must be copied into Cameras struct") assert.Equal(2, len(server.Cameras.Names), "both test data camera names must be saved into a convenience slice") assert.Equal(2, len(server.Cameras.Numbers), "both test data camera numbers must be saved into a convenience slice") - assert.WithinDuration(time.Now(), server.Info.Refreshed, time.Second, "Refreshed field must be updated by Refresh() method") + assert.WithinDuration(time.Now(), server.Info.Refreshed, time.Second, + "Refreshed field must be updated by Refresh() method") // Test that the data was unmarshalled properly. // These tests assume the test data does not change. assert.EqualValues("SecuritySpy", server.Info.Name, "the server's name was not properly unmarshalled") - assert.Equal("2019-02-10T15:53:23", server.Info.CurrentTime.Format("2006-01-02T15:04:05"), "the server's current time was not properly unmarshalled") + assert.Equal("2019-02-10T15:53:23", server.Info.CurrentTime.Format("2006-01-02T15:04:05"), + "the server's current time was not properly unmarshalled") assert.Equal(2304, server.systemInfo.CameraList.Cameras[0].Width, "camera info was not properly unmarshalled") assert.Equal("Road", server.systemInfo.CameraList.Cameras[1].Name, "camera info was not properly unmarshalled") assert.Equal("Unarmed 24/7", server.Info.ServerSchedules[0], "schedule info was not properly unmarshalled") assert.Equal("None", server.Info.ScheduleOverrides[0], "schedule override info was not properly unmarshalled") - assert.Equal("MyFirstPreset", server.Info.SchedulePresets[1930238093], "schedule preset info was not properly unmarshalled") + assert.Equal("MyFirstPreset", server.Info.SchedulePresets[1930238093], + "schedule preset info was not properly unmarshalled") // make sure bad xml returns an expected error fake.SecReqXMLReturns([]byte("broken"), nil) // Pass in a broken XML payload. - assert.Contains(server.Refresh().Error(), "xml.Unmarshal(++systemInfo)", "xml unmarhsalling must fail and produce this error") + assert.Contains(server.Refresh().Error(), "xml.Unmarshal(++systemInfo)", + "xml unmarhsalling must fail and produce this error") } func TestGetSounds(t *testing.T) { t.Parallel() + assert := assert.New(t) server, _ := GetServer(&Config{Username: "user", Password: "pass", URL: "http://127.0.0.1:5678", VerifySSL: false}) - fake := &fakeAPI{} // create a fake api interface that provides introspection methods. - server.api = fake // override our internal api interface with a fake interface. + fake := &fakeAPI{} // create a fake api interface that provides introspection methods. + server.api = fake // override our internal api interface with a fake interface. + fake.SecReqXMLReturns([]byte(testSoundsList), nil) // Pass in a test XML payload. + sounds, err := server.GetSounds() assert.Nil(err, "the method must not return an error when given valid XML to unmarshal") assert.Equal(20, len(sounds), "all 20 sounds must exist in the slice") assert.Equal("Beeps.aif", sounds[0], "the sound files were not properly unmarhsalled") // Test error conditions. - fake.SecReqXMLReturns([]byte(testSoundsList), errors.New("error goes here")) // Pass in a test XML payload. + fake.SecReqXMLReturns([]byte(testSoundsList), errTest) // Pass in a test XML payload. + _, err = server.GetSounds() assert.EqualError(err, "error goes here", "the error from secReqXML must be returned") fake.SecReqXMLReturns([]byte("bad xml goes here"), nil) // Pass in a bad XML payload. + _, err = server.GetSounds() assert.Contains(err.Error(), "xml.Unmarshal(++sounds)", "the error from xml.Unmarshal must be returned") } func TestGetScripts(t *testing.T) { t.Parallel() + assert := assert.New(t) server, _ := GetServer(&Config{Username: "user", Password: "pass", URL: "http://127.0.0.1:5678", VerifySSL: false}) - fake := &fakeAPI{} // create a fake api interface that provides introspection methods. - server.api = fake // override our internal api interface with a fake interface. + fake := &fakeAPI{} // create a fake api interface that provides introspection methods. + server.api = fake // override our internal api interface with a fake interface. + fake.SecReqXMLReturns([]byte(testScriptsList), nil) // Pass in a test XML payload. + scripts, err := server.GetScripts() assert.Nil(err, "the method must not return an error when given valid XML to unmarshal") assert.Equal(16, len(scripts), "all 16 scripts must exist in the slice") assert.Equal("Web-i Activate Relay 1.scpt", scripts[0], "the script files were not properly unmarhsalled") // Test error conditions. - fake.SecReqXMLReturns([]byte(testScriptsList), errors.New("error goes here")) // Pass in a test XML payload. + fake.SecReqXMLReturns([]byte(testScriptsList), errTest) // Pass in a test XML payload. + _, err = server.GetScripts() assert.EqualError(err, "error goes here", "the error from secReqXML must be returned") fake.SecReqXMLReturns([]byte("bad xml goes here"), nil) // Pass in a bad XML payload. + _, err = server.GetScripts() assert.Contains(err.Error(), "xml.Unmarshal(++scripts)", "the error from xml.Unmarshal must be returned") } func TestGetClient(t *testing.T) { t.Parallel() + assert := assert.New(t) - server := &Server{Config: &Config{VerifySSL: true}} - client := server.getClient(DefaultTimeout + 7*time.Second) - assert.Equal(DefaultTimeout+7*time.Second, client.Timeout, "timeout was not applied to the client") + server := &Server{Config: &Config{VerifySSL: true, Timeout: defaultTimeout + 7*time.Second}} + client := server.getClient() + // no way to check the verifySSL parameter? + assert.Equal(defaultTimeout+7*time.Second, client.Timeout, "timeout was not applied to the client") } func TestSecReq(t *testing.T) { @@ -140,10 +165,13 @@ func TestSecReq(t *testing.T) { assert.Nil(err, "the fake server must return an error writing to the client") assert.Equal(server.URL, "http://"+r.Host+"/", "the host was not set correctly in the request") }) + httpClient, close := testingHTTPClient(h) defer close() + resp, err := server.secReq("++path", make(url.Values), httpClient) assert.Nil(err, "the method must not return an error when given a valid server to query") + if err == nil { defer resp.Body.Close() assert.Equal(http.StatusOK, resp.StatusCode, "the server must return a 200 response code") @@ -163,30 +191,37 @@ func testingHTTPClient(handler http.Handler) (*http.Client, func()) { }, }, } + return client, fakeServer.Close } func TestSecReqXML(t *testing.T) { t.Parallel() + assert := assert.New(t) server, _ := GetServer(&Config{Username: "user", Password: "pass", URL: "http://some.host:5678", VerifySSL: false}) fake := &fakeAPI{} server.api = fake params := make(url.Values) + params.Add("myKey", "theValue") + client := &http.Response{ Body: ioutil.NopCloser(bytes.NewBufferString("Hello World")), StatusCode: http.StatusOK, } + fake.SecReqReturns(client, nil) + body, err := server.secReqXML("++foo", params) assert.Nil(err, "there must not be an error when input data is valid") assert.Equal("Hello World", string(body), "the wrong request response was provided") assert.Equal(1, fake.SecReqCallCount(), "secReq must be called exactly once per invocation") + calledWithPath, calledWithParams, calledWithClient := fake.SecReqArgsForCall(0) assert.Equal("++foo", calledWithPath, "the api path was not correct in the request") assert.Equal("theValue", calledWithParams.Get("myKey"), "the custom parameter was not set") - assert.Equal(DefaultTimeout, calledWithClient.Timeout, "default timeout must be applied to the request") + assert.Equal(defaultTimeout, calledWithClient.Timeout, "default timeout must be applied to the request") // try again with a bad status. client = &http.Response{ @@ -194,6 +229,7 @@ func TestSecReqXML(t *testing.T) { StatusCode: http.StatusForbidden, } fake.SecReqReturns(client, nil) + _, err = server.secReqXML("++foo", params) assert.Contains(err.Error(), "request failed", "the wrong error was returned") assert.Equal(2, fake.SecReqCallCount(), "secReq must be called exactly once per invocation") @@ -201,17 +237,21 @@ func TestSecReqXML(t *testing.T) { func TestSimpleReq(t *testing.T) { t.Parallel() + assert := assert.New(t) server, _ := GetServer(&Config{Username: "user", Password: "pass", URL: "http://some.host:5678", VerifySSL: false}) fake := &fakeAPI{} server.api = fake params := make(url.Values) + params.Add("myKey", "theValue") + client := &http.Response{ Body: ioutil.NopCloser(bytes.NewBufferString("Hello World")), StatusCode: http.StatusOK, } fake.SecReqReturns(client, nil) + err := server.simpleReq("++apipath", params, 3) assert.Equal(err, ErrorCmdNotOK, "hello world must produce an err") assert.Equal(1, fake.SecReqCallCount(), "secReq must be called exactly once per invocation") @@ -223,16 +263,19 @@ func TestSimpleReq(t *testing.T) { } fake.SecReqReturns(client, nil) + err = server.simpleReq("++apipath", params, 3) assert.Nil(err, "the responds ends with OK so we must have no error") assert.Equal(2, fake.SecReqCallCount(), "secReq must be called exactly once per invocation") + calledWithPath, calledWithParams, calledWithClient := fake.SecReqArgsForCall(1) assert.Equal("++apipath", calledWithPath, "the api path was not correct in the request") assert.Equal("3", calledWithParams.Get("cameraNum"), "the camera number was not in the parameters") - assert.Equal(DefaultTimeout, calledWithClient.Timeout, "default timeout must be applied to the request") + assert.Equal(defaultTimeout, calledWithClient.Timeout, "default timeout must be applied to the request") // test another error fake.SecReqReturns(client, ErrorCmdNotOK) + err = server.simpleReq("++apipath", params, 3) assert.Equal(ErrorCmdNotOK, err, "the error from secreq must be returned") assert.Equal(3, fake.SecReqCallCount(), "secReq must be called exactly once per invocation") @@ -240,15 +283,19 @@ func TestSimpleReq(t *testing.T) { func TestUnmarshalXMLYesNoBool(t *testing.T) { t.Parallel() + assert := assert.New(t) good := []string{"true", "yes", "1", "armed", "active", "enabled"} fail := []string{"anything", "else", "returns", "false", "including", "no", "0", "disarmed", "inactive", "disabled"} + var bit YesNoBool + for _, val := range good { assert.Nil(xml.Unmarshal([]byte(""+val+""), &bit), "unmarshalling must not produce an error") assert.True(bit.Val, "the value must unmarshal to true") assert.Equal(val, bit.Txt, "the value was not unmarshalled correctly") } + for _, val := range fail { assert.Nil(xml.Unmarshal([]byte(""+val+""), &bit), "unmarshalling must not produce an error") assert.False(bit.Val, "the value must unmarshal to false") @@ -258,9 +305,12 @@ func TestUnmarshalXMLYesNoBool(t *testing.T) { func TestUnmarshalXMLDuration(t *testing.T) { t.Parallel() + assert := assert.New(t) good := []string{"1", "20", "300", "4000", "50000", "666666"} + var bit Duration + for _, val := range good { assert.Nil(xml.Unmarshal([]byte(""+val+""), &bit), "unmarshalling must not produce an error") assert.Equal(val, bit.Val, "the value was not unmarshalled correctly") @@ -269,6 +319,7 @@ func TestUnmarshalXMLDuration(t *testing.T) { assert.Equal(num, bit.Seconds(), "the value was not unmarshalled correctly") assert.Equal(val, bit.Val, "the value was not unmarshalled correctly") } + // Test empty value. assert.Nil(xml.Unmarshal([]byte(""), &bit), "unmarshalling must not produce an error") assert.Equal("", bit.Val, "the value was not unmarshalled correctly") diff --git a/securityspy_types.go b/securityspy_types.go index f25edc0..f0e81a6 100644 --- a/securityspy_types.go +++ b/securityspy_types.go @@ -2,30 +2,31 @@ package securityspy import ( "encoding/xml" + "fmt" "net/http" "net/url" "strconv" "strings" "sync" "time" - - "github.com/pkg/errors" ) // ErrorCmdNotOK is returned for any command that has a successful web request, // but the reply does not end with the word OK. -var ErrorCmdNotOK = errors.New("command unsuccessful") +var ErrorCmdNotOK = fmt.Errorf("command unsuccessful") -// DefaultTimeout it used for almost every request to SecuritySpy. Adjust as needed. -var DefaultTimeout = 10 * time.Second +// defaultTimeout it used for almost every request to SecuritySpy. Adjust as needed. +const defaultTimeout = 10 * time.Second // Config is the input data for this library. Only set VerifySSL to true if your server // has a valid SSL certificate. The password is auto-repalced with a base64 encoded string. type Config struct { + Client *http.Client VerifySSL bool URL string Password string Username string + Timeout time.Duration } // Server is the main interface for this library. @@ -75,7 +76,7 @@ type ServerInfo struct { sync.RWMutex } -// systemInfo reresents ++systemInfo +// systemInfo reresents ++systemInfo api path. type systemInfo struct { XMLName xml.Name `xml:"system"` Server *ServerInfo `xml:"server"` @@ -96,7 +97,6 @@ type api interface { secReq(apiPath string, params url.Values, httpClient *http.Client) (resp *http.Response, err error) secReqXML(apiPath string, params url.Values) (body []byte, err error) simpleReq(apiURI string, params url.Values, cameraNum int) error - getClient(timeout time.Duration) (httpClient *http.Client) } // YesNoBool is used to capture strings into boolean format. If the string has @@ -114,6 +114,7 @@ func (bit *YesNoBool) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error _ = d.DecodeElement(&bit.Txt, &start) bit.Val = bit.Txt == "1" || strings.EqualFold(bit.Txt, "true") || strings.EqualFold(bit.Txt, "yes") || strings.EqualFold(bit.Txt, "armed") || strings.EqualFold(bit.Txt, "active") || strings.EqualFold(bit.Txt, "enabled") + return nil } @@ -128,6 +129,7 @@ type Duration struct { func (bit *Duration) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { _ = d.DecodeElement(&bit.Val, &start) r, _ := strconv.Atoi(bit.Val) + if bit.Duration = time.Second * time.Duration(r); bit.Val == "" { // In the context of this application -1ns will significantly make // obvious the fact that this value was empty and not a number. @@ -135,5 +137,6 @@ func (bit *Duration) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error // when one has yet to happen [since securityspy started]. bit.Duration = -1 } + return nil }