unifi_protect 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +21 -0
- data/.rspec +3 -0
- data/.rubocop.yml +32 -0
- data/.travis.yml +6 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +34 -0
- data/LICENSE.txt +21 -0
- data/README.md +144 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/exe/unifi_protect +317 -0
- data/lib/unifi_protect.rb +13 -0
- data/lib/unifi_protect/api.rb +169 -0
- data/lib/unifi_protect/camera.rb +62 -0
- data/lib/unifi_protect/camera_collection.rb +78 -0
- data/lib/unifi_protect/client.rb +27 -0
- data/lib/unifi_protect/nvr.rb +33 -0
- data/lib/unifi_protect/version.rb +5 -0
- data/unifi_protect.gemspec +29 -0
- metadata +67 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: c9f531ea3f45970257279878bed41050c57a9bfbf78ae14c4362bee0bf9dcad8
|
4
|
+
data.tar.gz: 0da6092519243113650c727c33939043489ff1bfc441696d9b5b03adeccba6d3
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 3a1e445d5190bf05f1e6435880dd2d8a68a019487f3ee09ddb853e1119bc9210e926f4394e03861686ba0b67c7dd1675c9a323ac2876bd73176333e499217c0d
|
7
|
+
data.tar.gz: 834f56311e33ac98adedbdf8837e9ad4a82b42b171775ec4eede72a9369fce7b00b2f36e9389abef21ac4448869bba8faaef41299c943278ca9af2aa665c5b52
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.rubocop.yml
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
AllCops:
|
2
|
+
TargetRubyVersion: 2.6
|
3
|
+
Lint/AmbiguousOperator:
|
4
|
+
Enabled: false
|
5
|
+
Layout/LineLength:
|
6
|
+
Max: 120
|
7
|
+
Metrics/BlockLength:
|
8
|
+
Max: 400
|
9
|
+
Style/TrailingCommaInArrayLiteral:
|
10
|
+
EnforcedStyleForMultiline: consistent_comma
|
11
|
+
Style/TrailingCommaInHashLiteral:
|
12
|
+
EnforcedStyleForMultiline: consistent_comma
|
13
|
+
Style/FormatString:
|
14
|
+
Enabled: false
|
15
|
+
Style/FormatStringToken:
|
16
|
+
Enabled: false
|
17
|
+
Style/Documentation:
|
18
|
+
Enabled: false
|
19
|
+
Metrics/ClassLength:
|
20
|
+
Enabled: false
|
21
|
+
Metrics/MethodLength:
|
22
|
+
Enabled: false
|
23
|
+
Metrics/AbcSize:
|
24
|
+
Enabled: false
|
25
|
+
Metrics/CyclomaticComplexity:
|
26
|
+
Enabled: false
|
27
|
+
Metrics/PerceivedComplexity:
|
28
|
+
Enabled: false
|
29
|
+
Style/SymbolArray:
|
30
|
+
MinSize: 1
|
31
|
+
Style/EmptyCaseCondition:
|
32
|
+
Enabled: false
|
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
unifi_protect (0.1.0)
|
5
|
+
|
6
|
+
GEM
|
7
|
+
remote: https://rubygems.org/
|
8
|
+
specs:
|
9
|
+
diff-lcs (1.3)
|
10
|
+
rake (12.3.3)
|
11
|
+
rspec (3.9.0)
|
12
|
+
rspec-core (~> 3.9.0)
|
13
|
+
rspec-expectations (~> 3.9.0)
|
14
|
+
rspec-mocks (~> 3.9.0)
|
15
|
+
rspec-core (3.9.2)
|
16
|
+
rspec-support (~> 3.9.3)
|
17
|
+
rspec-expectations (3.9.2)
|
18
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
19
|
+
rspec-support (~> 3.9.0)
|
20
|
+
rspec-mocks (3.9.1)
|
21
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
22
|
+
rspec-support (~> 3.9.0)
|
23
|
+
rspec-support (3.9.3)
|
24
|
+
|
25
|
+
PLATFORMS
|
26
|
+
ruby
|
27
|
+
|
28
|
+
DEPENDENCIES
|
29
|
+
rake (~> 12.0)
|
30
|
+
rspec (~> 3.0)
|
31
|
+
unifi_protect!
|
32
|
+
|
33
|
+
BUNDLED WITH
|
34
|
+
2.1.4
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2020 Jeremy Cole
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,144 @@
|
|
1
|
+
# An unofficial UniFi Protect API in Ruby
|
2
|
+
|
3
|
+
![Build Status for jeremycole/unifi_protect](https://travis-ci.org/jeremycole/unifi_protect.svg?branch=master)
|
4
|
+
|
5
|
+
This is an implementation of (parts of) [UniFi Protect](https://unifi-network.ui.com/building-security) API, which is primarily designed to support the local web interface. The API allows access to camera configuration, status, and of course the ability to collect real-time snapshots and export recorded video from the NVR.
|
6
|
+
|
7
|
+
Currently the API implemented in this Gem is read-only, but I do hope to allow setting at least some configuration such as camera parameters via the API. (For example, enabling or disabling IR modes, or zoom levels for cameras supporting optical zoom.)
|
8
|
+
|
9
|
+
_**Note**_: The details of the UniFi Protect API are unpublished and potentially unstable, so this is an unofficial and potentially equally unstable implementation of that API. This implementation is partly based on the Python [`unifi-protect-video-downloader`](https://github.com/unifi-toolbox/unifi-protect-video-downloader) script, as well as inspection of the actual API interactions through the UniFi Protect web interface.
|
10
|
+
|
11
|
+
## Installation
|
12
|
+
|
13
|
+
Add this line to your application's Gemfile:
|
14
|
+
|
15
|
+
```ruby
|
16
|
+
gem 'unifi_protect'
|
17
|
+
```
|
18
|
+
|
19
|
+
And then execute:
|
20
|
+
|
21
|
+
```
|
22
|
+
$ bundle install
|
23
|
+
```
|
24
|
+
|
25
|
+
Or install it yourself as:
|
26
|
+
|
27
|
+
```
|
28
|
+
$ gem install unifi_protect
|
29
|
+
```
|
30
|
+
|
31
|
+
## Usage of the library
|
32
|
+
|
33
|
+
Once installed, a `UnifiProtect` module is provided and its usage is fairly straightforward:
|
34
|
+
|
35
|
+
```ruby
|
36
|
+
require 'unifi_protect'
|
37
|
+
|
38
|
+
# Connect to the UniFi Protect API using HTTPS and basic authentication
|
39
|
+
u = UnifiProtect::Client.new(host: '1.2.3.4', username: 'bob', password: 'secret')
|
40
|
+
|
41
|
+
# Fetch all cameras as a CameraCollection:
|
42
|
+
u.cameras
|
43
|
+
|
44
|
+
# Return a CameraCollection for all cameras matching a Regexp:
|
45
|
+
u.cameras.match(name: /house/i)
|
46
|
+
|
47
|
+
# Some attributes can be filtered more simply, for example currently-connected cameras:
|
48
|
+
u.cameras.connected
|
49
|
+
|
50
|
+
# Filters can be chained together as they return new CameraCollection objects:
|
51
|
+
u.cameras.connected.recording.match(name: /^barn/i)
|
52
|
+
|
53
|
+
# If a single match is expected, #fetch will return #first from the collection directly:
|
54
|
+
c = u.cameras.fetch(name: 'Front Door')
|
55
|
+
|
56
|
+
# Once a Camera object is obtained, the current snapshot (in practice, one from within the past few
|
57
|
+
# seconds, cached by the NVR) can be retrieved from it using #snapshot, which returns a DownloadedFile
|
58
|
+
# object with #file and #size attributes:
|
59
|
+
d = c.snapshot
|
60
|
+
|
61
|
+
# => #<struct UnifiProtect::API::DownloadedFile file="5f33454c00d21103e701d84f_1598726026000.jpg", size=236855>
|
62
|
+
|
63
|
+
# Recorded video can be exported from the NVR as well, given a start_time and end_time; this returns
|
64
|
+
# a DownloadedFile object as well:
|
65
|
+
d = c.video_export(start_time: Time.now - 10, end_time: Time.now)
|
66
|
+
|
67
|
+
# => #<struct UnifiProtect::API::DownloadedFile file="5f33454c00d21103e701d84f_1598725975000_1598725985000.mp4", size=1019346>
|
68
|
+
```
|
69
|
+
|
70
|
+
## Usage of the command-line tool
|
71
|
+
|
72
|
+
A simple command-line tool is provided to make it especially easy to download snapshots and video from one or more cameras without doing any Ruby programming.
|
73
|
+
|
74
|
+
Listing cameras is simple using `--list-cameras` and supports a number of filters, such as `id`, `name`, `connected`, `recording`, etc.:
|
75
|
+
|
76
|
+
```
|
77
|
+
$ unifi_protect -H 1.2.3.4 -u bob -p secret --connected --name 'House' --list-cameras
|
78
|
+
ID Name Type State
|
79
|
+
xxxxxxxxxxxxxxxxxxxxxxxx Back of House Looking West UVC G3 Flex CONNECTED
|
80
|
+
yyyyyyyyyyyyyyyyyyyyyyyy Barn towards House UVC G3 Pro CONNECTED
|
81
|
+
zzzzzzzzzzzzzzzzzzzzzzzz Back of House Looking South UVC G3 Flex CONNECTED
|
82
|
+
```
|
83
|
+
|
84
|
+
More advanced filtering can be done using `--match`, `--match-i` and `--exact` arguments:
|
85
|
+
|
86
|
+
```
|
87
|
+
$ unifi_protect -H 1.2.3.4 -u bob -p secret --connected --match-i type=doorbell --list-cameras
|
88
|
+
|
89
|
+
ID Name Type State
|
90
|
+
xxxxxxxxxxxxxxxxxxxxxxxx Front Door UVC G4 Doorbell CONNECTED
|
91
|
+
```
|
92
|
+
|
93
|
+
More information about each camera can be obtained with `--describe-cameras`:
|
94
|
+
|
95
|
+
```
|
96
|
+
$ unifi_protect -H 1.2.3.4 -u bob -p secret --id yyyyyyyyyyyyyyyyyyyyyyyy --describe-cameras
|
97
|
+
|
98
|
+
Id : yyyyyyyyyyyyyyyyyyyyyyyy
|
99
|
+
Name : Barn towards House
|
100
|
+
Type : UVC G3 Pro
|
101
|
+
Mac : AABBCCDDEEFF
|
102
|
+
State : CONNECTED
|
103
|
+
Hardware Revision : 16
|
104
|
+
Firmware Version : 4.26.13
|
105
|
+
Firmware Build : 8a76001.200825.1028
|
106
|
+
Up Since : 2020-08-25 06:15:04 -0700
|
107
|
+
Connected Since : 2020-08-25 06:15:35 -0700
|
108
|
+
Last Motion : 2020-08-28 05:22:26 -0700
|
109
|
+
Last Ring : (none)
|
110
|
+
Last Seen : 2020-08-29 11:37:59 -0700
|
111
|
+
```
|
112
|
+
|
113
|
+
A current snapshot can be downloaded from each matched camera using `--snapshot`:
|
114
|
+
|
115
|
+
```
|
116
|
+
$ unifi_protect -H 1.2.3.4 -u bob -p secret --id yyyyyyyyyyyyyyyyyyyyyyyy --snapshot
|
117
|
+
|
118
|
+
Downloading snapshot from yyyyyyyyyyyyyyyyyyyyyyyy, Barn towards House... OK, 427 KiB.
|
119
|
+
```
|
120
|
+
|
121
|
+
Recorded video can be exported from each matched camera using `--video-export` with appropriate `--start-time` and either `--end-time` or `--duration` parameters:
|
122
|
+
|
123
|
+
```
|
124
|
+
$ unifi_protect -H 1.2.3.4 -u bob -p secret --name '^Barn' --video-export --start-time '2020-08-29 11:00:00 PDT' --duration 30s
|
125
|
+
|
126
|
+
Exporting video for 2 cameras from 2020-08-29 11:00:00 -0700 to 2020-08-29 11:00:30 -0700.
|
127
|
+
|
128
|
+
Downloading video from xxxxxxxxxxxxxxxxxxxxxxxx, Barn Driveway... OK, 3256 KiB.
|
129
|
+
Downloading video from yyyyyyyyyyyyyyyyyyyyyyyy, Barn towards House... OK, 3364 KiB.
|
130
|
+
```
|
131
|
+
|
132
|
+
## Development
|
133
|
+
|
134
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
135
|
+
|
136
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
137
|
+
|
138
|
+
## Contributing
|
139
|
+
|
140
|
+
Bug reports and pull requests are welcome [through GitHub](https://github.com/jeremycole/unifi_protect).
|
141
|
+
|
142
|
+
## License
|
143
|
+
|
144
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'bundler/setup'
|
5
|
+
require 'unifi_protect'
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
11
|
+
# require "pry"
|
12
|
+
# Pry.start
|
13
|
+
|
14
|
+
require 'irb'
|
15
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
data/exe/unifi_protect
ADDED
@@ -0,0 +1,317 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'optparse'
|
5
|
+
require 'ostruct'
|
6
|
+
require 'time'
|
7
|
+
require 'unifi_protect'
|
8
|
+
|
9
|
+
class UnifiProtectCommand
|
10
|
+
class BadOptionError < StandardError; end
|
11
|
+
|
12
|
+
NVR_FIELDS = %i[
|
13
|
+
id
|
14
|
+
name
|
15
|
+
type
|
16
|
+
mac
|
17
|
+
host
|
18
|
+
version
|
19
|
+
hardwareId
|
20
|
+
hardwarePlatform
|
21
|
+
hardwareRevision
|
22
|
+
firmwareVersion
|
23
|
+
upSince
|
24
|
+
].freeze
|
25
|
+
|
26
|
+
CAMERA_FIELDS = %i[
|
27
|
+
id
|
28
|
+
name
|
29
|
+
type
|
30
|
+
mac
|
31
|
+
state
|
32
|
+
hardwareRevision
|
33
|
+
firmwareVersion
|
34
|
+
firmwareBuild
|
35
|
+
upSince
|
36
|
+
connectedSince
|
37
|
+
lastMotion
|
38
|
+
lastRing
|
39
|
+
lastSeen
|
40
|
+
].freeze
|
41
|
+
|
42
|
+
attr_reader :options
|
43
|
+
attr_reader :option_parser
|
44
|
+
|
45
|
+
def initialize
|
46
|
+
initialize_options
|
47
|
+
end
|
48
|
+
|
49
|
+
def initialize_options
|
50
|
+
@options = OpenStruct.new(
|
51
|
+
host: nil,
|
52
|
+
port: 7443,
|
53
|
+
username: nil,
|
54
|
+
password: nil,
|
55
|
+
mode: [],
|
56
|
+
match: OpenStruct.new
|
57
|
+
)
|
58
|
+
end
|
59
|
+
|
60
|
+
DURATIONS = { 'd' => 24 * 60 * 60, 'h' => 60 * 60, 'm' => 60, 's' => 1 }.freeze
|
61
|
+
|
62
|
+
def parse_duration(str)
|
63
|
+
str.split(/([dhms])/).map { |t| DURATIONS[t] || t.to_i }.each_slice(2).map { |t, m| t * (m || 1) }.sum
|
64
|
+
end
|
65
|
+
|
66
|
+
def match_value(value)
|
67
|
+
return true if value == 'true'
|
68
|
+
return false if value == 'false'
|
69
|
+
|
70
|
+
value
|
71
|
+
end
|
72
|
+
|
73
|
+
def add_matcher(field, matcher)
|
74
|
+
options.match[field] ||= []
|
75
|
+
options.match[field] << match_value(matcher)
|
76
|
+
end
|
77
|
+
|
78
|
+
def parse_options(args)
|
79
|
+
@option_parser = OptionParser.new do |opts|
|
80
|
+
opts.on('-h', '--help', 'Show this help.') do
|
81
|
+
puts opts
|
82
|
+
exit 0
|
83
|
+
end
|
84
|
+
|
85
|
+
opts.on('-H', '--host=HOST', 'Hostname of the UniFi Protect controller.') { |o| options.host = o }
|
86
|
+
opts.on('-P', '--port=PORT', 'TCP Port of the UniFi Protect controller.') { |o| options.port = o.to_i }
|
87
|
+
opts.on('-u', '--username=USERNAME', 'Username for HTTP basic authentication.') { |o| options.username = o }
|
88
|
+
opts.on('-p', '--password=PASSWORD', 'Password for HTTP basic authentication.') { |o| options.password = o }
|
89
|
+
|
90
|
+
opts.on('--describe-nvr', 'Describe NVR in detail.') { options.mode << :describe_nvr }
|
91
|
+
|
92
|
+
opts.on('--list-cameras', 'List all matched cameras.') { options.mode << :list_cameras }
|
93
|
+
opts.on('--describe-cameras', 'Describe all matched cameras in detail.') { options.mode << :describe_cameras }
|
94
|
+
|
95
|
+
opts.on('-o', '--output-path=PATH', 'Specify a local path to save downloaded files to.') do |o|
|
96
|
+
options.output_path = o
|
97
|
+
end
|
98
|
+
|
99
|
+
opts.on('--snapshot', 'Download a recent snapshot from each matched camera.') { options.mode << :snapshot }
|
100
|
+
|
101
|
+
opts.on('--video-export', 'Download saved video for each matched camera.') { options.mode << :video_export }
|
102
|
+
|
103
|
+
opts.on('-s', '--start-time=TIME', "Start time for video, e.g. '2020-08-29 01:02:00 PDT'.") do |o|
|
104
|
+
options.start_time = Time.parse(o)
|
105
|
+
end
|
106
|
+
|
107
|
+
opts.on('-e', '--end-time=TIME', "End time for video, e.g. '2020-08-29 01:02:30 PDT'.") do |o|
|
108
|
+
options.end_time = Time.parse(o)
|
109
|
+
end
|
110
|
+
|
111
|
+
opts.on('-d', '--duration=DURATION', "Duration of video with units, e.g. '10s', '2m30s', or '1h10m'.") do |o|
|
112
|
+
options.end_time = options.start_time + parse_duration(o)
|
113
|
+
end
|
114
|
+
|
115
|
+
opts.on('--match=MATCH', 'Match cameras by field=value case-sensitively using a regular expression.') do |o|
|
116
|
+
o.split(',').each do |arg|
|
117
|
+
field, value = arg.split('=')
|
118
|
+
add_matcher(field, Regexp.new(value))
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
opts.on('--match-i=MATCH', 'Match cameras by field=value case-insensitively using a regular expression.') do |o|
|
123
|
+
o.split(',').each do |arg|
|
124
|
+
field, value = arg.split('=')
|
125
|
+
add_matcher(field, Regexp.new(value, Regexp::IGNORECASE))
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
opts.on('--exact=MATCH', 'Match cameras by field=value using an exact string match.') do |o|
|
130
|
+
o.split(',').each do |arg|
|
131
|
+
field, value = arg.split('=')
|
132
|
+
add_matcher(field, value)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
opts.on('--id=ID', 'Match cameras by exact camera ID.') do |o|
|
137
|
+
o.split(',').each { |id| add_matcher(:id, id) }
|
138
|
+
end
|
139
|
+
|
140
|
+
opts.on('--name=NAME', 'Match cameras by name using a case-insensitive regular expression.') do |o|
|
141
|
+
o.split(',').each { |name| add_matcher(:name, Regexp.new(name, Regexp::IGNORECASE)) }
|
142
|
+
end
|
143
|
+
|
144
|
+
opts.on('--[no-]connected', 'Match cameras currently connected.') do |o|
|
145
|
+
options.match.connected = o
|
146
|
+
end
|
147
|
+
|
148
|
+
opts.on('--[no-]recording', 'Match cameras currently recording.') do |o|
|
149
|
+
options.match.recording = o
|
150
|
+
end
|
151
|
+
|
152
|
+
opts.on('--[no-]dark', 'Match cameras currently detecting darkness.') do |o|
|
153
|
+
options.match.dark = o
|
154
|
+
end
|
155
|
+
|
156
|
+
opts.on('--[no-]motion-detected', 'Match cameras recently detecting motion.') do |o|
|
157
|
+
options.match.motion_detected = o
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
option_parser.parse!(args)
|
162
|
+
|
163
|
+
validate_options
|
164
|
+
|
165
|
+
self
|
166
|
+
end
|
167
|
+
|
168
|
+
def validate_options
|
169
|
+
raise BadOptionError, 'Missing required --host option' unless options.host
|
170
|
+
raise BadOptionError, 'Missing required --username option' unless options.username
|
171
|
+
raise BadOptionError, 'Missing required --password option' unless options.password
|
172
|
+
|
173
|
+
if options.mode.include?(:video_export) && !(options.start_time && options.end_time)
|
174
|
+
raise BadOptionError, 'The --video-export option requires --start-time and either --end-time or --duration'
|
175
|
+
end
|
176
|
+
|
177
|
+
true
|
178
|
+
end
|
179
|
+
|
180
|
+
def match_cameras
|
181
|
+
cameras = @client.cameras
|
182
|
+
|
183
|
+
options.match.to_h.each do |field, value|
|
184
|
+
# puts "Adding camera match filter on #{field} = #{value}"
|
185
|
+
case value
|
186
|
+
when TrueClass, FalseClass
|
187
|
+
cameras = cameras.filter(field, value)
|
188
|
+
else
|
189
|
+
cameras = cameras.match(field => value)
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
cameras
|
194
|
+
end
|
195
|
+
|
196
|
+
def to_name(field)
|
197
|
+
field.to_s.gsub(/([A-Z])/, ' \1').sub(/^[a-z]/, &:upcase)
|
198
|
+
end
|
199
|
+
|
200
|
+
def nvr
|
201
|
+
@client.nvr
|
202
|
+
end
|
203
|
+
|
204
|
+
def human_size(size)
|
205
|
+
format('%0.2f GiB', size / (1024.0 ** 3))
|
206
|
+
end
|
207
|
+
|
208
|
+
def describe_nvr
|
209
|
+
NVR_FIELDS.each do |field|
|
210
|
+
puts format('%-20s: %s', to_name(field), nvr.send(field) || '(none)')
|
211
|
+
end
|
212
|
+
|
213
|
+
puts format('%-20s:', 'Storage Info')
|
214
|
+
%i[totalSize totalSpaceUsed].each do |field|
|
215
|
+
puts format(' %-18s: %s', to_name(field), human_size(nvr.storageInfo.send(field)))
|
216
|
+
end
|
217
|
+
|
218
|
+
puts format(' %-18s:', 'Hard Drives')
|
219
|
+
nvr.storageInfo.hardDrives.each_with_index do |hd, i|
|
220
|
+
puts format(
|
221
|
+
' %-16s: %s',
|
222
|
+
format('Hard Drive %d', i),
|
223
|
+
format(
|
224
|
+
'%s (size=%s, serial=%s, health=%s)',
|
225
|
+
hd.name,
|
226
|
+
human_size(hd.size),
|
227
|
+
hd.serial,
|
228
|
+
hd.health
|
229
|
+
)
|
230
|
+
)
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
def cameras
|
235
|
+
@cameras ||= match_cameras
|
236
|
+
end
|
237
|
+
|
238
|
+
def list_cameras
|
239
|
+
return if cameras.empty?
|
240
|
+
|
241
|
+
puts format('%-30s%-40s%-20s%-20s', 'ID', 'Name', 'Type', 'State')
|
242
|
+
cameras.each do |camera|
|
243
|
+
puts format('%-30s%-40s%-20s%-20s', camera.id, camera.name, camera.type, camera.state)
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
247
|
+
def describe_cameras
|
248
|
+
cameras.each do |camera|
|
249
|
+
CAMERA_FIELDS.each do |field|
|
250
|
+
puts format('%-20s: %s', to_name(field), camera.send(field) || '(none)')
|
251
|
+
end
|
252
|
+
puts
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
def snapshot
|
257
|
+
cameras.each do |camera|
|
258
|
+
print "Downloading snapshot from #{camera.id}, #{camera.name}... "
|
259
|
+
|
260
|
+
begin
|
261
|
+
file = camera.snapshot
|
262
|
+
puts format('OK, %.0f KiB.', file.size / 1024.0)
|
263
|
+
rescue UnifiProtect::Camera::SnapshotError => e
|
264
|
+
puts "Failed: #{e}"
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
def video_export
|
270
|
+
puts "Exporting video for #{cameras.count} cameras from #{options.start_time} to #{options.end_time}."
|
271
|
+
puts
|
272
|
+
|
273
|
+
cameras.each do |camera|
|
274
|
+
print "Downloading video from #{camera.id}, #{camera.name}... "
|
275
|
+
|
276
|
+
begin
|
277
|
+
file = camera.video_export(start_time: options.start_time, end_time: options.end_time)
|
278
|
+
puts format('OK, %.0f KiB.', file.size / 1024.0)
|
279
|
+
rescue UnifiProtect::Camera::VideoExportError => e
|
280
|
+
puts "Failed: #{e}"
|
281
|
+
end
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
def run
|
286
|
+
if options.output_path
|
287
|
+
raise "output path #{options.output_path} does not exist" unless File.exist?(options.output_path)
|
288
|
+
end
|
289
|
+
|
290
|
+
@client = UnifiProtect::Client.new(
|
291
|
+
host: options.host,
|
292
|
+
port: options.port,
|
293
|
+
username: options.username,
|
294
|
+
password: options.password,
|
295
|
+
download_path: options.output_path
|
296
|
+
)
|
297
|
+
|
298
|
+
options.mode.each do |mode|
|
299
|
+
case mode
|
300
|
+
when :describe_nvr
|
301
|
+
describe_nvr
|
302
|
+
when :list_cameras
|
303
|
+
list_cameras
|
304
|
+
when :describe_cameras
|
305
|
+
describe_cameras
|
306
|
+
when :snapshot
|
307
|
+
snapshot
|
308
|
+
when :video_export
|
309
|
+
video_export
|
310
|
+
end
|
311
|
+
|
312
|
+
puts
|
313
|
+
end
|
314
|
+
end
|
315
|
+
end
|
316
|
+
|
317
|
+
UnifiProtectCommand.new.parse_options(ARGV).run
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'unifi_protect/version'
|
4
|
+
require 'unifi_protect/api'
|
5
|
+
require 'unifi_protect/client'
|
6
|
+
require 'unifi_protect/nvr'
|
7
|
+
require 'unifi_protect/camera'
|
8
|
+
require 'unifi_protect/camera_collection'
|
9
|
+
|
10
|
+
module UnifiProtect
|
11
|
+
class Error < StandardError; end
|
12
|
+
# Your code goes here...
|
13
|
+
end
|
@@ -0,0 +1,169 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'net/http'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
module UnifiProtect
|
7
|
+
class API
|
8
|
+
class RefreshBearerTokenError < StandardError; end
|
9
|
+
class RequestError < StandardError; end
|
10
|
+
|
11
|
+
DownloadedFile = Struct.new(:file, :size, keyword_init: true)
|
12
|
+
|
13
|
+
def initialize(host: nil, port: 7443, username: nil, password: nil, download_path: nil)
|
14
|
+
@host = host
|
15
|
+
@port = port
|
16
|
+
@username = username
|
17
|
+
@password = password
|
18
|
+
@download_path = download_path
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_s
|
22
|
+
"#<#{self.class.name} base_uri=#{base_uri.to_s.inspect} username=#{@username.inspect}>"
|
23
|
+
end
|
24
|
+
|
25
|
+
def inspect
|
26
|
+
to_s
|
27
|
+
end
|
28
|
+
|
29
|
+
def base_uri
|
30
|
+
URI::HTTPS.build(host: @host, port: @port, path: '/api/')
|
31
|
+
end
|
32
|
+
|
33
|
+
def uri(path:, query: nil)
|
34
|
+
uri = URI.join(base_uri, path)
|
35
|
+
uri.query = query if query
|
36
|
+
|
37
|
+
uri
|
38
|
+
end
|
39
|
+
|
40
|
+
def new_http_client
|
41
|
+
http_client = Net::HTTP.new(base_uri.host, base_uri.port)
|
42
|
+
http_client.use_ssl = true
|
43
|
+
http_client.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
44
|
+
|
45
|
+
http_client
|
46
|
+
end
|
47
|
+
|
48
|
+
def http_client
|
49
|
+
@http_client ||= new_http_client
|
50
|
+
end
|
51
|
+
|
52
|
+
def http_post_with_username_password(uri)
|
53
|
+
headers = {
|
54
|
+
'Content-Type' => 'application/json',
|
55
|
+
}
|
56
|
+
request = Net::HTTP::Post.new(uri.request_uri, headers)
|
57
|
+
request.body = { username: @username, password: @password }.to_json
|
58
|
+
|
59
|
+
request
|
60
|
+
end
|
61
|
+
|
62
|
+
def http_post_with_bearer_token(uri, body: nil)
|
63
|
+
headers = {
|
64
|
+
'Content-Type' => 'application/json',
|
65
|
+
'Authorization' => 'Bearer ' + bearer_token,
|
66
|
+
}
|
67
|
+
request = Net::HTTP::Post.new(uri.request_uri, headers)
|
68
|
+
request.body = body
|
69
|
+
|
70
|
+
request
|
71
|
+
end
|
72
|
+
|
73
|
+
def http_get_with_bearer_token(uri)
|
74
|
+
headers = {
|
75
|
+
'Authorization' => 'Bearer ' + bearer_token,
|
76
|
+
}
|
77
|
+
Net::HTTP::Get.new(uri.request_uri, headers)
|
78
|
+
end
|
79
|
+
|
80
|
+
def http_request_with_bearer_token(uri, method: :get, body: nil)
|
81
|
+
return http_get_with_bearer_token(uri) if method == :get
|
82
|
+
return http_post_with_bearer_token(uri, body: body) if method == :post
|
83
|
+
|
84
|
+
nil
|
85
|
+
end
|
86
|
+
|
87
|
+
def refresh_bearer_token
|
88
|
+
response = http_client.request(http_post_with_username_password(uri(path: 'auth')))
|
89
|
+
|
90
|
+
raise RefreshBearerTokenError, "#{response.code} #{response.msg}: #{response.body}" unless response.code == '200'
|
91
|
+
|
92
|
+
@bearer_token = response['Authorization']
|
93
|
+
end
|
94
|
+
|
95
|
+
def bearer_token
|
96
|
+
@bearer_token ||= refresh_bearer_token
|
97
|
+
end
|
98
|
+
|
99
|
+
def request_with_raw_response(uri, method: :get, body: nil, exception_class: RequestError)
|
100
|
+
response = http_client.request(http_request_with_bearer_token(uri, method: method, body: body))
|
101
|
+
|
102
|
+
raise exception_class, "#{response.code} #{response.msg}: #{response.body}" unless response.code == '200'
|
103
|
+
|
104
|
+
response.body
|
105
|
+
end
|
106
|
+
|
107
|
+
def request_with_json_response(uri, method: :get, body: nil, exception_class: RequestError)
|
108
|
+
response = http_client.request(http_request_with_bearer_token(uri, method: method, body: body))
|
109
|
+
|
110
|
+
raise exception_class, "#{response.code} #{response.msg}: #{response.body}" unless response.code == '200'
|
111
|
+
|
112
|
+
JSON.parse(response.body, object_class: OpenStruct)
|
113
|
+
end
|
114
|
+
|
115
|
+
def request_with_chunked_response(uri, method: :get, body: nil, exception_class: RequestError)
|
116
|
+
raise 'no block provided' unless block_given?
|
117
|
+
|
118
|
+
http_client.request(http_request_with_bearer_token(uri, method: method, body: body)) do |response|
|
119
|
+
raise exception_class, "#{response.code} #{response.msg}: #{response.body}" unless response.code == '200'
|
120
|
+
|
121
|
+
chunk_total = 0
|
122
|
+
response.read_body do |chunk|
|
123
|
+
chunk_total += chunk.size
|
124
|
+
yield chunk, chunk_total, response.content_length
|
125
|
+
end
|
126
|
+
|
127
|
+
response
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def download_file(uri, method: :get, body: nil, local_file:)
|
132
|
+
file = local_file
|
133
|
+
file = File.join(@download_path, file) if @download_path
|
134
|
+
|
135
|
+
File.open(file, 'wb') do |f|
|
136
|
+
r = request_with_chunked_response(uri, method: method, body: body) do |chunk, _total, _length|
|
137
|
+
f.write(chunk)
|
138
|
+
end
|
139
|
+
|
140
|
+
DownloadedFile.new(file: file, size: r.content_length)
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def bootstrap_json
|
145
|
+
request_with_raw_response(uri(path: 'bootstrap'))
|
146
|
+
end
|
147
|
+
|
148
|
+
def bootstrap
|
149
|
+
request_with_json_response(uri(path: 'bootstrap'))
|
150
|
+
end
|
151
|
+
|
152
|
+
def camera_snapshot(camera:, local_file: nil, time: Time.now)
|
153
|
+
ts = time.utc.to_i * 1000
|
154
|
+
local_file ||= "#{camera}_#{ts}.jpg"
|
155
|
+
|
156
|
+
query = URI.encode_www_form(force: true, ts: ts)
|
157
|
+
download_file(uri(path: "cameras/#{camera}/snapshot", query: query), local_file: local_file)
|
158
|
+
end
|
159
|
+
|
160
|
+
def video_export(camera:, start_time:, end_time:, local_file: nil)
|
161
|
+
start_ts = start_time.utc.to_i * 1000
|
162
|
+
end_ts = end_time.utc.to_i * 1000
|
163
|
+
local_file ||= "#{camera}_#{start_ts}_#{end_ts}.mp4"
|
164
|
+
|
165
|
+
query = URI.encode_www_form(camera: camera, start: start_ts, end: end_ts)
|
166
|
+
download_file(uri(path: 'video/export', query: query), local_file: local_file)
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UnifiProtect
|
4
|
+
class Camera
|
5
|
+
class SnapshotError < StandardError; end
|
6
|
+
class VideoExportError < StandardError; end
|
7
|
+
|
8
|
+
TIME_FIELDS = %i[upSince connectedSince lastSeen lastMotion lastRing].freeze
|
9
|
+
|
10
|
+
attr_reader :camera
|
11
|
+
|
12
|
+
def initialize(client:, camera:)
|
13
|
+
@client = client
|
14
|
+
@camera = camera
|
15
|
+
end
|
16
|
+
|
17
|
+
def to_s
|
18
|
+
"#<#{self.class.name} id=#{@camera.id.inspect} name=#{@camera.name.inspect}>"
|
19
|
+
end
|
20
|
+
|
21
|
+
def inspect
|
22
|
+
to_s
|
23
|
+
end
|
24
|
+
|
25
|
+
def respond_to_missing?(method_name, include_private = false)
|
26
|
+
@camera.respond_to?(method_name) || super
|
27
|
+
end
|
28
|
+
|
29
|
+
def method_missing(method_name, *args)
|
30
|
+
value = @camera.send(method_name, *args)
|
31
|
+
return Time.at(value / 1000) if value && TIME_FIELDS.include?(method_name)
|
32
|
+
|
33
|
+
value
|
34
|
+
end
|
35
|
+
|
36
|
+
def match(name, matcher)
|
37
|
+
return matcher.match(send(name)) if matcher.is_a?(Regexp)
|
38
|
+
return send(name) == matcher if matcher.is_a?(String) || [true, false].include?(matcher)
|
39
|
+
return matcher.any? { |item| match(name, item) } if matcher.is_a?(Array)
|
40
|
+
|
41
|
+
if matcher.is_a?(Hash)
|
42
|
+
value = send(name).send(matcher.first[0])
|
43
|
+
pattern = matcher.first[1]
|
44
|
+
return pattern.match(value)
|
45
|
+
end
|
46
|
+
|
47
|
+
false
|
48
|
+
end
|
49
|
+
|
50
|
+
def snapshot(local_file: nil)
|
51
|
+
@client.api.camera_snapshot(camera: id, local_file: local_file)
|
52
|
+
rescue UnifiProtect::API::RequestError => e
|
53
|
+
raise SnapshotError, e
|
54
|
+
end
|
55
|
+
|
56
|
+
def video_export(start_time:, end_time:, local_file: nil)
|
57
|
+
@client.api.video_export(camera: id, start_time: start_time, end_time: end_time, local_file: local_file)
|
58
|
+
rescue UnifiProtect::API::RequestError => e
|
59
|
+
raise VideoExportError, e
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'forwardable'
|
4
|
+
|
5
|
+
module UnifiProtect
|
6
|
+
class CameraCollection
|
7
|
+
extend Forwardable
|
8
|
+
|
9
|
+
attr_reader :cameras
|
10
|
+
|
11
|
+
FILTERS = {
|
12
|
+
adopting: :isAdopting,
|
13
|
+
adopted: :isAdopted,
|
14
|
+
provisioned: :isProvisioned,
|
15
|
+
attempting_to_connect: :isAttemptingToConnect,
|
16
|
+
managed: :isManaged,
|
17
|
+
updating: :isUpdating,
|
18
|
+
connected: :isConnected,
|
19
|
+
recording: :isRecording,
|
20
|
+
rebooting: :isRebooting,
|
21
|
+
deleting: :isDeleting,
|
22
|
+
|
23
|
+
# Real-world status
|
24
|
+
dark: :isDark,
|
25
|
+
motion_detected: :isMotionDetected,
|
26
|
+
}.freeze
|
27
|
+
|
28
|
+
def initialize(cameras = [])
|
29
|
+
@cameras = cameras
|
30
|
+
end
|
31
|
+
|
32
|
+
def_delegator :@cameras, :to_a
|
33
|
+
def_delegator :@cameras, :count
|
34
|
+
def_delegator :@cameras, :empty?
|
35
|
+
def_delegator :@cameras, :first
|
36
|
+
def_delegator :@cameras, :last
|
37
|
+
def_delegator :@cameras, :[]
|
38
|
+
def_delegator :@cameras, :each
|
39
|
+
def_delegator :@cameras, :each_index
|
40
|
+
def_delegator :@cameras, :each_slice
|
41
|
+
def_delegator :@cameras, :each_with_index
|
42
|
+
def_delegator :@cameras, :each_with_object
|
43
|
+
def_delegator :@cameras, :map
|
44
|
+
|
45
|
+
def respond_to_missing?(method_name, include_private = false)
|
46
|
+
return true if FILTERS.include?(method_name)
|
47
|
+
|
48
|
+
super
|
49
|
+
end
|
50
|
+
|
51
|
+
def method_missing(method_name, *args)
|
52
|
+
return filter(method_name, *args) if FILTERS.include?(method_name)
|
53
|
+
|
54
|
+
super
|
55
|
+
end
|
56
|
+
|
57
|
+
def match(**attrs)
|
58
|
+
return CameraCollection.new(cameras) if attrs.empty?
|
59
|
+
|
60
|
+
CameraCollection.new(
|
61
|
+
@cameras.select do |camera|
|
62
|
+
attrs.any? { |name, matcher| camera.match(name, matcher) }
|
63
|
+
end
|
64
|
+
)
|
65
|
+
end
|
66
|
+
|
67
|
+
def fetch(**attrs)
|
68
|
+
match(**attrs).first
|
69
|
+
end
|
70
|
+
|
71
|
+
def filter(name, value = true)
|
72
|
+
return CameraCollection.new if @cameras.empty?
|
73
|
+
raise 'unknown filter' unless FILTERS.include?(name.to_sym)
|
74
|
+
|
75
|
+
CameraCollection.new(@cameras.select { |c| c.send(FILTERS.fetch(name.to_sym)) == value })
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UnifiProtect
|
4
|
+
class Client
|
5
|
+
attr_reader :api
|
6
|
+
|
7
|
+
def initialize(api: nil, **args)
|
8
|
+
@api = api || API.new(**args)
|
9
|
+
end
|
10
|
+
|
11
|
+
def bootstrap
|
12
|
+
@bootstrap ||= api.bootstrap
|
13
|
+
end
|
14
|
+
|
15
|
+
def nvr
|
16
|
+
@nvr ||= NVR.new(client: self, nvr: bootstrap.nvr)
|
17
|
+
end
|
18
|
+
|
19
|
+
def create_camera_objects
|
20
|
+
bootstrap.cameras.map { |camera| Camera.new(client: self, camera: camera) }
|
21
|
+
end
|
22
|
+
|
23
|
+
def cameras
|
24
|
+
@cameras ||= CameraCollection.new(create_camera_objects)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UnifiProtect
|
4
|
+
class NVR
|
5
|
+
TIME_FIELDS = %i[upSince].freeze
|
6
|
+
|
7
|
+
attr_reader :nvr
|
8
|
+
|
9
|
+
def initialize(client:, nvr:)
|
10
|
+
@client = client
|
11
|
+
@nvr = nvr
|
12
|
+
end
|
13
|
+
|
14
|
+
def to_s
|
15
|
+
"#<#{self.class.name} id=#{@nvr.id.inspect} name=#{@nvr.name.inspect}>"
|
16
|
+
end
|
17
|
+
|
18
|
+
def inspect
|
19
|
+
to_s
|
20
|
+
end
|
21
|
+
|
22
|
+
def respond_to_missing?(method_name, include_private = false)
|
23
|
+
@nvr.respond_to?(method_name) || super
|
24
|
+
end
|
25
|
+
|
26
|
+
def method_missing(method_name, *args)
|
27
|
+
value = @nvr.send(method_name, *args)
|
28
|
+
return Time.at(value / 1000) if value && TIME_FIELDS.include?(method_name)
|
29
|
+
|
30
|
+
value
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'lib/unifi_protect/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = 'unifi_protect'
|
7
|
+
spec.version = UnifiProtect::VERSION
|
8
|
+
spec.authors = ['Jeremy Cole']
|
9
|
+
spec.email = ['jeremy@jcole.us']
|
10
|
+
|
11
|
+
spec.summary = 'UniFi Protect API'
|
12
|
+
spec.description = 'An unofficial implementation of the Ubiquiti UniFi Protect API in Ruby'
|
13
|
+
spec.homepage = 'http://github.com/jeremycole/unifi_protect'
|
14
|
+
spec.license = 'MIT'
|
15
|
+
spec.required_ruby_version = Gem::Requirement.new('>= 2.3.0')
|
16
|
+
|
17
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
18
|
+
spec.metadata['source_code_uri'] = spec.homepage
|
19
|
+
# spec.metadata['changelog_uri'] = ''
|
20
|
+
|
21
|
+
# Specify which files should be added to the gem when it is released.
|
22
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
23
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
24
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
25
|
+
end
|
26
|
+
spec.bindir = 'exe'
|
27
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
28
|
+
spec.require_paths = ['lib']
|
29
|
+
end
|
metadata
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: unifi_protect
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Jeremy Cole
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2020-08-30 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: An unofficial implementation of the Ubiquiti UniFi Protect API in Ruby
|
14
|
+
email:
|
15
|
+
- jeremy@jcole.us
|
16
|
+
executables:
|
17
|
+
- unifi_protect
|
18
|
+
extensions: []
|
19
|
+
extra_rdoc_files: []
|
20
|
+
files:
|
21
|
+
- ".gitignore"
|
22
|
+
- ".rakeTasks"
|
23
|
+
- ".rspec"
|
24
|
+
- ".rubocop.yml"
|
25
|
+
- ".travis.yml"
|
26
|
+
- Gemfile
|
27
|
+
- Gemfile.lock
|
28
|
+
- LICENSE.txt
|
29
|
+
- README.md
|
30
|
+
- Rakefile
|
31
|
+
- bin/console
|
32
|
+
- bin/setup
|
33
|
+
- exe/unifi_protect
|
34
|
+
- lib/unifi_protect.rb
|
35
|
+
- lib/unifi_protect/api.rb
|
36
|
+
- lib/unifi_protect/camera.rb
|
37
|
+
- lib/unifi_protect/camera_collection.rb
|
38
|
+
- lib/unifi_protect/client.rb
|
39
|
+
- lib/unifi_protect/nvr.rb
|
40
|
+
- lib/unifi_protect/version.rb
|
41
|
+
- unifi_protect.gemspec
|
42
|
+
homepage: http://github.com/jeremycole/unifi_protect
|
43
|
+
licenses:
|
44
|
+
- MIT
|
45
|
+
metadata:
|
46
|
+
homepage_uri: http://github.com/jeremycole/unifi_protect
|
47
|
+
source_code_uri: http://github.com/jeremycole/unifi_protect
|
48
|
+
post_install_message:
|
49
|
+
rdoc_options: []
|
50
|
+
require_paths:
|
51
|
+
- lib
|
52
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
53
|
+
requirements:
|
54
|
+
- - ">="
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: 2.3.0
|
57
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
requirements: []
|
63
|
+
rubygems_version: 3.0.3
|
64
|
+
signing_key:
|
65
|
+
specification_version: 4
|
66
|
+
summary: UniFi Protect API
|
67
|
+
test_files: []
|