#!/usr/bin/env ruby # frozen_string_literal: true require 'optparse' require 'ostruct' require 'time' require 'unifi_protect' class UnifiProtectCommand class BadOptionError < StandardError; end NVR_FIELDS = %i[ id name type mac host version hardwareId hardwarePlatform hardwareRevision firmwareVersion upSince ].freeze CAMERA_FIELDS = %i[ id name type mac state hardwareRevision firmwareVersion firmwareBuild upSince connectedSince lastMotion lastRing lastSeen ].freeze attr_reader :options attr_reader :option_parser def initialize initialize_options end def initialize_options @options = OpenStruct.new( host: nil, port: 7443, username: nil, password: nil, mode: [], match: OpenStruct.new ) end DURATIONS = { 'd' => 24 * 60 * 60, 'h' => 60 * 60, 'm' => 60, 's' => 1 }.freeze def parse_duration(str) str.split(/([dhms])/).map { |t| DURATIONS[t] || t.to_i }.each_slice(2).map { |t, m| t * (m || 1) }.sum end def match_value(value) return true if value == 'true' return false if value == 'false' value end def add_matcher(field, matcher) options.match[field] ||= [] options.match[field] << match_value(matcher) end def parse_options(args) @option_parser = OptionParser.new do |opts| opts.on('-h', '--help', 'Show this help.') do puts opts exit 0 end opts.on('-H', '--host=HOST', 'Hostname of the UniFi Protect controller.') { |o| options.host = o } opts.on('-P', '--port=PORT', 'TCP Port of the UniFi Protect controller.') { |o| options.port = o.to_i } opts.on('-u', '--username=USERNAME', 'Username for HTTP basic authentication.') { |o| options.username = o } opts.on('-p', '--password=PASSWORD', 'Password for HTTP basic authentication.') { |o| options.password = o } opts.on('--describe-nvr', 'Describe NVR in detail.') { options.mode << :describe_nvr } opts.on('--list-cameras', 'List all matched cameras.') { options.mode << :list_cameras } opts.on('--describe-cameras', 'Describe all matched cameras in detail.') { options.mode << :describe_cameras } opts.on('-o', '--output-path=PATH', 'Specify a local path to save downloaded files to.') do |o| options.output_path = o end opts.on('--snapshot', 'Download a recent snapshot from each matched camera.') { options.mode << :snapshot } opts.on('--video-export', 'Download saved video for each matched camera.') { options.mode << :video_export } opts.on('-s', '--start-time=TIME', "Start time for video, e.g. '2020-08-29 01:02:00 PDT'.") do |o| options.start_time = Time.parse(o) end opts.on('-e', '--end-time=TIME', "End time for video, e.g. '2020-08-29 01:02:30 PDT'.") do |o| options.end_time = Time.parse(o) end opts.on('-d', '--duration=DURATION', "Duration of video with units, e.g. '10s', '2m30s', or '1h10m'.") do |o| options.end_time = options.start_time + parse_duration(o) end opts.on('--match=MATCH', 'Match cameras by field=value case-sensitively using a regular expression.') do |o| o.split(',').each do |arg| field, value = arg.split('=') add_matcher(field, Regexp.new(value)) end end opts.on('--match-i=MATCH', 'Match cameras by field=value case-insensitively using a regular expression.') do |o| o.split(',').each do |arg| field, value = arg.split('=') add_matcher(field, Regexp.new(value, Regexp::IGNORECASE)) end end opts.on('--exact=MATCH', 'Match cameras by field=value using an exact string match.') do |o| o.split(',').each do |arg| field, value = arg.split('=') add_matcher(field, value) end end opts.on('--id=ID', 'Match cameras by exact camera ID.') do |o| o.split(',').each { |id| add_matcher(:id, id) } end opts.on('--name=NAME', 'Match cameras by name using a case-insensitive regular expression.') do |o| o.split(',').each { |name| add_matcher(:name, Regexp.new(name, Regexp::IGNORECASE)) } end opts.on('--[no-]connected', 'Match cameras currently connected.') do |o| options.match.connected = o end opts.on('--[no-]recording', 'Match cameras currently recording.') do |o| options.match.recording = o end opts.on('--[no-]dark', 'Match cameras currently detecting darkness.') do |o| options.match.dark = o end opts.on('--[no-]motion-detected', 'Match cameras recently detecting motion.') do |o| options.match.motion_detected = o end end option_parser.parse!(args) validate_options self end def validate_options raise BadOptionError, 'Missing required --host option' unless options.host raise BadOptionError, 'Missing required --username option' unless options.username raise BadOptionError, 'Missing required --password option' unless options.password if options.mode.include?(:video_export) && !(options.start_time && options.end_time) raise BadOptionError, 'The --video-export option requires --start-time and either --end-time or --duration' end true end def match_cameras cameras = @client.cameras options.match.to_h.each do |field, value| # puts "Adding camera match filter on #{field} = #{value}" case value when TrueClass, FalseClass cameras = cameras.filter(field, value) else cameras = cameras.match(field => value) end end cameras end def to_name(field) field.to_s.gsub(/([A-Z])/, ' \1').sub(/^[a-z]/, &:upcase) end def nvr @client.nvr end def human_size(size) format('%0.2f GiB', size / (1024.0 ** 3)) end def describe_nvr NVR_FIELDS.each do |field| puts format('%-20s: %s', to_name(field), nvr.send(field) || '(none)') end puts format('%-20s:', 'Storage Info') %i[totalSize totalSpaceUsed].each do |field| puts format(' %-18s: %s', to_name(field), human_size(nvr.storageInfo.send(field))) end puts format(' %-18s:', 'Hard Drives') nvr.storageInfo.hardDrives.each_with_index do |hd, i| puts format( ' %-16s: %s', format('Hard Drive %d', i), format( '%s (size=%s, serial=%s, health=%s)', hd.name, human_size(hd.size), hd.serial, hd.health ) ) end end def cameras @cameras ||= match_cameras end def list_cameras return if cameras.empty? puts format('%-30s%-40s%-20s%-20s', 'ID', 'Name', 'Type', 'State') cameras.each do |camera| puts format('%-30s%-40s%-20s%-20s', camera.id, camera.name, camera.type, camera.state) end end def describe_cameras cameras.each do |camera| CAMERA_FIELDS.each do |field| puts format('%-20s: %s', to_name(field), camera.send(field) || '(none)') end puts end end def snapshot cameras.each do |camera| print "Downloading snapshot from #{camera.id}, #{camera.name}... " begin file = camera.snapshot puts format('OK, %.0f KiB.', file.size / 1024.0) rescue UnifiProtect::Camera::SnapshotError => e puts "Failed: #{e}" end end end def video_export puts "Exporting video for #{cameras.count} cameras from #{options.start_time} to #{options.end_time}." puts cameras.each do |camera| print "Downloading video from #{camera.id}, #{camera.name}... " begin file = camera.video_export(start_time: options.start_time, end_time: options.end_time) puts format('OK, %.0f KiB.', file.size / 1024.0) rescue UnifiProtect::Camera::VideoExportError => e puts "Failed: #{e}" end end end def run if options.output_path raise "output path #{options.output_path} does not exist" unless File.exist?(options.output_path) end @client = UnifiProtect::Client.new( host: options.host, port: options.port, username: options.username, password: options.password, download_path: options.output_path ) options.mode.each do |mode| case mode when :describe_nvr describe_nvr when :list_cameras list_cameras when :describe_cameras describe_cameras when :snapshot snapshot when :video_export video_export end puts end end end UnifiProtectCommand.new.parse_options(ARGV).run