unifi_protect 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -0,0 +1,21 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ /out/
10
+
11
+ /*.mp4
12
+ /*.jpg
13
+
14
+ /*.gem
15
+
16
+ # rspec failure tracking
17
+ .rspec_status
18
+
19
+ # RubyMine files
20
+ .rakeTasks
21
+ .idea
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
@@ -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
@@ -0,0 +1,6 @@
1
+ ---
2
+ language: ruby
3
+ cache: bundler
4
+ rvm:
5
+ - 2.6.6
6
+ before_install: gem install bundler -v 2.1.4
data/Gemfile ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in unifi_protect.gemspec
6
+ gemspec
7
+
8
+ gem 'rake', '~> 12.0'
9
+ gem 'rspec', '~> 3.0'
@@ -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
@@ -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.
@@ -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).
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -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__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UnifiProtect
4
+ VERSION = '0.1.0'
5
+ 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: []