diff --git a/.reek b/.reek index 31797f26..47b9358c 100644 --- a/.reek +++ b/.reek @@ -31,6 +31,8 @@ NilCheck: # Boolean switches on state based methods (such as power and mute) are useful. BooleanParameter: enabled: false +ControlParameter: + enabled: false # Allow for a larger number of constants for protocol definitions. TooManyConstants: @@ -39,12 +41,16 @@ TooManyConstants: # Suppress warning about parameter length within reason. LongParameterList: max_params: 4 - + # Prevent from flagging multiple calls to utility methods # (e.g. is_affirmative?). RepeatedConditional: enabled: false +# Allow for device model numbers to be used as module names. +UncommunicativeModuleName: + enabled: false + # Support private, pure functions UtilityFunction: public_methods_only: true diff --git a/.rubocop.yml b/.rubocop.yml index 924ada19..4129bb2d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -27,6 +27,10 @@ Metrics/ClassLength: Metrics/ModuleLength: Max: 300 +Metrics/LineLength: + Exclude: + - "**/**/*_spec.rb" + Metrics/ParameterLists: Max: 5 @@ -70,3 +74,6 @@ Style/Documentation: Style/NumericLiterals: Enabled: false + +Style/RegexpLiteral: + AllowInnerSlashes: true diff --git a/lib/aca/tracking/people_count.rb b/lib/aca/tracking/people_count.rb new file mode 100644 index 00000000..24881639 --- /dev/null +++ b/lib/aca/tracking/people_count.rb @@ -0,0 +1,28 @@ +module Aca; end +module Aca::Tracking; end +class Aca::Tracking::PeopleCount < CouchbaseOrm::Base + design_document :pcount + + # Connection details + attribute :room_email, type: String + attribute :booking_id, type: String + attribute :system_id, type: String + attribute :capacity, type: Integer + attribute :maximum, type: Integer + attribute :average, type: Integer + attribute :median, type: Integer + attribute :organiser, type: String + attribute :counts, type: Array, default: lambda { [] } + + protected + + + before_create :set_id + + view :all, emit_key: :room_email + + def set_id + self.id = "count-#{self.booking_id}" + end + +end \ No newline at end of file diff --git a/lib/ibm/domino.rb b/lib/ibm/domino.rb new file mode 100644 index 00000000..c4840aec --- /dev/null +++ b/lib/ibm/domino.rb @@ -0,0 +1,623 @@ +# Reference: https://www.ibm.com/developerworks/lotus/library/ls-Domino_URL_cheat_sheet/ + +require 'active_support/time' +require 'logger' +module Ibm; end + +class Ibm::Domino + def initialize( + username:, + password:, + auth_hash:, + domain:, + timezone:, + logger: Logger.new(STDOUT) + ) + @domain = domain + @timeone = timezone + @logger = logger + @headers = { + 'Authorization' => "Basic #{auth_hash}", + 'Content-Type' => 'application/json' + } + @domino_api = UV::HttpEndpoint.new(@domain, {inactivity_timeout: 25000, keepalive: false}) + end + + def domino_request(request_method, endpoint, data = nil, query = {}, headers = {}, full_path = nil) + # Convert our request method to a symbol and our data to a JSON string + request_method = request_method.to_sym + data = data.to_json if !data.nil? && data.class != String + + @headers.merge(headers) if headers + + if full_path + if full_path.include?('/api/calendar/') + uri = URI.parse(full_path) + else + uri = URI.parse(full_path + '/api/calendar/events') + end + domino_api = UV::HttpEndpoint.new("https://#{uri.host}", {inactivity_timeout: 25000}) + domino_path = uri.to_s + elsif request_method == :post + domino_api = UV::HttpEndpoint.new(ENV['DOMINO_CREATE_DOMAIN'], {inactivity_timeout: 25000}) + domino_path = "#{ENV['DOMINO_CREATE_DOMAIN']}#{endpoint}" + else + domino_api = @domino_api + domino_path = "#{ENV['DOMINO_DOMAIN']}#{endpoint}" + end + + @logger.info "------------NEW DOMINO REQUEST--------------" + @logger.info domino_path + @logger.info query + @logger.info data + @logger.info @headers + @logger.info "--------------------------------------------" + + response = domino_api.__send__(request_method, path: domino_path, headers: @headers, body: data, query: query) + end + + def get_free_rooms(starting, ending) + starting = convert_to_simpledate(starting) + ending = convert_to_simpledate(ending) + + starting = starting.utc + ending = ending.utc + + req_params = { + :site => ENV["DOMINO_SITE"], + :start => to_ibm_date(starting), + :end => to_ibm_date(ending), + :capacity => 1 + } + + res = domino_request('get','/api/freebusy/freerooms', nil, req_params).value + domino_emails = JSON.parse(res.body)['rooms'] + end + + def get_users_bookings_created_today(database) + user_bookings = get_users_bookings(database, nil, nil, 1) + user_bookings.select!{ |booking| + if booking.key?('last-modified') + booking['last-modified'] && Time.now.midnight < booking['last-modified'] && Time.now.tomorrow.midnight > booking['last-modified'] + else + false + end + } + user_bookings + + rescue => e + STDERR.puts "#{e.message}\n#{e.backtrace.join("\n")}" + raise e + end + + def get_users_bookings(database, date=nil, simple=nil, weeks=1) + + if !date.nil? + # Make date a date object from epoch or parsed text + date = convert_to_simpledate(date).utc + starting = to_ibm_date(date) + ending = to_ibm_date(date.tomorrow) + else + starting = to_ibm_date(Time.now.midnight.utc) + ending = to_ibm_date((Time.now.midnight.utc + weeks.week)) + end + + query = { + before: ending, + since: starting + } + + events = [] + # First request is to the user's database + request = domino_request('get', nil, nil, query, nil, database).value + if [200,201,204].include?(request.status) + if request.body != '' + events = add_event_utc(JSON.parse(request.body)) + end + else + return nil + end + + query = { + since: starting + } + + invite_db = database + '/api/calendar/invitations' + request = domino_request('get', nil, nil, query, nil, invite_db).value + if [200,201,204].include?(request.status) + if request.body != '' + invites = JSON.parse(request.body)['events'] + events += invites if !invites.nil? + end + else + return nil + end + + full_events = [] + events.each{ |event| + db_uri = URI.parse(database) + base_domain = db_uri.scheme + "://" + db_uri.host + + if simple + # If we're dealing with an invite we must try and resolve the href + if !event.key?('start') + invite = get_attendees(base_domain + event['href']) + if invite + full_events.push({ + start: invite['start'], + end: invite['end'] + }) + end + else + full_events.push({ + start: event['start'], + end: event['end'] + }) + end + next + end + full_event = get_attendees(base_domain + event['href']) + + if full_event == false + full_event = event + full_event['organizer'] = {} + full_event['description'] = '' + full_event['attendees'] = [] + end + full_events.push(full_event) + } + full_events + + rescue => e + STDERR.puts "\n\n#{e.message}\n#{e.backtrace.join("\n")}\n\n" + raise "\n\n#{e.message}\n#{e.backtrace.join("\n")}\n\n" + end + + def get_booking(path, database) + db_uri = URI.parse(database) + base_domain = db_uri.scheme + "://" + db_uri.host + event_path = base_domain + path + booking_request = domino_request('get',nil,nil,nil,nil,event_path).value + if ![200,201,204].include?(booking_request.status) + @logger.info "Didn't get a 20X response from meeting detail requst." + return false + end + return JSON.parse(booking_request.body)['events'][0] + end + + def get_bookings(room_ids, date=Time.now.midnight, ending=nil, full_data=false) + room_ids = Array(room_ids) + room_names = room_ids.map{|id| Orchestrator::ControlSystem.find(id).settings['name']} + room_mapping = {} + room_ids.each{|id| + room_mapping[Orchestrator::ControlSystem.find(id).settings['name']] = id + } + + # The domino API takes a StartKey and UntilKey + # We will only ever need one days worth of bookings + # If startkey = 2017-11-29 and untilkey = 2017-11-30 + # Then all bookings on the 30th (the day of the untilkey) are returned + + # Make date a date object from epoch or parsed text + date = convert_to_simpledate(date) + starting = date.utc.strftime("%Y%m%dT%H%M%S,00Z") + + if ending + ending = convert_to_simpledate(ending).utc.strftime("%Y%m%dT%H%M%S,00Z") + else + ending = date.tomorrow.utc.strftime("%Y%m%dT%H%M%S,00Z") + end + + + # Set count to max + query = { + Count: '500', + StartKey: starting, + UntilKey: ending, + KeyType: 'time', + ReadViewEntries: nil, + OutputFormat: 'JSON' + } + + # Get our bookings + request = domino_request('get', "/RRDB.nsf/93FDE1776546DEEB482581E7000B27FF", nil, query) + response = request.value + + if full_data + uat_server = UV::HttpEndpoint.new(ENV['ALL_USERS_DOMAIN']) + all_users = uat_server.get(path: ENV['ALL_USERS_PATH']).value + all_users = JSON.parse(all_users.body)['staff'] + end + + # Go through the returned bookings and add to output array + rooms_bookings = {} + room_ids.each{|id| + rooms_bookings[id] = [] + } + bookings = JSON.parse(response.body)['viewentry'] || [] + @logger.info "Checking room names:" + @logger.info room_names + start_timer = Time.now + bookings.each{ |booking| + + # Get the room name + domino_room_name = booking['entrydata'][2]['text']['0'] + + # Check if room is in our list + if room_names.include?(domino_room_name) + organizer = booking['entrydata'][3]['text']['0'] + new_booking = { + start: Time.parse(booking['entrydata'][0]['datetime']['0']).to_i * 1000, + end: Time.parse(booking['entrydata'][1]['datetime']['0']).to_i * 1000, + summary: booking['entrydata'][5]['text']['0'], + organizer: organizer + } + if full_data + booking_id = booking['entrydata'][9]['text']['0'] + staff_db = nil + all_users.each{|u| + if u['StaffLNMail'] == organizer + staff_db = "https://#{u['ServerName']}/#{u['MailboxPath']}.nsf" + break + end + } + staff_db_uri = URI.parse(staff_db) + path = "#{staff_db_uri.path}/api/calendar/events/#{booking_id}" + new_booking[:database] = staff_db + new_booking[:path] = path + end + rooms_bookings[room_mapping[domino_room_name]].push(new_booking) + end + } + end_timer = Time.now + @logger.info "Total time #{end_timer - start_timer}" + rooms_bookings + end + + + def create_booking(current_user:, starting:, ending:, database:, room_id:, summary:, description: nil, organizer:, attendees: [], timezone: @timezone, **opts) + room = Orchestrator::ControlSystem.find(room_id) + starting = convert_to_simpledate(starting) + ending = convert_to_simpledate(ending) + event = { + :summary => summary, + :class => :public, + :start => to_utc_date(starting), + :end => to_utc_date(ending) + } + + if description.nil? + description = "" + end + + # if room.support_url + # description = description + "\nTo control this meeting room, click here: #{room.support_url}" + event[:description] = description + # end + + event[:attendees] = Array(attendees).collect do |attendee| + out_attendee = { + role: "req-participant", + status: "needs-action", + rsvp: true, + email: attendee[:email] + } + out_attendee[:displayName] = attendee[:name] if attendee[:name] + out_attendee + end + + # Set the current user as orgaqnizer and chair if no organizer passed in + if organizer + event[:organizer] = { + email: organizer[:email] + } + event[:organizer][:displayName] = organizer[:name] if organizer[:name] + + event[:attendees].push({ + "role":"chair", + "status":"accepted", + "rsvp":false, + "email": organizer[:email] + }) + else + event[:organizer] = { + email: current_user.email + } + event[:attendees].push({ + "role":"chair", + "status":"accepted", + "rsvp":false, + "email": current_user.email + }) + end + + # Add the room as an attendee + event[:attendees].push({ + "role":"chair", + "status":"accepted", + "rsvp":false, + "userType":"room", + "email": room.email + }) + + + request = domino_request('post', nil, {events: [event]}, nil, nil, database).value + request + end + + + + def delete_booking(database, id) + request = domino_request('delete', nil, nil, nil, nil, "#{database}/api/calendar/events/#{id}").value + end + + + def edit_booking(id:, current_user: nil, starting:, ending:, database:, room_email:, summary:, description: nil, organizer:, attendees: [], timezone: @timezone, **opts) + room = Orchestrator::ControlSystem.find_by_email(room_email) + starting = convert_to_simpledate(starting) + ending = convert_to_simpledate(ending) + event = { + :summary => summary, + :class => :public, + :start => to_utc_date(starting), + :end => to_utc_date(ending), + # :href => "/#{database}/api/calendar/events/#{id}", + :id => id + } + + if description.nil? + description = "" + end + + # if room.support_url + # description = description + "\nTo control this meeting room, click here: #{room.support_url}" + event[:description] = description + # end + + event[:attendees] = Array(attendees).collect do |attendee| + out_attendee = { + role: "req-participant", + status: "needs-action", + rsvp: true, + email: attendee[:email] + } + out_attendee[:displayName] = attendee[:name] if attendee[:name] + out_attendee + end + + if current_user.nil? + event[:organizer] = { + email: organizer + } + event[:attendees].push({ + "role":"chair", + "status":"accepted", + "rsvp":false, + "email": organizer + }) + else + # Organizer will not change + event[:organizer] = { + email: current_user.email + } + event[:attendees].push({ + "role":"chair", + "status":"accepted", + "rsvp":false, + "email": current_user.email + }) + end + + # Add the room as an attendee + event[:attendees].push({ + "role":"chair", + "status":"accepted", + "rsvp":false, + "userType":"room", + "email": room.email + }) + + + request = domino_request('put', nil, {events: [event]}, {workflow: true}, nil, database + "/api/calendar/events/#{id}").value + request + end + + def get_attendees(path) + booking_request = domino_request('get',nil,nil,nil,nil,path).value + if ![200,201,204].include?(booking_request.status) + @logger.info "Didn't get a 20X response from meeting detail requst." + return false + end + + booking_response = add_event_utc(JSON.parse(booking_request.body))[0] + room = get_system(booking_response) + + if room + room_id = room.id + support_url = room.support_url + else + room_id = nil + support_url = nil + end + + if booking_response['attendees'] + + declined = !(is_accepted(booking_response)) + booking_response['attendees'].each{|attendee| + if attendee.key?('userType') && attendee['userType'] == 'room' + booking_response['room_email'] = attendee['email'] + else + booking_response['room_email'] = nil + end + } + attendees = booking_response['attendees'].dup + attendees.map!{ |attendee| + if attendee['status'] == 'accepted' + accepted = true + else + accepted = false + end + if attendee.key?('displayName') + attendee_name = attendee['displayName'] + else + attendee_name = attendee['email'] + end + if attendee.key?('userType') && attendee['userType'] == 'room' + next + end + + { + name: attendee_name, + email: attendee['email'], + state: attendee['status'].gsub(/-/,' ') + } + }.compact! + booking_response['attendees'] = attendees + end + + if booking_response['organizer'] + organizer = booking_response['organizer'].dup + organizer = + { + name: organizer['displayName'], + email: organizer['email'], + accepted: true + } + booking_response['organizer'] = organizer + end + + booking_response['start_readable'] = Time.at(booking_response['start'].to_i / 1000).to_s + booking_response['end_readable'] = Time.at(booking_response['end'].to_i / 1000).to_s + booking_response['support_url'] = support_url if support_url + booking_response['room_id'] = room_id if room_id + booking_response['declined'] = true if declined + booking_response['location'] = "Unassigned" if declined + booking_response['room_email'] = nil if declined + booking_response['room_id'] = nil if declined + booking_response + end + + def is_accepted(event) + accepted = true + event['attendees'].each{|attendee| + if attendee.key?('userType') && attendee['userType'] == 'room' + if attendee.key?('status') && attendee['status'] == 'declined' + accepted = false + end + end + } + return accepted + end + + + def get_system(booking) + @@elastic ||= Elastic.new(Orchestrator::ControlSystem) + + # Deal with a date range query + elastic_params = ActionController::Parameters.new({}) + elastic_params[:q] = "\"#{booking['location']}\"" + elastic_params[:limit] = 500 + + + # Find the room with the email ID passed in + filters = {} + query = @@elastic.query(elastic_params, filters) + matching_rooms = @@elastic.search(query)[:results] + return matching_rooms[0] + + end + + def add_event_utc(response) + + events = response['events'] + response.key?('timezones') ? timezones = response['timezones'] : timezones = nil + + events.reject!{|event| + if event.key?('summary') + event['summary'][0..10] == "Invitation:" + end + } + events.each{ |event| + # If the event has no time, set time to "00:00:00" + if !event['start'].key?('time') + start_time = "00:00:00" + end_time = "00:00:00" + start_date = event['start']['date'] + end_date = event['end']['date'] + elsif !event.key?('end') + start_time = event['start']['time'] + end_time = event['start']['time'] + start_date = event['start']['date'] + end_date = event['start']['date'] + else + start_time = event['start']['time'] + end_time = event['end']['time'] + start_date = event['start']['date'] + end_date = event['end']['date'] + end + + # If the event start has a tzid field, use the timezones hash + if event['start'].key?('tzid') + offset = timezones.find{|t| t['tzid'] == event['start']['tzid']}['standard']['offsetFrom'] + + # If the event has a utc field set to true, use utc + elsif event['start'].key?('utc') && event['start']['utc'] + offset = "+0000" + end + + start_timestring = "#{start_date}T#{start_time}#{offset}" + start_utc = (Time.parse(start_timestring).utc.to_i.to_s + "000").to_i + + end_timestring = "#{end_date}T#{end_time}#{offset}" + end_utc = (Time.parse(end_timestring).utc.to_i.to_s + "000").to_i + + event['start'] = start_utc + event['end'] = end_utc + } + events + end + + # Take a time object and convert to a string in the format IBM uses + def to_ibm_date(time) + time.strftime("%Y-%m-%dT%H:%M:%SZ") + end + + # Takes a date of any kind (epoch, string, time object) and returns a time object + def convert_to_simpledate(date) + if !(date.class == Time) + if string_is_digits(date) + + # Convert to an integer + date = date.to_i + + # If JavaScript epoch remove milliseconds + if date.to_s.length == 13 + date /= 1000 + end + + # Convert to datetimes + date = Time.at(date) + else + date = Time.parse(date) + end + end + return date + end + + # Returns true if a string is all digits (used to check for an epoch) + def string_is_digits(string) + string = string.to_s + string.scan(/\D/).empty? + end + + # Take a time object and return a hash in the format LN uses + def to_utc_date(time) + utctime = time.getutc + { + date: utctime.strftime("%Y-%m-%d"), + time: utctime.strftime("%H:%M:%S"), + utc: true + } + end + +end diff --git a/lib/loqit/lockers.rb b/lib/loqit/lockers.rb new file mode 100644 index 00000000..75301697 --- /dev/null +++ b/lib/loqit/lockers.rb @@ -0,0 +1,134 @@ +require 'savon' +require 'active_support/time' +require 'digest/md5' +module Loqit; end + +# require 'loqit/lockers' +# lockers = Loqit::Lockers.new( +# username: 'xmltester', +# password: 'xmlPassword', +# wsdl: 'http://10.224.8.2/soap/server_wsdl.php?wsdl', +# serial: 'BE434080-7277-11E3-BC4D-94C69111930A' +# ) +# all_lockers = lockers.list_lockers_detailed + +# random_locker = all_lockers.sample +# locker_number = random_locker['number'] +# locker_number = '31061' +# locker_number = '31025' +# locker_number = '30914' +# random_locker_status = lockers.show_locker_status(locker_number) +# random_locker_available = random_locker_status['LockerState'] + + + +# open_status = lockers.open_locker(locker_number) + +# random_locker = lockers.list_lockers.sample['number'] +# random_status = lockers.show_status(random_locker) +# random_open = lockers.open_locker(random_locker) + + + +class Loqit::Lockers + def initialize( + username:, + password:, + serial:, + wsdl:, + log: false, + log_level: :debug + ) + savon_config = { + :wsdl => wsdl, + :log => log, + :log_level => log_level + } + + @client = Savon.client savon_config + @username = username + @password = password + @serial = serial + @header = { + header: { + username: @username, + password: Digest::MD5.hexdigest(@password), + serialnumber: @serial + } + } + end + + def list_lockers + response = @client.call(:list_lockers, + message: { + unitSerial: @serial + }, + soap_header: @header + ).body[:list_lockers_response][:return] + JSON.parse(response) + end + + def list_lockers_detailed(start_number:nil, end_number:nil) + all_lockers_detailed = [] + puts "STARTING LOCKER GET" + if start_number + (start_number.to_i..end_number.to_i).each do |num| + all_lockers_detailed.push(self.show_locker_status(num.to_s)) + end + else + all_lockers = self.list_lockers + all_lockers.each_with_index do |locker, ind| + all_lockers_detailed.push(self.show_locker_status(locker['number'])) + end + end + puts "FINISHED LOCKER GET" + all_lockers_detailed + end + + def show_locker_status(locker_number) + response = @client.call(:show_locker_status, + message: { + lockerNumber: locker_number, + unitSerial: @serial + }, + soap_header: @header + ).body[:show_locker_status_response][:return] + JSON.parse(response) + end + + def open_locker(locker_number) + response = @client.call(:open_locker, + message: { + lockerNumber: locker_number + }, + soap_header: @header + ).body[:locker_number_response][:return] + JSON.parse(response) + end + + def store_credentials(locker_number, user_pin_code, user_card, test_if_free=false) + payload = { + lockerNumber: locker_number, + userPincode: user_pin_code + } + payload[:userCard] = user_card if user_card + payload[:testIfFree] = test_if_free + response = @client.call(:store_credentials, + message: payload, + soap_header: @header + ).body[:store_credentials_response][:return] + JSON.parse(response) + end + + def customer_has_locker(user_card) + response = @client.call(:customer_has_locker, + message: { + lockerNumber: locker_number, + unitSerial: @serial + }, + soap_header: @header + ).body[:customer_has_locker_response][:return] + JSON.parse(response) + end + +end \ No newline at end of file diff --git a/lib/microsoft/exchange.rb b/lib/microsoft/exchange.rb new file mode 100644 index 00000000..9dac4d88 --- /dev/null +++ b/lib/microsoft/exchange.rb @@ -0,0 +1,437 @@ +require 'active_support/time' +require 'logger' + +module Microsoft; end + +class Microsoft::Exchange + TIMEZONE_MAPPING = { + "Sydney": "AUS Eastern Standard Time" + } + def initialize( + ews_url:, + service_account_email:, + service_account_password:, + internet_proxy:nil, + hide_all_day_bookings:false, + logger: Rails.logger + ) + begin + require 'viewpoint2' + rescue LoadError + STDERR.puts 'VIEWPOINT NOT PRESENT' + STDERR.flush + end + @ews_url = ews_url + @service_account_email = service_account_email + @service_account_password = service_account_password + @internet_proxy = internet_proxy + @hide_all_day_bookings = hide_all_day_bookings + ews_opts = { http_opts: { ssl_verify_mode: 0 } } + ews_opts[:http_opts][:http_client] = @internet_proxy if @internet_proxy + STDERR.puts '--------------- NEW CLIENT CREATED --------------' + STDERR.puts "At URL: #{@ews_url} with email: #{@service_account_email}" + STDERR.puts '-------------------------------------------------' + @ews_client ||= Viewpoint::EWSClient.new @ews_url, @service_account_email, @service_account_password, ews_opts + end + + def basic_text(field, name) + field[name][:text] + end + + def close + @ews_client.ews.connection.httpcli.reset_all + end + + def username(field, name=nil) + username = field[:email_addresses][:elems][0][:entry][:text].split("@")[0] + if ['smt','sip'].include?(username.downcase[0..2]) + username = username.gsub(/SMTP:|SIP:|sip:|smtp:/,'') + else + username = field[:email_addresses][:elems][-1][:entry][:text].split("@")[0] + if ['smt','sip'].include?(username.downcase[0..2]) + username = username.gsub(/SMTP:|SIP:|sip:|smtp:/,'') + else + username = field[:email_addresses][:elems][1][:entry][:text].split("@")[0] + if ['smt','sip'].include?(username.downcase[0..2]) + username = username.gsub(/SMTP:|SIP:|sip:|smtp:/,'') + else + username = nil + end + end + end + username + end + + def phone_list(field, name=nil) + phone = nil + field[:phone_numbers][:elems].each do |entry| + type = entry[:entry][:attribs][:key] + text = entry[:entry][:text] + + next unless text.present? + + if type == "MobilePhone" + return text + elsif type == "BusinessPhone" || phone.present? + phone = text + end + end + phone + end + + + def get_users(q: nil, limit: nil) + ews_users = @ews_client.search_contacts(q) + users = [] + fields = { + display_name: 'name:basic_text', + phone_numbers: 'phone:phone_list', + culture: 'locale:basic_text', + department: 'department:basic_text', + email_addresses: 'id:username' + } + keys = fields.keys + ews_users.each do |user| + begin + output = {} + user[:resolution][:elems][1][:contact][:elems].each do |field| + key = field.keys[0] + if keys.include?(key) + splits = fields[key].split(':') + output[splits[0]] = self.__send__(splits[1], field, key) + end + end + if output['name'].nil? + output['name'] = user[:resolution][:elems][0][:mailbox][:elems][0][:name][:text] + end + output['email'] = user[:resolution][:elems][0][:mailbox][:elems][1][:email_address][:text] + users.push(output) + rescue => e + STDERR.puts "GOT USER WITHOUT EMAIL" + STDERR.puts user + STDERR.flush + end + end + limit ||= users.length + limit = limit.to_i - 1 + return users[0..limit.to_i] + end + + def get_user(user_id:) + get_users(q: user_id, limit: 1)[0] + end + + def find_time(cal_event, time) + elems = cal_event[:calendar_event][:elems] + start_time = nil + elems.each do |item| + if item[time] + start_time = ActiveSupport::TimeZone.new("UTC").parse(item[time][:text]) + break + end + end + start_time + end + + def get_available_rooms(rooms:, start_time:, end_time:) + free_rooms = [] + start_time = start_time.utc + end_time = end_time.utc + STDERR.puts "Getting available rooms with" + STDERR.puts start_time + STDERR.puts end_time + STDERR.flush + + rooms.each_slice(30).each do |room_subset| + + # Get booking data for all rooms between time range bounds + user_free_busy = @ews_client.get_user_availability(room_subset, + start_time: start_time, + end_time: end_time, + requested_view: :detailed, + time_zone: { + bias: -0, + standard_time: { + bias: 0, + time: "03:00:00", + day_order: 1, + month: 10, + day_of_week: 'Sunday' + }, + daylight_time: { + bias: 0, + time: "02:00:00", + day_order: 1, + month: 4, + day_of_week: 'Sunday' + } + } + ) + + user_free_busy.body[0][:get_user_availability_response][:elems][0][:free_busy_response_array][:elems].each_with_index {|r,index| + # Remove meta data (business hours and response type) + resp = r[:free_busy_response][:elems][1][:free_busy_view][:elems].delete_if { |item| item[:free_busy_view_type] || item[:working_hours] } + + # Back to back meetings still show up so we need to remove these from the results + if resp.length == 1 + resp = resp[0][:calendar_event_array][:elems] + + if resp.length == 0 + free_rooms.push(room_subset[index]) + elsif resp.length == 1 + free_rooms.push(room_subset[index]) if find_time(resp[0], :start_time).to_i == end_time.to_i + free_rooms.push(room_subset[index]) if find_time(resp[0], :end_time).to_i == start_time.to_i + end + elsif resp.length == 0 + # If response length is 0 then the room is free + free_rooms.push(room_subset[index]) + end + } + end + + free_rooms + end + + def get_bookings(email:, start_param:DateTime.now.midnight, end_param:(DateTime.now.midnight + 2.days), use_act_as: false) + begin + # Get all the room emails + room_emails = Orchestrator::ControlSystem.all.to_a.map { |sys| sys.email } + if [Integer, String].include?(start_param.class) + start_param = DateTime.parse(Time.at(start_param.to_i / 1000).to_s) + end_param = DateTime.parse(Time.at(end_param.to_i / 1000).to_s) + end + STDERR.puts '---------------- GETTING BOOKINGS ---------------' + STDERR.puts "At email: #{email} with start: #{start_param} and end: #{end_param}" + STDERR.puts '-------------------------------------------------' + bookings = [] + if use_act_as + calendar_id = @ews_client.get_folder(:calendar, opts = {act_as: email }).id + events = @ews_client.find_items(folder_id: calendar_id, calendar_view: {start_date: start_param, end_date: end_param}) + else + @ews_client.set_impersonation(Viewpoint::EWS::ConnectingSID[:SMTP], email) + events = @ews_client.find_items({:folder_id => :calendar, :calendar_view => {:start_date => start_param.utc.iso8601, :end_date => end_param.utc.iso8601}}) + end + # events = @ews_client.get_item(:calendar, opts = {act_as: email}).items + events.each{|event| + event.get_all_properties! + booking = {} + booking[:subject] = event.subject + booking[:title] = event.subject + booking[:id] = event.id + # booking[:start_date] = event.start.utc.iso8601 + # booking[:end_date] = event.end.utc.iso8601 + booking[:start] = event.start.to_i * 1000 + booking[:end] = event.end.to_i * 1000 + booking[:body] = event.body + booking[:organiser] = { + name: event.organizer.name, + email: event.organizer.email + } + booking[:attendees] = event.required_attendees.map {|attendee| + if room_emails.include?(attendee.email) + booking[:room_id] = attendee.email + next + end + { + name: attendee.name, + email: attendee.email + } + }.compact if event.required_attendees + if @hide_all_day_bookings + STDERR.puts "SKIPPING #{event.subject}" + STDERR.flush + next if event.end.to_time - event.start.to_time > 86399 + end + bookings.push(booking) + } + bookings + rescue Exception => msg + STDERR.puts msg + STDERR.flush + return [] + end + end + + def create_booking(room_email:, start_param:, end_param:, subject:, description:nil, current_user:, attendees: nil, timezone:'Sydney', permission: 'impersonation', mailbox_location: 'user') + STDERR.puts "CREATING NEW BOOKING IN LIBRARY" + STDERR.puts "room_email is #{room_email}" + STDERR.puts "start_param is #{start_param}" + STDERR.puts "end_param is #{end_param}" + STDERR.puts "subject is #{subject}" + STDERR.puts "description is #{description}" + STDERR.puts "current_user is #{current_user}" + STDERR.puts "attendees is #{attendees}" + STDERR.puts "timezone is #{timezone}" + STDERR.flush + # description = String(description) + attendees = Array(attendees) + + + booking = {} + + # Allow for naming of subject or title + booking[:subject] = subject + booking[:title] = subject + booking[:location] = Orchestrator::ControlSystem.find_by_email(room_email).name + + + # Set the room email as a resource + booking[:resources] = [{ + attendee: { + mailbox: { + email_address: room_email + } + } + }] + + # start_object = Time.at(start_param.to_i) + # end_object = Time.at(end_param.to_i) + + start_object = ensure_ruby_date(start_param.to_i) + end_object = ensure_ruby_date(end_param.to_i) + + + # Add start and end times + booking[:start] = start_object.utc.iso8601.chop + booking[:end] = end_object.utc.iso8601.chop + + # Add the current user passed in as an attendee + mailbox = { email_address: current_user[:email] } + mailbox[:name] = current_user[:name] if current_user[:name] + booking[:required_attendees] = [{ + attendee: { mailbox: mailbox } + }] + + # Add the attendees + attendees.each do |attendee| + # If we don't have an array of emails then it's an object in the form {email: "a@b.com", name: "Blahman Blahson"} + if attendee.class != String + attendee = attendee['email'] + end + booking[:required_attendees].push({ + attendee: { mailbox: { email_address: attendee}} + }) + end + + # Add the room as an attendee (it seems as if some clients require this and others don't) + booking[:required_attendees].push({ attendee: { mailbox: { email_address: room_email}}}) + booking[:body] = description + + # A little debugging + STDERR.puts "MAKING REQUEST WITH" + STDERR.puts booking + STDERR.flush + + if mailbox_location == 'user' + mailbox = current_user[:email] + elsif mailbox_location == 'room' + mailbox = room_email + end + + # Determine whether to use delegation, impersonation or neither + if permission == 'delegation' + folder = @ews_client.get_folder(:calendar, { act_as: mailbox }) + elsif permission == 'impersonation' + @ews_client.set_impersonation(Viewpoint::EWS::ConnectingSID[:SMTP], mailbox) + folder = @ews_client.get_folder(:calendar) + elsif permission == 'none' || permission.nil? + folder = @ews_client.get_folder(:calendar) + end + + # Create the booking and return data relating to it + appointment = folder.create_item(booking) + { + id: appointment.id, + start: start_param, + end: end_param, + attendees: attendees, + subject: subject + } + end + + def update_booking(booking_id:, room_email:nil, start_param:nil, end_param:nil, subject:nil, description:nil, current_user:nil, attendees: nil, timezone:'Sydney', permission: 'impersonation', mailbox_location: 'user') + + event = @ews_client.get_item(booking_id) + booking = {} + + # Add attendees if passed in + attendees = Array(attendees) + attendees.each do |attendee| + if attendee.class != String + attendee = attendee['email'] + end + booking[:required_attendees] ||= [] + booking[:required_attendees].push({ + attendee: { mailbox: { email_address: attendee}} + }) + end if attendees && !attendees.empty? + + # Add subject or title + booking[:subject] = subject if subject + booking[:title] = subject if subject + + # Add location + booking[:location] = Orchestrator::ControlSystem.find_by_email(room_email).name if room_email + + # Add new times if passed + booking[:start] = Time.at(start_param.to_i / 1000).utc.iso8601.chop if start_param + booking[:end] = Time.at(end_param.to_i / 1000).utc.iso8601.chop if end_param + + if mailbox_location == 'user' + mailbox = current_user[:email] + elsif mailbox_location == 'room' + mailbox = room_email + end + + if permission == 'impersonation' + @ews_client.set_impersonation(Viewpoint::EWS::ConnectingSID[:SMTP], mailbox) + end + + new_booking = event.update_item!(booking) + + + { + id: new_booking.id, + start: new_booking.start, + end: new_booking.end, + attendees: new_booking.required_attendees, + subject: new_booking.subject + } + end + + def delete_booking(booking_id:, mailbox:) + @ews_client.set_impersonation(Viewpoint::EWS::ConnectingSID[:SMTP], mailbox) + booking = @ews_client.get_item(booking_id) + booking.delete!(:recycle, send_meeting_cancellations: "SendOnlyToAll") + 200 + end + + # Takes a date of any kind (epoch, string, time object) and returns a time object + def ensure_ruby_date(date) + if !(date.class == Time || date.class == DateTime) + if string_is_digits(date) + + # Convert to an integer + date = date.to_i + + # If JavaScript epoch remove milliseconds + if date.to_s.length == 13 + date /= 1000 + end + + # Convert to datetimes + date = Time.at(date) + else + date = Time.parse(date) + end + end + return date + end + + # Returns true if a string is all digits (used to check for an epoch) + def string_is_digits(string) + string = string.to_s + string.scan(/\D/).empty? + end + +end diff --git a/lib/microsoft/office.rb b/lib/microsoft/office.rb new file mode 100644 index 00000000..e1192cb5 --- /dev/null +++ b/lib/microsoft/office.rb @@ -0,0 +1,766 @@ +require 'active_support/time' +require 'logger' + +module Microsoft + class Error < StandardError + class ResourceNotFound < Error; end + class InvalidAuthenticationToken < Error; end + class BadRequest < Error; end + class ErrorInvalidIdMalformed < Error; end + class ErrorAccessDenied < Error; end + end +end + +class Microsoft::Office + TIMEZONE_MAPPING = { + "Sydney": "AUS Eastern Standard Time" + } + def initialize( + client_id:, + client_secret:, + app_site:, + app_token_url:, + app_scope:, + graph_domain:, + service_account_email:, + service_account_password:, + internet_proxy:nil, + permission: 'impersonation', + mailbox_location: 'user', + logger: Rails.logger + ) + @client_id = client_id + @client_secret = client_secret + @app_site = app_site + @app_token_url = app_token_url + @app_scope = app_scope + @graph_domain = graph_domain + @service_account_email = service_account_email + @service_account_password = service_account_password + @internet_proxy = internet_proxy + @permission = permission + @mailbox_location = mailbox_location + @delegated = false + oauth_options = { site: @app_site, token_url: @app_token_url } + oauth_options[:connection_opts] = { proxy: @internet_proxy } if @internet_proxy + @graph_client ||= OAuth2::Client.new( + @client_id, + @client_secret, + oauth_options + ) + end + + def graph_token + @graph_token = @graph_client.client_credentials.get_token({ + :scope => @app_scope + }).token + end + + def password_graph_token + @graph_token = @graph_client.password.get_token( + @service_account_email, + @service_account_password, + { + :scope => @app_scope + }).token + end + + def graph_request(request_method:, endpoint:, data:nil, query:{}, headers:nil, password:false) + headers = Hash(headers) + query = Hash(query) + # Convert our request method to a symbol and our data to a JSON string + request_method = request_method.to_sym + data = data.to_json if !data.nil? && data.class != String + + if password + headers['Authorization'] = "Bearer #{password_graph_token}" + else + headers['Authorization'] = "Bearer #{graph_token}" + end + + # Set our unchanging headers + headers['Content-Type'] = ENV['GRAPH_CONTENT_TYPE'] || "application/json" + headers['Prefer'] = ENV['GRAPH_PREFER'] || 'outlook.timezone="Australia/Sydney"' + + graph_path = "#{@graph_domain}#{endpoint}" + + log_graph_request(request_method, data, query, headers, graph_path, password) + + + graph_api_options = {inactivity_timeout: 25000, keepalive: false} + if @internet_proxy + proxy = URI.parse(@internet_proxy) + graph_api_options[:proxy] = { host: proxy.host, port: proxy.port } + end + + graph_api = UV::HttpEndpoint.new(@graph_domain, graph_api_options) + response = graph_api.__send__(request_method, path: graph_path, headers: headers, body: data, query: query) + + start_timing = Time.now.to_i + response_value = response.value + end_timing = Time.now.to_i + return response_value + end + + + def bulk_graph_request(request_method:, endpoints:, data:nil, query:nil, headers:nil, password:false) + query = Hash(query) + headers = Hash(headers) + + if password + headers['Authorization'] = "Bearer #{password_graph_token}" + else + headers['Authorization'] = "Bearer #{graph_token}" + end + + # Set our unchanging headers + headers['Content-Type'] = ENV['GRAPH_CONTENT_TYPE'] || "application/json" + headers['Prefer'] = ENV['GRAPH_PREFER'] || 'outlook.timezone="Australia/Sydney"' + + graph_path = "#{@graph_domain}/v1.0/$batch" + query_string = "?#{query.map { |k,v| "#{k}=#{v}" }.join('&')}" + + request_array = [] + endpoints.each_with_index do |endpoint, i| + request_array.push({ + id: i, + method: request_method.upcase, + url: "#{endpoint}#{query_string}" + }) + end + bulk_data = { + requests: request_array + }.to_json + + graph_api_options = {inactivity_timeout: 25000, keepalive: false} + + + if @internet_proxy + proxy = URI.parse(@internet_proxy) + graph_api_options[:proxy] = { host: proxy.host, port: proxy.port } + end + log_graph_request(request_method, bulk_data, query, headers, graph_path, password, endpoints) + + graph_api = UV::HttpEndpoint.new(@graph_domain, graph_api_options) + response = graph_api.__send__('post', path: graph_path, headers: headers, body: bulk_data) + + start_timing = Time.now.to_i + response_value = response.value + end_timing = Time.now.to_i + return response_value + end + + + def log_graph_request(request_method, data, query, headers, graph_path, password, endpoints=nil) + end + + def check_response(response) + case response.status + when 200, 201, 204 + return + when 400 + if response['error']['code'] == 'ErrorInvalidIdMalformed' + raise Microsoft::Error::ErrorInvalidIdMalformed.new(response.body) + else + raise Microsoft::Error::BadRequest.new(response.body) + end + when 401 + raise Microsoft::Error::InvalidAuthenticationToken.new(response.body) + when 403 + raise Microsoft::Error::ErrorAccessDenied.new(response.body) + when 404 + raise Microsoft::Error::ResourceNotFound.new(response.body) + end + end + + # https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_list + def get_users(q: nil, limit: nil, contact_email:nil) + + # If we have a query and the query has at least one space + if q && q.include?(" ") + # Split it into word tokens + queries = q.split(" ") + filter_params = [] + # For each word, create a filtering statement + queries.each do |q| + filter_params.push("(startswith(displayName,'#{q}') or startswith(givenName,'#{q}') or startswith(surname,'#{q}') or startswith(mail,'#{q}'))") + end + # Join these filtering statements using 'or' and add accountEnabled filter + filter_param = "(accountEnabled eq true) and #{filter_params.join(" and ")}" + else + filter_param = "(accountEnabled eq true) and (startswith(displayName,'#{q}') or startswith(givenName,'#{q}') or startswith(surname,'#{q}') or startswith(mail,'#{q}'))" if q + end + filter_param = "accountEnabled eq true" if q.nil? + query_params = { + '$filter': filter_param, + '$top': limit + }.compact + endpoint = "/v1.0/users" + request = graph_request(request_method: 'get', endpoint: endpoint, query: query_params, password: @delegated) + check_response(request) + user_list = JSON.parse(request.body)['value'] + user_list += self.get_contacts(owner_email:contact_email) if contact_email + user_list + end + + # https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_get + def get_user(user_id:) + endpoint = "/v1.0/users/#{user_id}" + request = graph_request(request_method: 'get', endpoint: endpoint, password: @delegated) + check_response(request) + JSON.parse(request.body) + end + + def has_user(user_id:) + endpoint = "/v1.0/users/#{user_id}" + request = graph_request(request_method: 'get', endpoint: endpoint, password: @delegated) + if [200, 201, 204].include?(request.status) + return true + else + return false + end + end + + # https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_list_contacts + def get_contacts(owner_email:, q:nil, limit:nil) + query_params = { '$top': (limit || 1000) } + query_params['$filter'] = "startswith(displayName, #{q}) or startswith(givenName, #{q}) or startswith(surname, #{q}) or emailAddresses/any(a:a/address eq #{q})" if q + endpoint = "/v1.0/users/#{owner_email}/contacts" + request = graph_request(request_method: 'get', endpoint: endpoint, query: query_params, password: @delegated) + check_response(request) + return format_contacts(JSON.parse(request.body)['value']) + end + + def format_contacts(contacts) + output_contacts = [] + contacts.each do |contact| + output_format = {} + output_format[:id] = contact['id'] + output_format[:first_name] = contact['givenName'] + output_format[:last_name] = contact['surname'] + output_format[:phone] = contact['businessPhones'][0] + output_format[:organisation] = contact['companyName'] + output_format[:email] = contact['emailAddresses'][0]['address'] + output_contacts.push(output_format) + end + output_contacts + end + + # https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_list_contacts + def get_contact(owner_email:, contact_email:) + endpoint = "/v1.0/users/#{owner_email}/contacts" + query_params = { + '$top': 1, + '$filter': "emailAddresses/any(a:a/address eq '#{contact_email}')" + } + request = graph_request(request_method: 'get', endpoint: endpoint, query: query_params, password: @delegated) + check_response(request) + JSON.parse(request.body)['value'] + end + + def get_contact_by_id(owner_email:, contact_id:) + endpoint = "/v1.0/users/#{owner_email}/contacts/#{contact_id}" + request = graph_request(request_method: 'get', endpoint: endpoint, password: @delegated) + JSON.parse(request.body) + end + + def has_contact(owner_email:, contact_email:) + return get_contact(owner_email: owner_email, contact_email: contact_email).length > 0 + end + + # https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_post_contacts + def add_contact(owner_email:, email:, first_name:, last_name:, phone:nil, organisation:nil, other:{}) + # This data is required so add it unconditionally + contact_data = { + givenName: first_name, + surname: last_name, + emailAddresses: [{ + address: email, + name: "#{first_name} #{last_name}" + }] + } + + # Only add these fields if passed in + contact_data[:businessPhones] = [ phone ] if phone + contact_data[:companyName] = organisation if organisation + other.each do |field, value| + contact_data[field.to_sym] = value + end + + + endpoint = "/v1.0/users/#{owner_email}/contacts" + request = graph_request(request_method: 'post', endpoint: endpoint, data: contact_data, password: @delegated) + check_response(request) + JSON.parse(request.body) + end + + # https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_list_contacts + def get_organisations(owner_email:) + query_params = { + '$top': 1000 + } + endpoint = "/v1.0/users/#{owner_email}/contacts" + request = graph_request(request_method: 'get', endpoint: endpoint, query: query_params, password: @delegated) + check_response(request) + contacts = JSON.parse(request.body)['value'] + orgs = [] + contacts.each do |cont| orgs.push(cont['companyName']) if !cont['companyName'].nil? && !cont['companyName'].empty? end + orgs.uniq.compact + end + + # https://developer.microsoft.com/en-us/graph/docs/api-reference/beta/api/user_findrooms + def get_rooms(q: nil, limit: nil) + filter_param = "startswith(name,'#{q}') or startswith(address,'#{q}')" if q + query_params = { + '$filter': filter_param, + '$top': limit + }.compact + endpoint = "/beta/users/#{@service_account_email}/findRooms" + request = graph_request(request_method: 'get', endpoint: endpoint, query: query_params, password: @delegated) + check_response(request) + room_response = JSON.parse(request.body)['value'] + end + + # https://developer.microsoft.com/en-us/graph/docs/api-reference/beta/api/user_findrooms + def get_room(room_id:) + endpoint = "/beta/users/#{@service_account_email}/findRooms" + request = graph_request(request_method: 'get', endpoint: endpoint, password: true) + check_response(request) + room_response = JSON.parse(request.body)['value'] + room_response.select! { |room| room['email'] == room_id } + end + + # https://developer.microsoft.com/en-us/graph/docs/api-reference/beta/api/user_findmeetingtimes + def get_available_rooms(rooms:, start_param:, end_param:) + endpoint = "/v1.0/users/#{@service_account_email}/findMeetingTimes" + now = Time.now + start_ruby_param = ensure_ruby_date((start_param || now)) + end_ruby_param = ensure_ruby_date((end_param || (now + 1.hour))) + duration_string = "PT#{end_ruby_param.to_i-start_ruby_param.to_i}S" + start_param = start_ruby_param.utc.iso8601.split("+")[0] + end_param = end_ruby_param.utc.iso8601.split("+")[0] + + # Add the attendees + rooms.map!{|room| + { + type: 'required', + emailAddress: { + address: room[:email], + name: room[:name] + } } + } + + time_constraint = { + activityDomain: 'unrestricted', + timeslots: [{ + start: { + DateTime: start_param, + TimeZone: 'UTC' + }, + end: { + DateTime: end_param, + TimeZone: 'UTC' + } + }] + } + + post_data = { + attendees: rooms, + timeConstraint: time_constraint, + maxCandidates: 1000, + returnSuggestionReasons: true, + meetingDuration: duration_string, + isOrganizerOptional: true + + + }.to_json + + request = graph_request(request_method: 'post', endpoint: endpoint, data: post_data, password: true) + check_response(request) + JSON.parse(request.body) + end + + # https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/event_get + def get_booking(booking_id:, mailbox:) + endpoint = "/v1.0/users/#{mailbox}/events/#{booking_id}" + request = graph_request(request_method: 'get', endpoint: endpoint, password: @delegated) + check_response(request) + JSON.parse(request.body) + end + + # https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/event_delete + def delete_booking(booking_id:, mailbox:) + endpoint = "/v1.0/users/#{mailbox}/events/#{booking_id}" + request = graph_request(request_method: 'delete', endpoint: endpoint, password: @delegated) + check_response(request) + 200 + end + + + def get_bookings_by_user(user_id:, start_param:Time.now, end_param:(Time.now + 1.week), available_from: Time.now, available_to: (Time.now + 1.hour), bulk: false, availability: true) + # The user_ids param can be passed in as a string or array but is always worked on as an array + user_id = Array(user_id) + + # Allow passing in epoch, time string or ruby Time class + start_param = ensure_ruby_date(start_param).utc.iso8601.split("+")[0] + end_param = ensure_ruby_date(end_param).utc.iso8601.split("+")[0] + + # Array of all bookings within our period + if bulk + recurring_bookings = bookings_request_by_users(user_id, start_param, end_param) + else + recurring_bookings = bookings_request_by_user(user_id, start_param, end_param) + end + + recurring_bookings.each do |u_id, bookings| + is_available = true + bookings.each_with_index do |booking, i| + bookings[i] = extract_booking_data(booking, available_from, available_to) + if bookings[i]['free'] == false + is_available = false + end + end + recurring_bookings[u_id] = {available: is_available, bookings: bookings} + end + + if bulk + return recurring_bookings + else + if availability + return recurring_bookings[user_id[0]] + else + return recurring_bookings[user_id[0]][:bookings] + end + end + end + + def extract_booking_data(booking, start_param, end_param) + # Create time objects of the start and end for easier use + booking_start = ActiveSupport::TimeZone.new(booking['start']['timeZone']).parse(booking['start']['dateTime']) + booking_end = ActiveSupport::TimeZone.new(booking['end']['timeZone']).parse(booking['end']['dateTime']) + + # Check if this means the room is unavailable + booking_overlaps_start = booking_start < start_param && booking_end > start_param + booking_in_between = booking_start >= start_param && booking_end <= end_param + booking_overlaps_end = booking_start < end_param && booking_end > end_param + if booking_overlaps_start || booking_in_between || booking_overlaps_end + booking['free'] = false + else + booking['free'] = true + end + + # Grab the start and end in the right format for the frontend + # booking['Start'] = booking_start.utc.iso8601 + # booking['End'] = booking_end.utc.iso8601 + booking['start_epoch'] = booking_start.to_i + booking['end_epoch'] = booking_end.to_i + + # Get some data about the booking + booking['title'] = booking['subject'] + booking['booking_id'] = booking['id'] + + # Format the attendees and save the old format + new_attendees = [] + booking['attendees'].each do |attendee| + if attendee['type'] == 'resource' + booking['room_id'] = attendee['emailAddress']['address'].downcase + else + new_attendees.push({ + email: attendee['emailAddress']['address'], + name: attendee['emailAddress']['name'] + }) + end + end + booking['old_attendees'] = booking['attendees'] + booking['attendees'] = new_attendees + + # Get the organiser and location data + booking['organizer'] = { name: booking['organizer']['emailAddress']['name'], email: booking['organizer']['emailAddress']['address']} + if !booking.key?('room_id') && booking['locations'] && !booking['locations'].empty? && booking['locations'][0]['uniqueId'] + booking['room_id'] = booking['locations'][0]['uniqueId'].downcase + end + if !booking['location']['displayName'].nil? && !booking['location']['displayName'].empty? + booking['room_name'] = booking['location']['displayName'] + end + + booking + end + + # https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_list_calendarview + def bookings_request_by_user(user_id, start_param=Time.now, end_param=(Time.now + 1.week)) + if user_id.class == Array + user_id = user_id[0] + end + # Allow passing in epoch, time string or ruby Time class + start_param = ensure_ruby_date(start_param).iso8601.split("+")[0] + end_param = ensure_ruby_date(end_param).iso8601.split("+")[0] + + recurring_endpoint = "/v1.0/users/#{user_id}/calendarView" + + # Build our query to only get bookings within our datetimes + query_hash = {} + query_hash['$top'] = "200" + + if not start_param.nil? + query_hash['startDateTime'] = start_param + query_hash['endDateTime'] = end_param + end + + recurring_response = graph_request(request_method: 'get', endpoint: recurring_endpoint, query: query_hash, password: @delegated) + check_response(recurring_response) + recurring_bookings = {} + recurring_bookings[user_id] = JSON.parse(recurring_response.body)['value'] + recurring_bookings + end + + # https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_list_calendarview + def bookings_request_by_users(user_ids, start_param=Time.now, end_param=(Time.now + 1.week)) + # Allow passing in epoch, time string or ruby Time class + start_param = ensure_ruby_date(start_param).iso8601.split("+")[0] + end_param = ensure_ruby_date(end_param).iso8601.split("+")[0] + + all_endpoints = user_ids.map do |email| + "/users/#{email}/calendarView" + end + slice_size = 20 + responses = [] + all_endpoints.each_slice(slice_size).with_index do |endpoints, ind| + query = { + '$top': 200, + startDateTime: start_param, + endDateTime: end_param, + } + bulk_response = bulk_graph_request(request_method: 'get', endpoints: endpoints, query: query ) + + check_response(bulk_response) + parsed_response = JSON.parse(bulk_response.body)['responses'] + parsed_response.each do |res| + local_id = res['id'].to_i + global_id = local_id + (slice_size * ind.to_i) + res['id'] = global_id + responses.push(res) + end + end + + recurring_bookings = {} + responses.each_with_index do |res, i| + recurring_bookings[user_ids[res['id'].to_i]] = res['body']['value'] + end + recurring_bookings + end + + def get_bookings_by_room(room_id:, start_param:Time.now, end_param:(Time.now + 1.week)) + return get_bookings_by_user(user_id: room_id, start_param: start_param, end_param: end_param) + end + + # https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_post_events + def create_booking(room_id:, start_param:, end_param:, subject:, description:nil, current_user:, attendees: nil, recurrence: nil, is_private: false, timezone:'Sydney') + description = String(description) + attendees = Array(attendees) + + # Get our room + room = Orchestrator::ControlSystem.find(room_id) + + if @mailbox_location == 'room' || current_user.nil? + endpoint = "/v1.0/users/#{room.email}/events" + elsif @mailbox_location == 'user' + endpoint = "/v1.0/users/#{current_user[:email]}/events" + end + + # Ensure our start and end params are Ruby dates and format them in Graph format + start_object = ensure_ruby_date(start_param).in_time_zone(timezone) + end_object = ensure_ruby_date(end_param).in_time_zone(timezone) + start_param = ensure_ruby_date(start_param).in_time_zone(timezone).iso8601.split("+")[0] + end_param = ensure_ruby_date(end_param).in_time_zone(timezone).iso8601.split("+")[0] + + + # Add the attendees + attendees.map!{|a| + if a[:optional] + attendee_type = 'optional' + else + attendee_type = 'required' + end + { emailAddress: { + address: a[:email], + name: a[:name] + }, + type: attendee_type + } + } + + # Add the room as an attendee + attendees.push({ + type: "resource", + emailAddress: { + address: room.email, + name: room.name + } + }) + + # Add the current user as an attendee + if current_user + attendees.push({ + emailAddress: { + address: current_user[:email], + name: current_user[:name] + } + }) + end + + # Create our event which will eventually be stringified + event = { + subject: subject, + body: { + contentType: 'html', + content: description + }, + start: { + dateTime: start_param, + timeZone: TIMEZONE_MAPPING[timezone.to_sym] + }, + end: { + dateTime: end_param, + timeZone: TIMEZONE_MAPPING[timezone.to_sym] + }, + location: { + displayName: room.name, + locationEmailAddress: room.email + }, + isOrganizer: false, + attendees: attendees + } + + if current_user + event[:organizer] = { + emailAddress: { + address: current_user.email, + name: current_user.name + } + } + else + event[:organizer] = { + emailAddress: { + address: room.email, + name: room.name + } + } + end + + if recurrence + event[:recurrence] = { + pattern: { + type: recurrence, + interval: 1, + daysOfWeek: [start_object.strftime("%A")] + }, + range: { + type: 'noEnd', + startDate: start_object.strftime("%F") + } + } + end + + if is_private + event[:sensitivity] = 'private' + end + + event = event.to_json + + request = graph_request(request_method: 'post', endpoint: endpoint, data: event, password: @delegated) + + check_response(request) + + response = JSON.parse(request.body) + end + + # https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/event_update + def update_booking(booking_id:, room_id:, start_param:nil, end_param:nil, subject:nil, description:nil, attendees:nil, current_user:nil, timezone:'Sydney') + # We will always need a room and endpoint passed in + room = Orchestrator::ControlSystem.find_by_email(room_id) + + + if @mailbox_location == 'room' || current_user.nil? + endpoint = "/v1.0/users/#{room.email}/events/#{booking_id}" + elsif @mailbox_location == 'user' + endpoint = "/v1.0/users/#{current_user[:email]}/events/#{booking_id}" + end + + + + start_object = ensure_ruby_date(start_param).in_time_zone(timezone) + end_object = ensure_ruby_date(end_param).in_time_zone(timezone) + start_param = ensure_ruby_date(start_param).in_time_zone(timezone).iso8601.split("+")[0] + end_param = ensure_ruby_date(end_param).in_time_zone(timezone).iso8601.split("+")[0] + + event = {} + event[:subject] = subject if subject + + event[:start] = { + dateTime: start_param, + timeZone: TIMEZONE_MAPPING[timezone.to_sym] + } if start_param + + event[:end] = { + dateTime: end_param, + timeZone: TIMEZONE_MAPPING[timezone.to_sym] + } if end_param + + event[:body] = { + contentType: 'html', + content: description + } if description + + # Let's assume that the request has the current user and room as an attendee already + event[:attendees] = attendees.map{|a| + { + emailAddress: { + address: a[:email], + name: a[:name] + } + } + } if attendees + + event[:attendees].push({ + emailAddress: { + address: room.email, + name: room.name + }, + type: 'resource' + }) + + request = graph_request(request_method: 'patch', endpoint: endpoint, data: event.to_json, password: @delegated) + check_response(request) + response = JSON.parse(request.body)['value'] + end + + + # Takes a date of any kind (epoch, string, time object) and returns a time object + def ensure_ruby_date(date) + if !(date.class == Time || date.class == DateTime) + if string_is_digits(date) + + # Convert to an integer + date = date.to_i + + # If JavaScript epoch remove milliseconds + if date.to_s.length == 13 + date /= 1000 + end + + # Convert to datetimes + date = Time.at(date) + else + date = Time.parse(date) + end + end + return date + end + + # Returns true if a string is all digits (used to check for an epoch) + def string_is_digits(string) + string = string.to_s + string.scan(/\D/).empty? + end + +end diff --git a/lib/microsoft/skype.rb b/lib/microsoft/skype.rb new file mode 100644 index 00000000..b4cce7fc --- /dev/null +++ b/lib/microsoft/skype.rb @@ -0,0 +1,102 @@ +require 'active_support/time' +require 'logger' +module Microsoft + class Error < StandardError + class ResourceNotFound < Error; end + class InvalidAuthenticationToken < Error; end + class BadRequest < Error; end + class ErrorInvalidIdMalformed < Error; end + class ErrorAccessDenied < Error; end + end +end + +class Microsoft::Skype + TIMEZONE_MAPPING = { + "Sydney": "AUS Eastern Standard Time" + } + def initialize( + domain:, + client_id:, + client_secret:, + username:, + password: + ) + @domain = domain + @username = username + @password = password + @client_id = client_id + @client_secret = client_secret + end + + # Probably the only public method that will be called + def create_meeting + user_url = dicover_user_url + end + + def get_token(url) + uri = URI(url) + resource = "#{uri.scheme}://#{uri.host}" + token_uri = URI("https://login.windows.net/#{@domain.split('.').first}.onmicrosoft.com/oauth2/token") + params = {:resource=>resource, :client_id=>@client_id, :grant_type=>"password", + :username=>@username, :password=>@password, :client_secret=>@client_secret} + puts "PARAMS ARE" + puts params + skype_auth_api = UV::HttpEndpoint.new(token_uri, {inactivity_timeout: 25000, keepalive: false}) + request = skype_auth_api.post({path: token_uri, body: params, headers: {"Content-Type":"application/x-www-form-urlencoded"}}) + auth_response = nil + reactor.run { + auth_response = request.value + } + JSON.parse(auth_response.body)["access_token"] + end + + def create_skype_meeting(subject) + my_online_meetings_url = @app["_embedded"]["onlineMeetings"]["_links"]["myOnlineMeetings"]["href"] + + body = {accessLevel: "Everyone", subject: subject} + + url = @base_url+my_online_meetings_url + r = RestClient.post url, body.to_json, @apps_headers + return JSON.parse(r.body) + end + + def discover_user_url + @skype_domain = "http://lyncdiscover.#{@domain}" + skype_discover_api = UV::HttpEndpoint.new(@skype_domain, {inactivity_timeout: 25000, keepalive: false}) + discover_request = skype_discover_api.get + discover_response = nil + reactor.run { + discover_response = discover_request.value + } + r = JSON.parse(discover_response.body) + r["_links"]["user"]["href"] + end + + def get_user_data + user_token = get_token(users_url) + + users_headers = { + "Accept" => "application/json", + "Content-Type" => "application/json", + "Authorization" => "Bearer #{user_token}" + } + + + # GET to users_url + skype_users_api = UV::HttpEndpoint.new(users_url, {inactivity_timeout: 25000, keepalive: false}) + user_request = skype_users_api.get({ + path: users_url, + headers: users_headers + }) + user_response = nil + reactor.run { + user_response = user_request.value + } + full_auth_response = JSON.parse(user_response.body) + + end + + def discover_apps_url(user_url) + + end +end diff --git a/modules/aca/demo_logic.rb b/modules/aca/demo_logic.rb index 7af505aa..92e9bdd6 100644 --- a/modules/aca/demo_logic.rb +++ b/modules/aca/demo_logic.rb @@ -9,6 +9,7 @@ class Aca::DemoLogic def on_load + self[:name] = system.name self[:volume] = 0 self[:mute] = false self[:views] = 0 diff --git a/modules/aca/device_config.rb b/modules/aca/device_config.rb new file mode 100644 index 00000000..a326939c --- /dev/null +++ b/modules/aca/device_config.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Aca; end + +class Aca::DeviceConfig + include ::Orchestrator::Constants + + descriptive_name 'ACA Device Config Manager' + generic_name :DeviceConfig + implements :logic + description <<~DESC + Utility module for executing device setup actions when connectivity is + established. + + Actions may be specified under the `device_config` setting. This should + be of the form: + + mod => { method => args } + + Or, if a method must be executed multiple times + + mod => [{ method => args }] + DESC + + default_settings( + # Setup actions to perform on any devices to ensure they are correctly + # configured for interaction with this system. Structure should be of + # the form device => { method => args }. These actions will be pushed + # to the device on connect. + device_config: {} + ) + + def on_load + system.load_complete do + setup_config_subscriptions + end + end + + def on_update + setup_config_subscriptions + end + + + protected + + + # Setup event subscriptions to push device setup actions to devices when + # they connect. + def setup_config_subscriptions + @device_config_subscriptions&.each { |ref| unsubscribe ref } + + @device_config_subscriptions = load_config.map do |dev, actions| + mod, idx = mod_idx_for dev + + system.subscribe(mod, idx, :connected) do |notification| + next unless notification.value + logger.debug { "pushing system defined config to #{dev}" } + device = system.get mod, idx + actions.each { |(method, args)| device.send method, *args } + end + end + end + + def load_config + actions = setting(:device_config) || {} + + # Allow device config actions to either be specified as a single hash + # of method => arg mappings, or an array of these if the same method + # needs to be called multiple times. + actions.transform_values! do |exec_methods| + exec_methods = Array.wrap exec_methods + exec_methods.flat_map(&:to_a).map do |method, args| + [method.to_sym, Array.wrap(args)] + end + end + + actions.freeze + end + + + + # Map a module id in the form name_idx out into its [name, idx] components. + # + # @param device [Symbol, String] the module id to destructure + # @return [[Symbol, Integer]] + def mod_idx_for(device) + mod, idx = device.to_s.split '_' + mod = mod.to_sym + idx = idx&.to_i || 1 + [mod, idx] + end +end diff --git a/modules/aca/exchange_booking.rb b/modules/aca/exchange_booking.rb index 7bf09ad2..a745b7f2 100644 --- a/modules/aca/exchange_booking.rb +++ b/modules/aca/exchange_booking.rb @@ -96,6 +96,11 @@ def on_update self[:hide_all] = setting(:hide_all) || false self[:touch_enabled] = setting(:touch_enabled) || false self[:name] = self[:room_name] = setting(:room_name) || system.name + self[:description] = setting(:description) || nil + self[:title] = setting(:title) || nil + self[:timeout] = setting(:timeout) || false + self[:booking_endable] = setting(:booking_endable) || false + self[:booking_ask_end] = setting(:booking_ask_end) || false self[:control_url] = setting(:booking_control_url) || system.config.support_url self[:booking_controls] = setting(:booking_controls) @@ -105,10 +110,17 @@ def on_update self[:booking_hide_user] = setting(:booking_hide_user) self[:booking_hide_description] = setting(:booking_hide_description) self[:booking_hide_timeline] = setting(:booking_hide_timeline) + self[:last_meeting_started] = setting(:last_meeting_started) + self[:cancel_meeting_after] = setting(:cancel_meeting_after) self[:booking_min_duration] = setting(:booking_min_duration) + self[:booking_cancel_timeout] = setting(:booking_cancel_timeout) self[:booking_disable_future] = setting(:booking_disable_future) self[:booking_max_duration] = setting(:booking_max_duration) + self[:hide_all_day_bookings] = setting(:hide_all_day_bookings) self[:timeout] = setting(:timeout) + self[:arrow_direction] = setting(:arrow_direction) + self[:icon] = setting(:icon) + @hide_all_day_bookings = Boolean(setting(:hide_all_day_bookings)) @check_meeting_ending = setting(:check_meeting_ending) # seconds before meeting ending @extend_meeting_by = setting(:extend_meeting_by) || 15.minutes.to_i @@ -197,8 +209,12 @@ def on_update end schedule.clear - schedule.in(rand(10000)) { fetch_bookings } - schedule.every((setting(:update_every) || 120000) + rand(10000)) { fetch_bookings } + schedule.in(rand(10000)) { + fetch_bookings(true) + } + schedule.every((setting(:update_every) || 120000).to_i + rand(10000)) { + fetch_bookings + } end @@ -342,10 +358,11 @@ def order_complete # ====================================== # ROOM BOOKINGS: # ====================================== - def fetch_bookings(*args) + def fetch_bookings(first=false) logger.debug { "looking up todays emails for #{@ews_room}" } + skype_exists = system.exists?(:Skype) task { - todays_bookings + todays_bookings(first, skype_exists) }.then(proc { |bookings| self[:today] = bookings if @check_meeting_ending @@ -367,7 +384,7 @@ def start_meeting(meeting_ref) define_setting(:last_meeting_started, meeting_ref) end - def cancel_meeting(start_time) + def cancel_meeting(start_time, reason = "timeout") task { if start_time.class == Integer delete_ews_booking (start_time / 1000).to_i @@ -377,7 +394,7 @@ def cancel_meeting(start_time) delete_ews_booking start_time.to_i end }.then(proc { |count| - logger.debug { "successfully removed #{count} bookings" } + logger.warn { "successfully removed #{count} bookings due to #{reason}" } self[:last_meeting_started] = 0 self[:meeting_pending] = 0 @@ -598,10 +615,15 @@ def get_attr(entry, attr_name) def make_ews_booking(user_email: nil, subject: 'On the spot booking', room_email:, start_time:, end_time:) user_email ||= self[:email] # if swipe card used + if subject.empty? + subject = "Quick Book by Display Panel On #{system.name}" + end + booking = { subject: subject, start: start_time, - end: end_time + end: end_time, + location: system.name } if user_email @@ -627,48 +649,50 @@ def make_ews_booking(user_email: nil, subject: 'On the spot booking', room_email end def delete_ews_booking(delete_at) - now = Time.now - if @timezone - start = now.in_time_zone(@timezone).midnight - ending = now.in_time_zone(@timezone).tomorrow.midnight - else - start = now.midnight - ending = now.tomorrow.midnight - end - - count = 0 - - cli = Viewpoint::EWSClient.new(*@ews_creds) - - if @use_act_as - # TODO:: think this line can be removed?? - delete_at = Time.parse(delete_at.to_s).to_i - - opts = {} - opts[:act_as] = @ews_room if @ews_room - - folder = cli.get_folder(:calendar, opts) - items = folder.items({:calendar_view => {:start_date => start.utc.iso8601, :end_date => ending.utc.iso8601}}) - else - cli.set_impersonation(Viewpoint::EWS::ConnectingSID[@ews_connect_type], @ews_room) if @ews_room - items = cli.find_items({:folder_id => :calendar, :calendar_view => {:start_date => start.utc.iso8601, :end_date => ending.utc.iso8601}}) - end - - items.each do |meeting| - meeting_time = Time.parse(meeting.ews_item[:start][:text]) - - # Remove any meetings that match the start time provided - if meeting_time.to_i == delete_at - meeting.delete!(:recycle, send_meeting_cancellations: 'SendOnlyToAll') - count += 1 - end - end - - # Return the number of meetings removed - count + now = Time.now + if @timezone + start = now.in_time_zone(@timezone).midnight + ending = now.in_time_zone(@timezone).tomorrow.midnight + else + start = now.midnight + ending = now.tomorrow.midnight + end + + count = 0 + + cli = Viewpoint::EWSClient.new(*@ews_creds) + + if @use_act_as + # TODO:: think this line can be removed?? + # delete_at = Time.parse(delete_at.to_s).to_i + + opts = {} + opts[:act_as] = @ews_room if @ews_room + + folder = cli.get_folder(:calendar, opts) + items = folder.items({:calendar_view => {:start_date => start.utc.iso8601, :end_date => ending.utc.iso8601}}) + else + cli.set_impersonation(Viewpoint::EWS::ConnectingSID[@ews_connect_type], @ews_room) if @ews_room + items = cli.find_items({:folder_id => :calendar, :calendar_view => {:start_date => start.utc.iso8601, :end_date => ending.utc.iso8601}}) + end + + items.each do |meeting| + meeting_time = Time.parse(meeting.ews_item[:start][:text]) + + # any meetings that match the start time provided + if meeting_time.to_i == delete_at + # new_booking = meeting.update_item!({ end: Time.now.utc.iso8601.chop }) + + meeting.delete!(:recycle, send_meeting_cancellations: 'SendOnlyToAll') + count += 1 + end + end + + # Return the number of meetings removed + count end - def todays_bookings + def todays_bookings(first=false, skype_exists=false) now = Time.now if @timezone start = now.in_time_zone(@timezone).midnight @@ -692,11 +716,11 @@ def todays_bookings items = cli.find_items({:folder_id => :calendar, :calendar_view => {:start_date => start.utc.iso8601, :end_date => ending.utc.iso8601}}) end - skype_exists = set_skype_url = system.exists?(:Skype) + set_skype_url = skype_exists set_skype_url = true if @force_skype_extract now_int = now.to_i - items.select! { |booking| !booking.cancelled? } + # items.select! { |booking| !booking.cancelled? } results = items.collect do |meeting| item = meeting.ews_item start = item[:start][:text] @@ -712,6 +736,9 @@ def todays_bookings end_integer = real_end.to_i - @skype_end_offset if now_int > start_integer && now_int < end_integer + if first + self[:last_meeting_started] = start_integer * 1000 + end meeting.get_all_properties! if meeting.body @@ -739,6 +766,7 @@ def todays_bookings self[:skype_meeting_pending] = true end set_skype_url = false + self[:skype_meeting_address] = links[0] system[:Skype].set_uri(links[0]) if skype_exists end end @@ -756,15 +784,23 @@ def todays_bookings logger.debug { item.inspect } + if @hide_all_day_bookings + next if Time.parse(ending) - Time.parse(start) > 86399 + end + # Prevent connections handing with TIME_WAIT - cli.ews.connection.httpcli.reset_all + # cli.ews.connection.httpcli.reset_all - subject = item[:subject] + if ["Private", "Confidential"].include?(meeting.sensitivity) + subject = meeting.sensitivity + else + subject = item[:subject][:text] + end { :Start => start, :End => ending, - :Subject => subject ? subject[:text] : "Private", + :Subject => subject, :owner => item[:organizer][:elems][0][:mailbox][:elems][0][:name][:text], :setup => 0, :breakdown => 0, @@ -772,6 +808,7 @@ def todays_bookings :end_epoch => real_end.to_i } end + results.compact! if set_skype_url self[:pexip_meeting_address] = nil diff --git a/modules/aca/google_refresh_booking.rb b/modules/aca/google_refresh_booking.rb index 43a872a1..d6597ef3 100644 --- a/modules/aca/google_refresh_booking.rb +++ b/modules/aca/google_refresh_booking.rb @@ -30,7 +30,9 @@ class Aca::GoogleRefreshBooking false end CAN_GOOGLE = begin - require 'google_calendar' + require 'googleauth' + require 'google/apis/admin_directory_v1' + require 'google/apis/calendar_v3' true rescue LoadError false @@ -80,9 +82,11 @@ class Aca::GoogleRefreshBooking ews_room: 'room@email.address', # Optional EWS for creating and removing bookings - google_organiser_location: 'attendees' - # google_client_id: ENV["GOOGLE_APP_CLIENT_ID"], - # google_secret: ENV["GOOGLE_APP_CLIENT_SECRET"], + google_organiser_location: 'attendees', + google_client_id: '', + google_secret: '', + google_redirect_uri: '', + google_scope: 'https://www.googleapis.com/auth/calendar', # google_scope: ENV['GOOGLE_APP_SCOPE'], # google_site: ENV["GOOGLE_APP_SITE"], # google_token_url: ENV["GOOGLE_APP_TOKEN_URL"], @@ -157,6 +161,7 @@ def on_update @google_secret = setting(:google_client_secret) @google_redirect_uri = setting(:google_redirect_uri) @google_refresh_token = setting(:google_refresh_token) + @google_scope = setting(:google_scope) @google_room = (setting(:google_room) || system.email) # supports: SMTP, PSMTP, SID, UPN (user principle name) # NOTE:: Using UPN we might be able to remove the LDAP requirement @@ -318,27 +323,22 @@ def fetch_bookings(*args) # client = OAuth2::Client.new(@google_client_id, @google_secret, {site: @google_site, token_url: @google_token_url}) - # Create an instance of the calendar. - params = {:client_id => @google_client_id, - :client_secret => @google_secret, - :calendar => @google_room, - :redirect_url => @google_redirect_uri}.to_json - logger.debug params - begin - cal = Google::Calendar.new(:client_id => @google_client_id, - :client_secret => @google_secret, - :calendar => @google_room, - :redirect_url => @google_redirect_uri # this is what Google uses for 'applications' - ) - cal.connection.login_with_refresh_token(@google_refresh_token) - - events = cal.find_events_in_range(Time.now.midnight, Time.now.tomorrow.midnight, {max_results: 2500}) - rescue Exception => e - logger.debug e.message - logger.debug e.backtrace.inspect - raise e - end + options = { + client_id: @google_client_id, + client_secret: @google_secret, + scope: @google_scope, + redirect_uri: @google_redirect_uri, + refresh_token: @google_refresh_token, + grant_type: "refresh_token" + } + authorization = Google::Auth::UserRefreshCredentials.new options + + # Calendar = Google::Apis::CalendarV3 + calendar = Calendar::CalendarService.new + calendar.authorization = authorization + events = calendar.list_events(system.email) + task { todays_bookings(events) }.then(proc { |bookings| diff --git a/modules/aca/http_ping.rb b/modules/aca/http_ping.rb new file mode 100644 index 00000000..7d82fc39 --- /dev/null +++ b/modules/aca/http_ping.rb @@ -0,0 +1,33 @@ +module Aca; end + +class Aca::HttpPing + include ::Orchestrator::Constants + + implements :service + descriptive_name 'Check if service is live' + generic_name :HttpPing + + keepalive false + + def on_load + schedule.every('60s') { check_status } + on_update + end + + def on_update + @path = setting(:path) || '/' + @result = setting(:result) || 200 + + # Don't update status on connection failure as we maintaining this + config update_status: false + end + + def check_status + get(@path, name: :check_status) { |data| + logger.debug { "request status was #{data.status.inspect}" } + set_connected_state(data.status == @result) + :success + } + nil + end +end diff --git a/modules/aca/joiner.rb b/modules/aca/joiner.rb index 4a55a702..e2c80075 100644 --- a/modules/aca/joiner.rb +++ b/modules/aca/joiner.rb @@ -105,7 +105,7 @@ def join(*ids) start_joining # Ensure all id's are symbols - ids.map! {|id| id.to_sym} + ids = ids.flatten.map {|id| id.to_sym} # Grab only valid IDs rmset = Set.new(ids) & @rooms diff --git a/modules/aca/meeting_room.rb b/modules/aca/meeting_room.rb index 153e1776..fcd0f0d4 100644 --- a/modules/aca/meeting_room.rb +++ b/modules/aca/meeting_room.rb @@ -22,6 +22,7 @@ def on_update self[:analytics] = setting(:analytics) self[:Camera] = setting(:Camera) self[:Wired] = setting(:Wired) + self[:vc_show_pres_layout] = setting(:vc_show_pres_layout) self[:hide_vc_sources] = setting(:hide_vc_sources) self[:mics_mutes] = setting(:mics_mutes) @confidence_monitor = setting(:confidence_monitor) @@ -511,8 +512,11 @@ def switch_mode(mode_name, from_join = false, booting: false) mode_outs = mode[:outputs] || {} difference = mode_outs.keys - default_outs.keys - self[:outputs] = ActiveSupport::HashWithIndifferentAccess.new.deep_merge(mode_outs.merge(default_outs)) - @original_outputs = self[:outputs].deep_dup + if mode[:outputs_clobber] + self[:outputs] = ActiveSupport::HashWithIndifferentAccess.new.deep_merge(mode_outs) + else + self[:outputs] = ActiveSupport::HashWithIndifferentAccess.new.deep_merge(mode_outs.merge(default_outs)) + end if respond_to? :switch_mode_custom begin @@ -525,7 +529,9 @@ def switch_mode(mode_name, from_join = false, booting: false) # Update the inputs inps = (setting(:inputs) + (mode[:inputs] || [])) - (mode[:remove_inputs] || []) inps.uniq! - inps.each do |input| + + # Camera is special + (inps + [:Camera]).uniq.each do |input| inp = setting(input) || mode[input] if inp @@ -748,7 +754,9 @@ def shutdown_actual(scheduled_shutdown = false) end switch_mode(@defaults[:shutdown_mode]) if @defaults[:shutdown_mode] + self[:vc_content_source] = nil + shutdown_vc mixer = system[:Mixer] @@ -876,9 +884,18 @@ def shutdown_actual(scheduled_shutdown = false) # # MISC FUNCTIONS # + + def shutdown_vc + vidconf = system[:VidConf] + vidconf.call('disconnect') + vc_mute true + end + def init_vc - start_cameras + system[:VidConf].clear_search_results system[:VidConf].wake + start_cameras + vc_mute false end def vc_status_changed(state) @@ -894,10 +911,9 @@ def vc_status_changed(state) end def vc_mute(mute) - perform_action(mod: :System, func: :vc_mute_actual, args: [mute]) - vidconf = system[:VidConf] - vidconf.mute(mute) unless vidconf.nil? + vidconf.mute(mute) unless vidconf.nil? || setting(:disable_vc_mute) + perform_action(mod: :System, func: :vc_mute_actual, args: [mute]) end def vc_mute_actual(mute) @@ -936,6 +952,10 @@ def select_camera(source, input, output = nil) inp = src[:input] out = src[:output] system[:Switcher].switch({inp => out}) if inp && out + + # Enable or disable Cisco Speakertrack for this camera + speaker_track_setting = src[:auto_camera] # true/false/nil. When nil, no command is sent + system[:VidConf].speaker_track(speaker_track_setting) unless speaker_track_setting.nil? end end @@ -1264,7 +1284,7 @@ def show(source, display) end if disp_source[:audio_deembed] - switcher.switch({disp_source[:input] => disp_source[:audio_deembed]}) + switcher.switch_audio({disp_source[:input] => disp_source[:audio_deembed]}) end end diff --git a/modules/aca/office_booking.rb b/modules/aca/office_booking.rb index 4cebeec0..0abe7525 100644 --- a/modules/aca/office_booking.rb +++ b/modules/aca/office_booking.rb @@ -2,6 +2,7 @@ require 'faraday' require 'uv-rays' +require 'microsoft/office' Faraday.default_adapter = :libuv # For rounding up to the nearest 15min @@ -117,6 +118,8 @@ def on_update self[:booking_hide_user] = setting(:booking_hide_user) self[:booking_hide_description] = setting(:booking_hide_description) self[:booking_hide_timeline] = setting(:booking_hide_timeline) + self[:booking_endable] = setting(:booking_endable) + self[:timeout] = setting(:timeout) # Skype join button available 2min before the start of a meeting @skype_start_offset = setting(:skype_start_offset) || 120 @@ -149,18 +152,33 @@ def on_update # Do we want to use exchange web services to manage bookings if CAN_OFFICE logger.debug "Setting OFFICE" - @office_organiser_location = setting(:office_organiser_location) + @office_organiser_location = setting(:office_organiser_location) @office_client_id = setting(:office_client_id) @office_secret = setting(:office_secret) @office_scope = setting(:office_scope) @office_site = setting(:office_site) @office_token_url = setting(:office_token_url) @office_options = setting(:office_options) + @office_user_email = setting(:office_user_email) + @office_user_password = setting(:office_user_password) + @office_delegated = setting(:office_delegated) @office_room = (setting(:office_room) || system.email) # supports: SMTP, PSMTP, SID, UPN (user principle name) # NOTE:: Using UPN we might be able to remove the LDAP requirement @office_connect_type = (setting(:office_connect_type) || :SMTP).to_sym @timezone = setting(:room_timezone) + + @client = ::Microsoft::Office.new({ + client_id: @office_client_id || ENV['OFFICE_CLIENT_ID'], + client_secret: @office_secret || ENV["OFFICE_CLIENT_SECRET"], + app_site: @office_site || ENV["OFFICE_SITE"] || "https://login.microsoftonline.com", + app_token_url: @office_token_url || ENV["OFFICE_TOKEN_URL"], + app_scope: @office_scope || ENV['OFFICE_SCOPE'] || "https://graph.microsoft.com/.default", + graph_domain: ENV['GRAPH_DOMAIN'] || "https://graph.microsoft.com", + service_account_email: @office_user_password || ENV['OFFICE_ACCOUNT_EMAIL'], + service_account_password: @office_user_password || ENV['OFFICE_ACCOUNT_PASSWORD'], + internet_proxy: @internet_proxy || ENV['INTERNET_PROXY'] + }) else logger.warn "oauth2 gem not available" if setting(:office_creds) end @@ -306,54 +324,9 @@ def order_complete # ROOM BOOKINGS: # ====================================== def fetch_bookings(*args) - - # @office_client_id = ENV["OFFICE_APP_CLIENT_ID"] - # @office_secret = ENV["OFFICE_APP_CLIENT_SECRET"] - # @office_scope = ENV['OFFICE_APP_SCOPE'] - # @office_options = { - # site: ENV["OFFICE_APP_SITE"], - # token_url: ENV["OFFICE_APP_TOKEN_URL"] - # } - # @office_room = 'testroom@internationaltowers.com' - - client = OAuth2::Client.new(@office_client_id, @office_secret, {site: @office_site, token_url: @office_token_url}) - - begin - access_token = client.client_credentials.get_token({ - :scope => @office_scope - # :client_secret => ENV["OFFICE_APP_CLIENT_SECRET"], - # :client_id => ENV["OFFICE_APP_CLIENT_ID"] - }).token - rescue Exception => e - logger.debug e.message - logger.debug e.backtrace.inspect - raise e - end - - - # Set out domain, endpoint and content type - domain = 'https://graph.microsoft.com' - host = 'graph.microsoft.com' - endpoint = "/v1.0/users/#{@office_room}/events" - content_type = 'application/json;odata.metadata=minimal;odata.streaming=true' - - # Create the request URI and config - office_api = UV::HttpEndpoint.new(domain, tls_options: {host_name: host}) - headers = { - 'Authorization' => "Bearer #{access_token}", - 'Content-Type' => content_type - } - # Make the request - response = office_api.get(path: "#{domain}#{endpoint}", headers: headers).value - - - - task { - todays_bookings(response, @office_organiser_location) - }.then(proc { |bookings| - self[:today] = bookings - }, proc { |e| logger.print_error(e, 'error fetching bookings') }) + response = @client.get_bookings_by_user(user_id: @office_room, start_param: Time.now.midnight, end_param: Time.now.tomorrow.midnight)[:bookings] + self[:today] = todays_bookings(response, @office_organiser_location) end @@ -370,18 +343,17 @@ def start_meeting(meeting_ref) end def cancel_meeting(start_time) - task { + if start_time.class == Integer delete_ews_booking (start_time / 1000).to_i - }.then(proc { |count| - logger.debug { "successfully removed #{count} bookings" } - - self[:last_meeting_started] = start_time - self[:meeting_pending] = start_time - self[:meeting_ending] = false - self[:meeting_pending_notice] = false - }, proc { |error| - logger.print_error error, 'removing ews booking' - }) + else + # Converts to time object regardless of start_time being string or time object + start_time = Time.parse(start_time.to_s) + delete_ews_booking start_time.to_i + end + self[:last_meeting_started] = start_time + self[:meeting_pending] = start_time + self[:meeting_ending] = false + self[:meeting_pending_notice] = false end # If last meeting started !== meeting pending then @@ -428,8 +400,8 @@ def create_meeting(options) req_params[:room_email] = @ews_room req_params[:organizer] = options[:user_email] req_params[:subject] = options[:title] - req_params[:start_time] = Time.at(options[:start].to_i / 1000).utc.iso8601.chop - req_params[:end_time] = Time.at(options[:end].to_i / 1000).utc.iso8601.chop + req_params[:start_time] = Time.at(options[:start].to_i / 1000).utc.to_i + req_params[:end_time] = Time.at(options[:end].to_i / 1000).utc.to_i # TODO:: Catch error for booking failure @@ -535,93 +507,91 @@ def get_attr(entry, attr_name) # ======================================= def make_office_booking(user_email: nil, subject: 'On the spot booking', room_email:, start_time:, end_time:, organizer:) + STDERR.puts organizer + logger.info organizer + + STDERR.puts organizer.class + logger.info organizer.class + + STDERR.puts organizer.nil? + logger.info organizer.nil? + + STDERR.flush + booking_data = { subject: subject, start: { dateTime: start_time, timeZone: "UTC" }, end: { dateTime: end_time, timeZone: "UTC" }, location: { displayName: @office_room, locationEmailAddress: @office_room }, attendees: [ emailAddress: { address: organizer, name: "User"}] - }.to_json - - logger.debug "Creating booking:" - logger.debug booking_data + } - client = OAuth2::Client.new(@office_client_id, @office_secret, {site: @office_site, token_url: @office_token_url}) - begin - access_token = client.client_credentials.get_token({ - :scope => @office_scope - # :client_secret => ENV["OFFICE_APP_CLIENT_SECRET"], - # :client_id => ENV["OFFICE_APP_CLIENT_ID"] - }).token - rescue Exception => e - logger.debug e.message - logger.debug e.backtrace.inspect - raise e + if organizer.nil? + booking_data[:attendees] = [] end + booking_data = booking_data.to_json - # Set out domain, endpoint and content type - domain = 'https://graph.microsoft.com' - host = 'graph.microsoft.com' - endpoint = "/v1.0/users/#{@office_room}/events" - content_type = 'application/json;odata.metadata=minimal;odata.streaming=true' + logger.debug "Creating booking:" + logger.debug booking_data - # Create the request URI and config - office_api = UV::HttpEndpoint.new(domain, tls_options: {host_name: host}) - headers = { - 'Authorization' => "Bearer #{access_token}", - 'Content-Type' => content_type - } + # client = OAuth2::Client.new(@office_client_id, @office_secret, {site: @office_site, token_url: @office_token_url}) + + # begin + # access_token = client.client_credentials.get_token({ + # :scope => @office_scope + # # :client_secret => ENV["OFFICE_APP_CLIENT_SECRET"], + # # :client_id => ENV["OFFICE_APP_CLIENT_ID"] + # }).token + # rescue Exception => e + # logger.debug e.message + # logger.debug e.backtrace.inspect + # raise e + # end + + + # # Set out domain, endpoint and content type + # domain = 'https://graph.microsoft.com' + # host = 'graph.microsoft.com' + # endpoint = "/v1.0/users/#{@office_room}/events" + # content_type = 'application/json;odata.metadata=minimal;odata.streaming=true' + + # # Create the request URI and config + # office_api = UV::HttpEndpoint.new(domain, tls_options: {host_name: host}) + # headers = { + # 'Authorization' => "Bearer #{access_token}", + # 'Content-Type' => content_type + # } # Make the request - response = office_api.post(path: "#{domain}#{endpoint}", body: booking_data, headers: headers).value - logger.debug response.body - logger.debug response.to_json - logger.debug JSON.parse(response.body)['id'] + # response = office_api.post(path: "#{domain}#{endpoint}", body: booking_data, headers: headers).value + response = @client.create_booking(room_id: system.id, start_param: start_time, end_param: end_time, subject: subject, current_user: nil) + STDERR.puts "BOOKING SIP CREATE RESPONSE:" + STDERR.puts response.inspect + STDERR.puts response['id'] + STDERR.flush - id = JSON.parse(response.body)['id'] + id = response['id'] # Return the booking IDs id end def delete_ews_booking(delete_at) - now = Time.now - if @timezone - start = now.in_time_zone(@timezone).midnight - ending = now.in_time_zone(@timezone).tomorrow.midnight - else - start = now.midnight - ending = now.tomorrow.midnight - end - count = 0 - - cli = Viewpoint::EWSClient.new(*@ews_creds) - - if @use_act_as - # TODO:: think this line can be removed?? - delete_at = Time.parse(delete_at.to_s).to_i - - opts = {} - opts[:act_as] = @ews_room if @ews_room - - folder = cli.get_folder(:calendar, opts) - items = folder.items({:calendar_view => {:start_date => start.utc.iso8601, :end_date => ending.utc.iso8601}}) - else - cli.set_impersonation(Viewpoint::EWS::ConnectingSID[@ews_connect_type], @ews_room) if @ews_room - items = cli.find_items({:folder_id => :calendar, :calendar_view => {:start_date => start.utc.iso8601, :end_date => ending.utc.iso8601}}) - end - - items.each do |meeting| - meeting_time = Time.parse(meeting.ews_item[:start][:text]) - - # Remove any meetings that match the start time provided - if meeting_time.to_i == delete_at - meeting.delete!(:recycle, send_meeting_cancellations: 'SendOnlyToAll') - count += 1 + delete_at_object = Time.at(delete_at) + if self[:today] + self[:today].each_with_index do |booking, i| + booking_start_object = Time.parse(booking[:Start]) + if delete_at_object.to_i == booking_start_object.to_i + response = @client.delete_booking(booking_id: booking[:id], current_user: system) + if response == 200 + count += 1 + self[:today].delete(i) + end + end end end @@ -630,40 +600,49 @@ def delete_ews_booking(delete_at) end def todays_bookings(response, office_organiser_location) - - meeting_response = JSON.parse(response.body)['value'] - results = [] - - meeting_response.each{|booking| - + response.each{|booking| # start_time = Time.parse(booking['start']['dateTime']).utc.iso8601[0..18] + 'Z' # end_time = Time.parse(booking['end']['dateTime']).utc.iso8601[0..18] + 'Z' - start_time = ActiveSupport::TimeZone.new('UTC').parse(booking['start']['dateTime']).iso8601 - end_time = ActiveSupport::TimeZone.new('UTC').parse(booking['end']['dateTime']).iso8601 + if booking['start'].key?("timeZone") + start_time = ActiveSupport::TimeZone.new(booking['start']['timeZone']).parse(booking['start']['dateTime']).utc.iso8601 + end_time = ActiveSupport::TimeZone.new(booking['start']['timeZone']).parse(booking['end']['dateTime']).utc.iso8601 + end if office_organiser_location == 'attendees' # Grab the first attendee - organizer = booking['attendees'][0]['emailAddress']['name'] - elsif office_organiser_location == 'organizer' + if booking.key?('attendees') && !booking['attendees'].empty? + organizer = booking['attendees'][0]['name'] + else + organizer = "" + end + else # Grab the organiser - organizer = booking['organizer']['emailAddress']['name'] + organizer = booking['organizer']['name'] + end + + subject = booking['subject'] + if booking.key?('sensitivity') && ['private','confidential'].include?(booking['sensitivity']) + organizer = "" + subject = "" end results.push({ :Start => start_time, :End => end_time, - :Subject => booking['subject'], + :Subject => subject, + :id => booking['id'], :owner => organizer - # :setup => 0, - # :breakdown => 0 }) } - logger.info "Got #{results.length} results!" - logger.info results.to_json - results end + + def log(msg) + STDERR.puts msg + logger.info msg + STDERR.flush + end # ======================================= end diff --git a/modules/aca/router.rb b/modules/aca/router.rb new file mode 100644 index 00000000..844d6e77 --- /dev/null +++ b/modules/aca/router.rb @@ -0,0 +1,688 @@ +# frozen_string_literal: true + +require 'algorithms' +require 'set' + +module Aca; end + +class Aca::Router + include ::Orchestrator::Constants + + descriptive_name 'ACA Signal Router' + generic_name :Router + implements :logic + description <<~DESC + Signal distribution management for handling routing across multiple + devices and complex/layered switching infrastructure. + DESC + + + default_settings( + # Nested hash of signal connectivity. See SignalGraph.from_map. + connections: {} + ) + + + # ------------------------------ + # Callbacks + + def on_load + on_update + end + + def on_update + connections = setting :connections + + logger.warn 'no connections defined' unless connections + + load_from_map(connections || {}) + end + + + # ------------------------------ + # Public API + + # Route a set of signals to arbitrary destinations. + # + # `signal_map` is a hash of the structure `{ source: sink | [sinks] }` + # 'atomic' may be used to prevent activation of any part of the signal + # map, prior to any device interaction taking place, if any + # of the routes are not possible + # `force` control if switch events should be forced, even when the + # associated device module is already reporting it's on the + # correct input + # + # Multiple sources can be specified simultaneously, or if connecting a + # single source to a single destination, Ruby's implicit hash syntax can be + # used to let you express it neatly as `connect source => sink`. + # + # A promise is returned that will resolve when all device interactions have + # completed. This will be fullfilled with the applied signal map and a + # boolean - true if this was a complete recall, or false if partial. + def connect(signal_map, atomic: false, force: false) + # Convert the signal map to a nested hash of routes + # { source => { dest => [edges] } } + edge_map = build_edge_map signal_map, atomic: atomic + + # Reduce the edge map to a set of edges + edges_to_connect = edge_map.reduce(Set.new) do |s, (_, routes)| + s | routes.values.flatten + end + + switch = activate_all edges_to_connect, atomic: atomic, force: force + + switch.then do |success, failed| + if failed.empty? + logger.debug 'signal map activated' + recalled_map = edge_map.transform_values(&:keys) + [recalled_map, true] + + elsif success.empty? + thread.reject 'failed to activate, devices untouched' + + else + logger.warn 'signal map partially activated' + recalled_map = edge_map.transform_values do |routes| + routes.each_with_object([]) do |(output, edges), completed| + completed << output if success.superset? Set.new(edges) + end + end + [recalled_map, false] + end + end + end + + # Lookup the input on a sink node that would be used to connect a specific + # source to it. + # + # `on` may be ommited if the source node has only one neighbour (e.g. is + # an input node) and you wish to query the phsycial input associated with + # it. Similarly `on` maybe used to look up the input used by any other node + # within the graph that would be used to show `source`. + def input_for(source, on: nil) + if on.nil? + edges = signal_graph.incoming_edges source + raise "no outputs from #{source}" if edges.empty? + raise "multiple outputs from #{source}, please specify a sink" \ + unless edges.map(&:device).uniq.size == 1 + else + _, edges = route source, on + end + + edges.last.input + end + + # Find the device that input node is attached to. + # + # Efficiently queries the graph for the device that an signal input connects + # to for checking signal properties revealed by the device state. + def device_for(source) + edges = signal_graph.incoming_edges source + raise "no outputs from #{source}" if edges.empty? + raise "#{source} is not an input node" if edges.size > 1 + edges.first.device + end + + # Get a list of devices that a signal passes through for a specific route. + # + # This may be used to walk up or down a path to find encoders, decoders or + # other devices that may provide some interesting state, or require + # additional interactions (signal presence monitoring etc). + def devices_between(source, sink) + _, edges = route source, sink + edges.map(&:device) + end + + # Given a sink id, find the chain of devices that sit immediately upstream + # in the signal path. The returned list will include all devices which for + # a static, linear chain exists before any routing is possible + # + # This may be used to find devices that are installed for the use of this + # output only (decoders, image processors etc). + # + # If the sink itself has mutiple inputs, the input to retrieve the chain for + # may be specified with the `on_input` param. + def upstream_devices_of(sink, on_input: nil) + device_chain = [] + + # Bail out early if there's no linear signal path from the sink + return device_chain unless on_input || signal_graph.outdegree(sink) == 1 + + # Otherwise, grab the initial edge from the sink node + initial = signal_graph[sink].edges.values.find do |edge| + if on_input + edge.input == on_input.to_sym + else + true + end + end + + # Then walk the graph and accumulate devices until we reach a branch + successors = [initial.target] + while successors.size == 1 + node = successors.first + device_chain << node + successors = signal_graph.successors node + end + + device_chain + end + + + # ------------------------------ + # Internals + + protected + + def signal_graph + @signal_graph ||= SignalGraph.new + end + + def paths + @path_cache ||= Hash.new do |hash, node| + hash[node] = signal_graph.dijkstra node + end + end + + def load_from_map(connections) + logger.debug 'building graph from signal map' + + @path_cache = nil + @signal_graph = SignalGraph.from_map(connections).freeze + + # TODO: track active signal source at each node and expose as a hash + self[:nodes] = signal_graph.node_ids + self[:inputs] = signal_graph.sinks + self[:outputs] = signal_graph.sources + end + + # Find the shortest path between between two nodes and return a list of the + # nodes which this passes through and their connecting edges. + def route(source, sink) + source = source.to_sym + sink = sink.to_sym + + path = paths[sink] + + distance = path.distance_to[source] + raise "no route from #{source} to #{sink}" if distance.infinite? + + logger.debug do + "found route connecting #{source} to #{sink} in #{distance} hops" + end + + nodes = [] + edges = [] + node = source + until node.nil? + nodes.unshift node + prev = path.predecessor[node] + edges << signal_graph[prev].edges[node] unless prev.nil? + node = prev + end + + logger.debug { edges.map(&:to_s).join ' then ' } + + [nodes, edges] + end + + # Convert a signal map of the structure + # + # source => [dest] + # + # to a nested hash of the structure + # + # source => { dest => [edges] } + # + def build_edge_map(signal_map, atomic: false) + nodes_in_use = Set.new + edge_map = {} + + signal_map.each_pair do |source, sinks| + source = source.to_sym + sinks = Array(sinks).map(&:to_sym) + + source_nodes = Set.new + edge_map[source] = {} + + sinks.each do |sink| + begin + nodes, edges = route source, sink + + if nodes_in_use.intersect? Set[nodes] + partial_map = edge_map.transform_values(&:keys) + route = "route from #{source} to #{sink}" + raise "#{route} conflicts with routes in #{partial_map}" + end + + source_nodes |= nodes + edge_map[source][sink] = edges + rescue => e + # note `route` may also throw an exception (e.g. when there + # is an invalid source / sink or unroutable path) + raise if atomic + logger.error e.message + end + end + + nodes_in_use |= source_nodes + end + + edge_map + end + + # Given a set of edges, activate them all and return a promise that will + # resolve following the completion of all device interactions. + # + # The returned promise contains the original edges, partitioned into + # success and failure sets. + def activate_all(edges, atomic: false, force: false) + success = Set.new + failed = Set.new + + # Filter out any edges we can skip over + skippable = edges.reject { |e| needs_activation? e, force: force } + success |= skippable + edges -= skippable + + # Remove anything that we know will fail up front + unroutable = edges.reject { |e| can_activate? e } + failed |= unroutable + edges -= unroutable + + raise 'can not perform all routes' if atomic && unroutable.any? + + interactions = edges.map { |e| activate e } + + thread.finally(interactions).then do |results| + edges.zip(results).each do |edge, (result, resolved)| + if resolved + success << edge + else + logger.warn "failed to switch #{edge}: #{result}" + failed << edge + end + end + [success, failed] + end + end + + def needs_activation?(edge, force: false) + mod = system[edge.device] + + fail_with = proc do |reason| + logger.info "module for #{edge.device} #{reason} - skipping #{edge}" + return false + end + + single_source = signal_graph.outdegree(edge.source) == 1 + + fail_with['does not exist, but appears to be an alias'] \ + if mod.nil? && single_source + + fail_with['already on correct input'] \ + if edge.nx1? && mod && mod[:input] == edge.input && !force + + fail_with['has an incompatible api, but only a single input defined'] \ + if edge.nx1? && !mod.respond_to?(:switch_to) && single_source + + true + end + + def can_activate?(edge) + mod = system[edge.device] + + fail_with = proc do |reason| + logger.warn "mod #{edge.device} #{reason} - can not switch #{edge}" + return false + end + + fail_with['does not exist'] if mod.nil? + + fail_with['offline'] if mod[:connected] == false + + fail_with['has an incompatible api (missing #switch_to)'] \ + if edge.nx1? && !mod.respond_to?(:switch_to) + + fail_with['has an incompatible api (missing #switch)'] \ + if edge.nxn? && !mod.respond_to?(:switch) + + true + end + + def activate(edge) + mod = system[edge.device] + + if edge.nx1? + mod.switch_to edge.input + elsif edge.nxn? + mod.switch edge.input => edge.output + else + raise 'unexpected edge type' + end + end +end + +# Graph data structure for respresentating abstract signal networks. +# +# All signal sinks and sources are represented as nodes, with directed edges +# holding connectivity information needed to execute device level interaction +# to 'activate' the edge. +# +# Directivity of the graph is inverted from the signal flow - edges use signal +# sinks as source and signal sources as their terminus. This optimises for +# cheap removal of signal sinks and better path finding (as most environments +# will have a small number of displays and a large number of sources). +class Aca::Router::SignalGraph + Paths = Struct.new :distance_to, :predecessor + + class Edge + attr_reader :source, :target, :device, :input, :output + + Meta = Struct.new(:device, :input, :output) + + def initialize(source, target, &blk) + @source = source.to_sym + @target = target.to_sym + + meta = Meta.new.tap(&blk) + normalise_io = lambda do |x| + if x.is_a? String + x[/^\d+$/]&.to_i || x.to_sym + else + x + end + end + @device = meta.device&.to_sym + @input = normalise_io[meta.input] + @output = normalise_io[meta.output] + end + + def to_s + "#{target} to #{device} (in #{input})" + end + + # Check if the edge is a switchable input on a single output device + def nx1? + output.nil? + end + + # Check if the edge a matrix switcher / multi-output device + def nxn? + !nx1? + end + end + + class Node + attr_reader :id, :edges + + def initialize(id) + @id = id.to_sym + @edges = Hash.new do |_, other_id| + raise ArgumentError, "No edge from \"#{@id}\" to \"#{other_id}\"" + end + end + + def join(other_id, datum) + edges[other_id.to_sym] = datum + self + end + + def to_s + id.to_s + end + + def eql?(other) + id == other + end + + def hash + id.hash + end + end + + include Enumerable + + attr_reader :nodes + + def initialize + @nodes = Hash.new do |_, id| + raise ArgumentError, "\"#{id}\" does not exist" + end + end + + def [](id) + id = id.to_sym + nodes[id] + end + + def insert(id) + id = id.to_sym + nodes[id] = Node.new id unless nodes.include? id + self + end + + alias << insert + + # If there is *certainty* the node has no incoming edges (i.e. it was a temp + # node used during graph construction), `check_incoming_edges` can be set + # to false to keep this O(1) rather than O(n). Using this flag at any other + # time will result a corrupt structure. + def delete(id, check_incoming_edges: true) + id = id.to_sym + nodes.delete(id) { raise ArgumentError, "\"#{id}\" does not exist" } + each { |node| node.edges.delete id } if check_incoming_edges + self + end + + def join(source, target, &block) + source = source.to_sym + target = target.to_sym + datum = Edge.new(source, target, &block) + nodes[source].join target, datum + self + end + + def each(&block) + nodes.values.each(&block) + end + + def include?(id) + id = id.to_sym + nodes.key? id + end + + def node_ids + map(&:id) + end + + def successors(id) + id = id.to_sym + nodes[id].edges.keys + end + + def sources + node_ids.select { |id| indegree(id).zero? } + end + + def sinks + node_ids.select { |id| outdegree(id).zero? } + end + + def incoming_edges(id) + id = id.to_sym + each_with_object([]) do |node, edges| + edges << node.edges[id] if node.edges.key? id + end + end + + def outgoing_edges(id) + id = id.to_sym + nodes[id].edges.values + end + + def indegree(id) + incoming_edges(id).size + end + + def outdegree(id) + id = id.to_sym + nodes[id].edges.size + end + + def dijkstra(id) + id = id.to_sym + + active = Containers::PriorityQueue.new { |x, y| (x <=> y) == -1 } + distance_to = Hash.new { 1.0 / 0.0 } + predecessor = {} + + distance_to[id] = 0 + active.push id, distance_to[id] + + until active.empty? + u = active.pop + successors(u).each do |v| + alt = distance_to[u] + 1 + next unless alt < distance_to[v] + distance_to[v] = alt + predecessor[v] = u + active.push v, distance_to[v] + end + end + + Paths.new distance_to, predecessor + end + + def inspect + object_identifier = "#{self.class.name}:0x#{format('%02x', object_id)}" + nodes = map(&:inspect).join ', ' + "#<#{object_identifier} @nodes={ #{nodes} }>" + end + + def to_s + "{ #{to_a.join ', '} }" + end + + # Pre-parse a connection map into a normalised nested hash structure + # suitable for parsing into the graph. + # + # This assumes the input map has been parsed from JSON so takes care of + # mapping keys back to integers (where suitable) and expanding sources + # specified as an array into a nested Hash. The target normalised output is + # + # { device: { input: source } } + # + def self.normalise(map) + map.transform_values do |inputs| + case inputs + when Array + (1..inputs.size).zip(inputs).to_h + when Hash + inputs.transform_keys do |key| + key.to_s[/^\d+$/]&.to_i || key.to_sym + end + else + raise ArgumentError, 'inputs must be a Hash or Array' + end + end + end + + # Extract module references from a connection map. + # + # This is a destructive operation that will tranform outputs specified as + # `device as output` to simply `output` and return a Hash of the structure + # `{ output: device }`. + def self.extract_mods!(map) + mods = HashWithIndifferentAccess.new + + map.transform_keys! do |key| + mod, node = key.to_s.split(' as ') + node ||= mod + mods[node] = mod + node.to_sym + end + + mods + end + + # Build a signal map from a nested hash of input connectivity. The input + # map should be of the structure + # + # { device: { input_name: source } } + # or + # { device: [source] } + # + # When inputs are specified as an array, 1-based indices will be used. + # + # Sources that refer to the output of a matrix switcher are defined as + # "device__output" (using two underscores to seperate the output + # name/number and device). + # + # For example, a map containing two displays and 2 laptop inputs, all + # connected via 2x2 matrix switcher would be: + # { + # Display_1: { + # hdmi: :Switcher_1__1 + # }, + # Display_2: { + # hdmi: :Switcher_1__2 + # }, + # Switcher_1: [:Laptop_1, :Laptop_2], + # } + # + # Device keys should relate to module id's for control. These may also be + # aliased by defining them as as "mod as device". This can be used to + # provide better readability (e.g. "Display_1 as Left_LCD") or to segment + # them so that only specific routes are allowed. This approach enables + # devices such as centralised matrix switchers split into multiple virtual + # switchers that only have access to a subset of the inputs. + def self.from_map(map) + graph = new + + matrix_nodes = [] + + connections = normalise map + + mods = extract_mods! connections + + connections.each_pair do |device, inputs| + # Create the node for the signal sink + graph << device + + inputs.each_pair do |input, source| + # Create a node and edge to each input source + graph << source + graph.join(device, source) do |edge| + edge.device = mods[device] + edge.input = input + end + + # Check is the input is a matrix switcher or multi-output + # device (such as a USB switch). + upstream_device, output = source.to_s.split '__' + next if output.nil? + + upstream_device = upstream_device.to_sym + matrix_nodes |= [upstream_device] + + # Push in nodes and edges to each matrix input + matrix_inputs = connections[upstream_device] + matrix_inputs.each_pair do |matrix_input, upstream_source| + graph << upstream_source + graph.join(source, upstream_source) do |edge| + edge.device = mods[upstream_device] + edge.input = matrix_input + edge.output = output + end + end + end + end + + # Remove any temp 'matrix device nodes' as we now how fully connected + # nodes for each input and output. + matrix_nodes.each { |id| graph.delete id, check_incoming_edges: false } + + graph + end +end diff --git a/modules/aca/router_spec.rb b/modules/aca/router_spec.rb new file mode 100644 index 00000000..43cba2c4 --- /dev/null +++ b/modules/aca/router_spec.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +require 'json' + +Orchestrator::Testing.mock_device 'Aca::Router' do + def section(message) + puts "\n\n#{'-' * 80}" + puts message + puts "\n" + end + + SignalGraph = Aca::Router::SignalGraph + + # ------------------------------------------------------------------------- + section 'Internal graph structure' + + graph = SignalGraph.new + + # Node insertion + graph << :test_node + expect(graph).to include(:test_node) + + # Node access + expect(graph[:test_node]).to be_a(SignalGraph::Node) + expect { graph[:does_not_exist] }.to raise_error(ArgumentError) + + # Node deletion + graph.delete :test_node + expect(graph).not_to include(:test_node) + + # Edge creation + graph << :display + graph << :laptop + graph.join(:display, :laptop) do |edge| + edge.device = :Display_1 + edge.input = :hdmi + end + expect(graph.successors(:display)).to include(:laptop) + + # Graph structural inspection + # note: signal flow is inverted from graph directivity + expect(graph.indegree(:display)).to be(0) + expect(graph.indegree(:laptop)).to be(1) + expect(graph.outdegree(:display)).to be(1) + expect(graph.outdegree(:laptop)).to be(0) + expect(graph.sources).to include(:display) + expect(graph.sinks).to include(:laptop) + + # Edge inspection + edge = graph[:display].edges[:laptop] + expect(edge.device).to be(:Display_1) + expect(edge.input).to be(:hdmi) + expect(edge).to be_nx1 + expect(edge).not_to be_nxn + + + # ------------------------------------------------------------------------- + section 'Parse from signal map' + + signal_map = JSON.parse <<-JSON + { + "Display_1 as Left_LCD": { + "hdmi": "Switcher_1__1", + "hdmi2": "SubSwitchA__1", + "hdmi3": "Receiver_1" + }, + "Display_2 as Right_LCD": { + "hdmi": "Switcher_1__2", + "hdmi2": "SubSwitchB__2", + "display_port": "g" + }, + "Switcher_1": ["a", "b"], + "Switcher_2 as SubSwitchA": { + "1": "c", + "2": "d" + }, + "Switcher_2 as SubSwitchB": { + "3": "e", + "4": "f" + }, + "Receiver_1": { + "hdbaset": "Transmitter_1" + }, + "Transmitter_1": { + "hdmi": "h" + } + } + JSON + + normalised_map = SignalGraph.normalise(signal_map) + expect(normalised_map).to eq( + 'Display_1 as Left_LCD' => { + hdmi: 'Switcher_1__1', + hdmi2: 'SubSwitchA__1', + hdmi3: 'Receiver_1' + }, + 'Display_2 as Right_LCD' => { + hdmi: 'Switcher_1__2', + hdmi2: 'SubSwitchB__2', + display_port: 'g' + }, + 'Switcher_1' => { + 1 => 'a', + 2 => 'b' + }, + 'Switcher_2 as SubSwitchA' => { + 1 => 'c', + 2 => 'd' + }, + 'Switcher_2 as SubSwitchB' => { + 3 => 'e', + 4 => 'f' + }, + 'Receiver_1' => { + hdbaset: 'Transmitter_1' + }, + 'Transmitter_1' => { + hdmi: 'h' + } + ) + + mods = SignalGraph.extract_mods!(normalised_map) + expect(mods).to eq( + 'Left_LCD' => 'Display_1', + 'Right_LCD' => 'Display_2', + 'Switcher_1' => 'Switcher_1', + 'SubSwitchA' => 'Switcher_2', + 'SubSwitchB' => 'Switcher_2', + 'Receiver_1' => 'Receiver_1', + 'Transmitter_1' => 'Transmitter_1' + ) + expect(normalised_map).to eq( + Left_LCD: { + hdmi: 'Switcher_1__1', + hdmi2: 'SubSwitchA__1', + hdmi3: 'Receiver_1' + }, + Right_LCD: { + hdmi: 'Switcher_1__2', + hdmi2: 'SubSwitchB__2', + display_port: 'g' + }, + Switcher_1: { + 1 => 'a', + 2 => 'b' + }, + SubSwitchA: { + 1 => 'c', + 2 => 'd' + }, + SubSwitchB: { + 3 => 'e', + 4 => 'f' + }, + Receiver_1: { + hdbaset: 'Transmitter_1' + }, + Transmitter_1: { + hdmi: 'h' + } + ) + + graph = SignalGraph.from_map(signal_map) + + expect(graph.sources).to contain_exactly(:Left_LCD, :Right_LCD) + + expect(graph.sinks).to contain_exactly(*(:a..:h).to_a) + + routes = graph.sources.map { |id| [id, graph.dijkstra(id)] }.to_h + expect(routes[:Left_LCD].distance_to[:a]).to be(2) + expect(routes[:Left_LCD].distance_to[:c]).to be(2) + expect(routes[:Left_LCD].distance_to[:e]).to be_infinite + expect(routes[:Left_LCD].distance_to[:g]).to be_infinite + expect(routes[:Right_LCD].distance_to[:g]).to be(1) + expect(routes[:Right_LCD].distance_to[:a]).to be(2) + expect(routes[:Right_LCD].distance_to[:g]).to be(1) + expect(routes[:Right_LCD].distance_to[:c]).to be_infinite + + + # ------------------------------------------------------------------------- + + exec(:load_from_map, signal_map) + + # ------------------------------------------------------------------------- + section 'Routing' + + exec(:route, :a, :Left_LCD) + nodes, edges = result + expect(nodes).to contain_exactly(:a, :Switcher_1__1, :Left_LCD) + expect(edges.first).to be_nxn + expect(edges.first.device).to be(:Switcher_1) + expect(edges.first.input).to be(1) + expect(edges.first.output).to be(1) + expect(edges.second).to be_nx1 + expect(edges.second.device).to be(:Display_1) + expect(edges.second.input).to be(:hdmi) + + exec(:route, :c, :Left_LCD) + nodes, = result + expect(nodes).to contain_exactly(:c, :SubSwitchA__1, :Left_LCD) + + expect { exec(:route, :e, :Left_LCD) }.to \ + raise_error('no route from e to Left_LCD') + + # ------------------------------------------------------------------------- + section 'Edge maps' + + exec(:build_edge_map, a: :Left_LCD, b: :Right_LCD) + edge_map = result + expect(edge_map.keys).to contain_exactly(:a, :b) + expect(edge_map[:a]).to be_a(Hash) + expect(edge_map[:a][:Left_LCD]).to be_a(Array) + + + # ------------------------------------------------------------------------- + section 'Graph queries' + + exec(:input_for, :a) + expect(result).to be(1) + + exec(:input_for, :a, on: :Left_LCD) + expect(result).to be(:hdmi) + + exec(:device_for, :g) + expect(result).to be(:Display_2) + + exec(:devices_between, :c, :Left_LCD) + expect(result).to contain_exactly(:Switcher_2, :Display_1) + + exec(:upstream_devices_of, :Left_LCD, on_input: :hdmi3) + expect(result).to contain_exactly(:Receiver_1, :Transmitter_1, :h) + + exec(:upstream_devices_of, :Left_LCD) + expect(result).to be_empty +end diff --git a/modules/aca/slack.rb b/modules/aca/slack.rb index 02cf9209..4a889dab 100644 --- a/modules/aca/slack.rb +++ b/modules/aca/slack.rb @@ -54,10 +54,10 @@ def send_message(message_text) if thread_id # Post to the slack channel using the thread ID - message = @client.web_client.chat_postMessage channel: setting(:channel), text: message_text, username: "#{current_user.name} (#{current_user.email})", thread_ts: thread_id + message = @client.web_client.chat_postMessage channel: setting(:channel), text: message_text, username: current_user.email, thread_ts: thread_id else - message = @client.web_client.chat_postMessage channel: setting(:channel), text: message_text, username: "#{current_user.name} (#{current_user.email})" + message = @client.web_client.chat_postMessage channel: setting(:channel), text: message_text, username: current_user.email # logger.debug "Message from frontend:" # logger.debug message.to_json # Store thread id @@ -66,6 +66,8 @@ def send_message(message_text) User.bucket.set("slack-user-#{thread_id}", user.id) on_message(message.message) end + user.last_message_sent = Time.now.to_i * 1000 + user.save! end def get_historic_messages @@ -85,11 +87,15 @@ def get_historic_messages messages = JSON.parse(response.body)['messages'] { + last_sent: user.last_message_sent, + last_read: user.last_message_read, thread_id: thread_id, messages: messages } else { + last_sent: user.last_message_sent, + last_read: user.last_message_read, thread_id: nil, messages: [] } diff --git a/modules/aca/slack_concierge.rb b/modules/aca/slack_concierge.rb index 2c6e285d..21a7c80d 100644 --- a/modules/aca/slack_concierge.rb +++ b/modules/aca/slack_concierge.rb @@ -11,6 +11,12 @@ class Aca::SlackConcierge generic_name :Slack implements :logic + def log(msg) + logger.info msg + STDERR.puts msg + STDERR.flush + end + def on_load on_update end @@ -32,19 +38,34 @@ def send_message(message_text, thread_id) message = @client.web_client.chat_postMessage channel: setting(:channel), text: message_text, thread_ts: thread_id, username: 'Concierge' end + def update_last_message_read(email) + authority_id = Authority.find_by_domain('uat-book.internationaltowers.com').id + user = User.find_by_email(authority_id, email) + user.last_message_read = Time.now.to_i * 1000 + user.save! + end + def get_threads messages = @client.web_client.channels_history({channel: setting(:channel), oldest: (Time.now - 12.months).to_i, count: 1000})['messages'] messages.delete_if{ |message| !((!message.key?('thread_ts') || message['thread_ts'] == message['ts']) && message['subtype'] == 'bot_message') } logger.debug "Processing messages in get_threads" - messages.each_with_index{|message, i| - if message['username'].include?('(') - messages[i]['name'] = message['username'].split(' (')[0] if message.key?('username') - messages[i]['email'] = message['username'].split(' (')[1][0..-2] if message.key?('username') + messages.each_with_index{|message, i| + if message.key?('username') + authority_id = Authority.find_by_domain('uat-book.internationaltowers.com').id + user = User.find_by_email(authority_id, messages[i]['email']) + messages[i]['email'] = message['username'] + messages[i]['name'] = user.name + end + if !user.nil? + messages[i]['last_sent'] = user.last_message_sent + messages[i]['last_read'] = user.last_message_read else - messages[i]['name'] = message['username'] + messages[i]['last_sent'] = nil + messages[i]['last_read'] = nil end + # update_last_message_read(messages[i]['email']) messages[i]['replies'] = get_message(message['ts']) } logger.debug "Finished processing messages in get_threads" @@ -70,6 +91,12 @@ def get_thread(thread_id) return nil end + def update_read_time(thread_id) + user = User.find(User.bucket.get("slack-user-#{thread_id}", quiet: true)) + user.last_message_read = Time.now.to_i * 1000 + user.save! + end + protected # Create a realtime WS connection to the Slack servers @@ -110,20 +137,31 @@ def create_websocket if data.key?('subtype') && data['subtype'] == 'message_replied' next end + user_email = nil # # This is not a reply if data.key?('thread_ts') + # if data['username'].include?('(') + # user_email = data['username'].split(' (')[1][0..-2] if data.key?('username') + # end get_thread(data['ts']) get_thread(data['thread_ts']) else - logger.info "Adding thread too binding" + logger.info "Adding thread to binding" if data['username'].include?('(') data['name'] = data['username'].split(' (')[0] if data.key?('username') data['email'] = data['username'].split(' (')[1][0..-2] if data.key?('username') + # user_email = data['email'] else data['name'] = data['username'] end messages = self["threads"].dup.unshift(data) self["threads"] = messages + # if user_email + # authority_id = Authority.find_by_domain('uat-book.internationaltowers.com').id + # user = User.find_by_email(authority_id, user_email) + # user.last_message_read = Time.now.to_i * 1000 + # user.save! + # end logger.debug "Getting threads! " get_threads end diff --git a/modules/aca/tracking/desk_management.rb b/modules/aca/tracking/desk_management.rb index 0fba63ba..75960ab1 100644 --- a/modules/aca/tracking/desk_management.rb +++ b/modules/aca/tracking/desk_management.rb @@ -10,6 +10,8 @@ module Aca::Tracking; end class Aca::Tracking::DeskManagement include ::Orchestrator::Constants + + descriptive_name 'ACA Desk Management' generic_name :DeskManagement implements :logic diff --git a/modules/aca/tracking/locate_user.rb b/modules/aca/tracking/locate_user.rb index 71b3d050..d3f5332d 100644 --- a/modules/aca/tracking/locate_user.rb +++ b/modules/aca/tracking/locate_user.rb @@ -288,7 +288,9 @@ def perform_lookup(ip, login, domain, hostname, ttl) # We search the wireless networks in case snooping is enabled on the # port that the wireless controller is connected to if @meraki_enabled + logger.debug { "Wireless check for IP #{ip}" } resp = @scanner.get(path: "/meraki/#{ip}").value + logger.debug { "Wireless check for got #{resp.status.inspect}" } if resp.status == 200 details = JSON.parse(resp.body, symbolize_names: true) diff --git a/modules/aca/tracking/people_counter.rb b/modules/aca/tracking/people_counter.rb new file mode 100644 index 00000000..210bc810 --- /dev/null +++ b/modules/aca/tracking/people_counter.rb @@ -0,0 +1,189 @@ +require 'aca/tracking/people_count' +module Enumerable + def each_with_previous + self.inject(nil){|prev, curr| yield prev, curr; curr} + self + end +end + +module Aca; end +module Aca::Tracking; end + +class Aca::Tracking::PeopleCounter + include ::Orchestrator::Constants + include ::Orchestrator::StateBinder + descriptive_name 'ACA People Count' + generic_name :Count + implements :logic + + bind :VidConf, :people_count, to: :count_changed + + bind :Bookings, :today, to: :booking_changed + + durations = [] + total_duration = 0 + events = [] + + def booking_changed(details) + return if details.nil? + self[:todays_bookings] = details + logger.info "Got new bookings, clearing schedule" + schedule.clear + details.each do |meeting| + if Time.parse(meeting[:End]) > Time.now + logger.info "Calculating average at #{meeting[:End]}" + schedule.at(meeting[:End]) { + calculate_average(meeting) + } + end + end + + end + + def get_current_booking(details) + start_time = Time.now.to_i + # For every meeting + current = nil + details.each do |meeting| + # Grab the start and end + meeting_start = Time.at(meeting[:start_epoch]).to_i + meeting_end = Time.at(meeting[:end_epoch]).to_i + + # If it's past the start time and before the end time + if start_time >= meeting_start && start_time < meeting_end + current = meeting + end + end + current + end + + def count_changed(new_count) + new_count = 0 if new_count == -1 + return if self[:todays_bookings].nil? || self[:todays_bookings].empty? + # Check the current meeting + current = get_current_booking(self[:todays_bookings]) + return if current.nil? + + logger.info "Count changed: #{new_count} and ID: #{current[:id]}" + + # Add the change to the dataset for that meeting + current_dataset = Aca::Tracking::PeopleCount.find_by_id("count-#{current[:id]}") + if current_dataset.nil? + current_dataset = create_dataset(new_count, current) + logger.info "Created dataset with ID: #{current_dataset.id}" + logger.info "Created dataset with counts: #{current_dataset.counts}" + end + + # Check if the new count is max + current_dataset.maximum = new_count if new_count > current_dataset.maximum + + # Update the dataset with the new count + current_dataset.counts_will_change! + current_dataset.counts.push([Time.now.to_i, new_count]) + + # Save it back + current_dataset.save! + end + + def create_dataset(count, booking) + logger.info "Creating a dataset" + dataset = Aca::Tracking::PeopleCount.new + + # # Dataset attrs + # attribute :room_email, type: String + # attribute :booking_id, type: String + # attribute :system_id, type: String + # attribute :capacity, type: Integer + # attribute :maximum, type: Integer + # attribute :average, type: Integer + # attribute :median, type: Integer + # attribute :organiser, type: String + + dataset.room_email = system.email + dataset.system_id = system.id + dataset.capacity = system.capacity + dataset.maximum = count + dataset.average = count + dataset.median = count + dataset.booking_id = booking[:id] + dataset.organiser = booking[:owner] + return dataset if dataset.save! + end + + def calculate_average(meeting) + logger.info "Calculating average for: #{meeting[:id]}" + + # Set up our holding vars + durations = [] + total_duration = 0 + + # Get the dataset + dataset = ::Aca::Tracking::PeopleCount.find_by_id("count-#{meeting[:id]}") + + events = dataset.counts.dup + + # Calculate array of weighted durations + events.each_with_previous do |prev, curr| + if prev + time = curr[0] + count = curr[1] + prev_time = prev[0] + prev_count = prev[1] + durations[prev_count] ||= 0 + durations[prev_count] += (time - prev_time) + total_duration += (time - prev_time) + end + end + + # Remove nils + durations = durations.each_with_index.map {|x,y| [x,y] }.delete_if { |x| x[0].nil? } + + # Generate weighted average + running_total = 0 + average = nil + durations.each {|reading| + duration = reading[0] + count = reading[1] + running_total += duration + if running_total / total_duration > 0.5 + average = reading[1] + break + end + } + + dataset.average = average + dataset.save! + + return average + end + + + def on_load + on_update + end + + def on_update + self[:name] = system.name + self[:views] = 0 + self[:state] = 'Idle' + self[:todays_bookings] = [] + schedule.clear + logger.info "Starting booking update in 30s" + schedule.in('10s') { + logger.info "Grabbing bookings to update" + self[:todays_bookings] = system[:Bookings][:today] + booking_changed(self[:todays_bookings]) + } + end + + def update_state + if self[:state] == 'Stopped' + state('Idle') + end + self[:views] += rand(7) + end + + def state(status) + self[:state] = status + end +end diff --git a/modules/atlona/omni_stream/example_websocket_resp.txt b/modules/atlona/omni_stream/example_websocket_resp.txt new file mode 100644 index 00000000..0e46762f --- /dev/null +++ b/modules/atlona/omni_stream/example_websocket_resp.txt @@ -0,0 +1,1104 @@ + +Encoder Inputs Response: +======================== + +{ + "config": [ + { + "audio": { + "active": false, + "bitdepth": 0, + "channelcount": 0, + "codingtype": "Unknown", + "samplingfrequency": "unknown" + }, + "cabledetect": false, + "edid": "Default", + "hdcp": { + "encrypted": false, + "support_version": "1.4" + }, + "name": "hdmi_input1", + "video": { + "active": false, + "colordepth": 0, + "colorspace": "YUV", + "framerate": 0, + "interlaced": false, + "resolution": { + "height": 0, + "width": 0 + }, + "subsampling": "444" + } + }, + { + "audio": { + "active": false, + "bitdepth": 0, + "channelcount": 0, + "codingtype": "Unknown", + "samplingfrequency": "unknown" + }, + "cabledetect": false, + "edid": "Default", + "hdcp": { + "encrypted": false, + "support_version": "1.4" + }, + "name": "hdmi_input2", + "video": { + "active": false, + "colordepth": 0, + "colorspace": "YUV", + "framerate": 0, + "interlaced": false, + "resolution": { + "height": 0, + "width": 0 + }, + "subsampling": "444" + } + } + ], + "error": false, + "id": "hdmi_input" +} + + + + +Encoder Sessions Response: +========================== + +{ + "config": [ + { + "audio": { + "encoder": "hdmi_input1", + "stream": { + "destination_address": "239.32.0.2", + "destination_port": 1100, + "dscp": 0, + "enabled": true, + "fec": { + "columns": 4, + "enabled": false, + "rows": 4 + }, + "rtcp": { + "enabled": false + }, + "ttl": 255 + } + }, + "aux": { + "bidirectional": { + "enabled": false, + "listen_port": 1204 + }, + "encoder": "", + "stream": { + "destination_address": "", + "destination_port": 1200, + "dscp": 0, + "enabled": false, + "rtcp": { + "enabled": false + }, + "ttl": 255 + } + }, + "encodergroup": { + "enabled": false, + "is_active": false, + "members": [ + ], + "name": "session1", + "trigger": "manual" + }, + "interface": "eth1", + "name": "session1", + "sap": { + "description": "", + "enabled": false, + "frequency": 10, + "name": "session1", + "originator": "-" + }, + "scrambling": { + "enabled": false, + "key": "" + }, + "video": { + "encoder": "vc2_encoder1", + "stream": { + "destination_address": "239.32.0.1", + "destination_port": 1000, + "dscp": 0, + "enabled": true, + "fec": { + "columns": 15, + "enabled": false, + "rows": 15 + }, + "rtcp": { + "enabled": false + }, + "ttl": 255 + } + } + }, + { + "audio": { + "encoder": "hdmi_input2", + "stream": { + "destination_address": "", + "destination_port": 1100, + "dscp": 0, + "enabled": true, + "fec": { + "columns": 4, + "enabled": false, + "rows": 4 + }, + "rtcp": { + "enabled": false + }, + "ttl": 255 + } + }, + "aux": { + "bidirectional": { + "enabled": false, + "listen_port": 1204 + }, + "encoder": "", + "stream": { + "destination_address": "", + "destination_port": 1200, + "dscp": 0, + "enabled": false, + "rtcp": { + "enabled": false + }, + "ttl": 255 + } + }, + "encodergroup": { + "enabled": false, + "is_active": false, + "members": [ + ], + "name": "session2", + "trigger": "manual" + }, + "interface": "eth2", + "name": "session2", + "sap": { + "description": "", + "enabled": false, + "frequency": 10, + "name": "session2", + "originator": "-" + }, + "scrambling": { + "enabled": false, + "key": "" + }, + "video": { + "encoder": "vc2_encoder2", + "stream": { + "destination_address": "239.255.0.102", + "destination_port": 1000, + "dscp": 0, + "enabled": true, + "fec": { + "columns": 15, + "enabled": false, + "rows": 15 + }, + "rtcp": { + "enabled": false + }, + "ttl": 255 + } + } + }, + { + "audio": { + "encoder": "", + "stream": { + "destination_address": "", + "destination_port": 1120, + "dscp": 0, + "enabled": false, + "fec": { + "columns": 4, + "enabled": false, + "rows": 4 + }, + "rtcp": { + "enabled": false + }, + "ttl": 255 + } + }, + "aux": { + "bidirectional": { + "enabled": false, + "listen_port": 1204 + }, + "encoder": "", + "stream": { + "destination_address": "", + "destination_port": 1220, + "dscp": 0, + "enabled": false, + "rtcp": { + "enabled": false + }, + "ttl": 255 + } + }, + "encodergroup": { + "enabled": false, + "is_active": false, + "members": [ + ], + "name": "session3", + "trigger": "manual" + }, + "interface": "", + "name": "session3", + "sap": { + "description": "", + "enabled": false, + "frequency": 10, + "name": "session3", + "originator": "-" + }, + "scrambling": { + "enabled": false, + "key": "" + }, + "video": { + "encoder": "", + "stream": { + "destination_address": "", + "destination_port": 1020, + "dscp": 0, + "enabled": false, + "fec": { + "columns": 15, + "enabled": false, + "rows": 15 + }, + "rtcp": { + "enabled": false + }, + "ttl": 255 + } + } + }, + { + "audio": { + "encoder": "", + "stream": { + "destination_address": "", + "destination_port": 1120, + "dscp": 0, + "enabled": false, + "fec": { + "columns": 4, + "enabled": false, + "rows": 4 + }, + "rtcp": { + "enabled": false + }, + "ttl": 255 + } + }, + "aux": { + "bidirectional": { + "enabled": false, + "listen_port": 1204 + }, + "encoder": "", + "stream": { + "destination_address": "", + "destination_port": 1220, + "dscp": 0, + "enabled": false, + "rtcp": { + "enabled": false + }, + "ttl": 255 + } + }, + "encodergroup": { + "enabled": false, + "is_active": false, + "members": [ + ], + "name": "session4", + "trigger": "manual" + }, + "interface": "", + "name": "session4", + "sap": { + "description": "", + "enabled": false, + "frequency": 10, + "name": "session4", + "originator": "-" + }, + "scrambling": { + "enabled": false, + "key": "" + }, + "video": { + "encoder": "", + "stream": { + "destination_address": "", + "destination_port": 1020, + "dscp": 0, + "enabled": false, + "fec": { + "columns": 15, + "enabled": false, + "rows": 15 + }, + "rtcp": { + "enabled": false + }, + "ttl": 255 + } + } + } + ], + "error": false, + "id": "sessions" +} + + + +Decoder ip_input response: +========================== + +{ + "config": [ + { + "enabled": true, + "interface": "eth1", + "multicast": { + "address": "239.32.0.1", + "filter": { + "addresses": [ + ], + "mode": "exclude" + } + }, + "name": "ip_input1", + "port": 1000, + "status": { + "packets": 652216070 + } + }, + { + "enabled": true, + "interface": "eth1", + "multicast": { + "address": "239.32.0.2", + "filter": { + "addresses": [ + ], + "mode": "exclude" + } + }, + "name": "ip_input2", + "port": 1100, + "status": { + "packets": 0 + } + }, + { + "enabled": true, + "interface": "eth1", + "multicast": { + "address": "226.0.10.11", + "filter": { + "addresses": [ + ], + "mode": "exclude" + } + }, + "name": "ip_input3", + "port": 1100, + "status": { + "packets": 0 + } + }, + { + "enabled": false, + "interface": "eth1", + "multicast": { + "address": "226.0.10.2", + "filter": { + "addresses": [ + ], + "mode": "exclude" + } + }, + "name": "ip_input4", + "port": 1100, + "status": { + "packets": 0 + } + }, + { + "enabled": false, + "interface": "eth1", + "multicast": { + "address": "", + "filter": { + "addresses": [ + ], + "mode": "exclude" + } + }, + "name": "ip_input5", + "port": 1200, + "status": { + "packets": 0 + } + } + ], + "error": false, + "id": "ip_input" +} + + + +Decoder hdmi_output response: +============================= + +{ + "config": [ + { + "audio": { + "analog": { + "input": { + "enable": false, + "status": false + }, + "output": { + "enable": false, + "status": false + } + }, + "backup": { + "active_input": "ip_input2", + "change_grace_period": 0, + "input": "", + "mode": "Off" + }, + "input": "ip_input2", + "mute": false, + "status": { + "active": false, + "bitdepth": 0, + "channelcount": 0, + "codingtype": "LPCM", + "samplingfrequency": "44.1kHz" + }, + "volume": 15 + }, + "aux": { + "input": "" + }, + "name": "hdmi_output1", + "output": { + "cabledetect": true, + "edid": { + "manufactured": "Year 2017, Week 21", + "modes": [ + "720x400 @ 70Hz", + "640x480 @ 60Hz", + "800x600 @ 60Hz", + "1024x768 @ 60Hz", + "1280x1024 @ 75Hz", + "1280x720 @ 60Hz", + "1280x800 @ 60Hz", + "1280x960 @ 60Hz", + "1280x1024 @ 60Hz", + "1400x1050 @ 60Hz", + "1440x900 @ 60Hz", + "1600x1200 @ 60Hz", + "1680x1050 @ 60Hz", + "1920x1080 @ 60Hz" + ], + "preferred_mode": "1920x1080 @ 60Hz", + "product": "V423", + "raw": "00ffffffffffff0038a3ac6801010101151b0103805d3478eaef4ba5554d9c270e474aa1090081c081008140818090409500a940b300023a801871382d40582c4500a20b3200001e000000fd0030551c5c11000a202020202020000000fc00563432330a2020202020202020000000ff0037353035303931364e420a20200165020320f14d900504130312141f2021220716230907078301000065030c0010000e1f008051001e3040803700a20b3200001c662150b051001b3040703600a20b3200001e662156aa51001e30468f3300a20b3200001e011d8018711c1620582c2500a20b3200009e011d80d0721c1620102c2580a20b3200009e000000000049", + "serial": "75050916NB", + "vendor": "NEC" + }, + "hdcp": { + "encrypted": false, + "support_version": "1.4" + } + }, + "scrambling": { + "enabled": true, + "key": "scrambling" + }, + "standby": { + "auto_on": true, + "projector_cooldown": 0, + "timeout": 0, + "type": "DispSW AVon" + }, + "video": { + "backup": { + "active_input": "ip_input1", + "change_grace_period": 0, + "input": "", + "mode": "Off" + }, + "generator": { + "format": { + "active": true, + "colordepth": 12, + "colorspace": "RGB", + "framerate": 60, + "interlaced": false, + "resolution": { + "height": 1200, + "width": 1920 + }, + "subsampling": "444" + }, + "slate": { + "logo": "", + "mode": "off" + } + }, + "input": "ip_input1", + "output": { + "aspect_ratio": "keep aspect ratio", + "frame_rate_conversion": { + "mode": "sub frame latency" + }, + "resolution": "auto", + "wall": { + "edge_compensation": { + "bottom": 0, + "left": 0, + "mode": "none", + "right": 0, + "top": 0 + }, + "enabled": false, + "input_selection": { + "height": 1080, + "width": 1920, + "x": 0, + "y": 0 + }, + "physical_size": { + "height": 0, + "width": 0 + }, + "rotation": 0, + "unit": "pixels" + } + }, + "status": { + "active": false, + "colordepth": 8, + "colorspace": "RGB", + "framerate": 0, + "interlaced": false, + "resolution": { + "height": 0, + "width": 0 + }, + "subsampling": "444" + } + } + } + ], + "error": false, + "id": "hdmi_output" +} + + +Alarm Warning: +============== + +{ + "config": [ + { + "active": false, + "description": "Selected output video mode is not supported", + "id": 10, + "name": "Unable to scale video on hdmi_output1", + "timestamp": "2017-10-25T08:29:59.000UTC" + }, + { + "active": false, + "description": "Selected output video mode is not supported", + "id": 10, + "name": "Unable to scale video on hdmi_output1", + "timestamp": "2017-10-25T08:28:49.000UTC" + } + ], + "error": false +} + + + +Network Query: +============== + +{ + "config": [ + { + "carrier": true, + "dhcpmode": "dhcp", + "enabled": true, + "gateway": "10.155.209.1", + "ipaddress": "10.155.209.241", + "linkspeed": 1000, + "macaddress": "B8:98:B0:01:A1:DF", + "name": "eth1", + "nvt": { + "authentication": true, + "port": 2323 + }, + "subnetmask": "255.255.255.0", + "telnet": { + "authentication": true, + "port": 23 + } + } + ], + "error": false, + "id": "net" +} + + + +Decoder Configure Input Request: +================================ + +Configure Input 1 + +{ + "id": "ip_input", + "username": "admin", + "password": "Atlona", + "config_set": { + "name": "ip_input", + "config": [ + { + "enabled": true, + "interface": "eth1", + "multicast": { + "address": "239.32.0.1", + "filter": { + "addresses": [], + "mode": "exclude" + }, + "tempAddress": "" + }, + "name": "ip_input1", + "port": 1000, + "status": { + "packets": 652216070 + }, + "number": 1, + "$$hashKey": "object:14" + } + ] + } +} + +Configure Input 2 + +{ + "id": "ip_input", + "username": "admin", + "password": "Atlona", + "config_set": { + "name": "ip_input", + "config": [ + { + "enabled": true, + "interface": "eth1", + "multicast": { + "address": "239.32.0.2", + "filter": { + "addresses": [], + "mode": "exclude" + }, + "tempAddress": "" + }, + "name": "ip_input2", + "port": 1100, + "status": { + "packets": 0 + }, + "number": 2, + "$$hashKey": "object:15", + "error": null + } + ] + } +} + + + +Decoder Configure Output Response: +================================== + +Output1 to IP Input 2 + +{ + "id": "hdmi_output", + "username": "admin", + "password": "Atlona", + "config_set": { + "name": "hdmi_output", + "config": [ + { + "audio": { + "analog": { + "input": { + "enable": false, + "status": false + }, + "output": { + "enable": false, + "status": false + } + }, + "backup": { + "active_input": "ip_input2", + "change_grace_period": 0, + "input": "", + "mode": "Off" + }, + "input": "ip_input2", + "mute": false, + "status": { + "active": false, + "bitdepth": 0, + "channelcount": 0, + "codingtype": "LPCM", + "samplingfrequency": "44.1kHz" + }, + "volume": 15 + }, + "aux": { + "input": "" + }, + "name": "hdmi_output1", + "output": { + "cabledetect": true, + "edid": { + "manufactured": "Year 2017, Week 21", + "modes": [ + "720x400 @ 70Hz", + "640x480 @ 60Hz", + "800x600 @ 60Hz", + "1024x768 @ 60Hz", + "1280x1024 @ 75Hz", + "1280x720 @ 60Hz", + "1280x800 @ 60Hz", + "1280x960 @ 60Hz", + "1280x1024 @ 60Hz", + "1400x1050 @ 60Hz", + "1440x900 @ 60Hz", + "1600x1200 @ 60Hz", + "1680x1050 @ 60Hz", + "1920x1080 @ 60Hz" + ], + "preferred_mode": "1920x1080 @ 60Hz", + "product": "V423", + "raw": "00ffffffffffff0038a3ac6801010101151b0103805d3478eaef4ba5554d9c270e474aa1090081c081008140818090409500a940b300023a801871382d40582c4500a20b3200001e000000fd0030551c5c11000a202020202020000000fc00563432330a2020202020202020000000ff0037353035303931364e420a20200165020320f14d900504130312141f2021220716230907078301000065030c0010000e1f008051001e3040803700a20b3200001c662150b051001b3040703600a20b3200001e662156aa51001e30468f3300a20b3200001e011d8018711c1620582c2500a20b3200009e011d80d0721c1620102c2580a20b3200009e000000000049", + "serial": "75050916NB", + "vendor": "NEC" + }, + "hdcp": { + "encrypted": false, + "support_version": "1.4" + } + }, + "scrambling": { + "enabled": true, + "key": "scrambling" + }, + "standby": { + "auto_on": true, + "projector_cooldown": 0, + "timeout": 0, + "type": "DispSW AVon" + }, + "video": { + "backup": { + "active_input": "ip_input1", + "change_grace_period": 0, + "input": "", + "mode": "Off" + }, + "generator": { + "format": { + "active": true, + "colordepth": 12, + "colorspace": "RGB", + "framerate": 60, + "interlaced": false, + "resolution": { + "height": 1200, + "width": 1920 + }, + "subsampling": "444" + }, + "slate": { + "logo": "", + "mode": "off" + } + }, + "input": "ip_input2", + "output": { + "aspect_ratio": "keep aspect ratio", + "frame_rate_conversion": { + "mode": "sub frame latency" + }, + "resolution": "auto", + "wall": { + "edge_compensation": { + "bottom": 0, + "left": 0, + "mode": "none", + "right": 0, + "top": 0 + }, + "enabled": false, + "input_selection": { + "height": 1080, + "width": 1920, + "x": 0, + "y": 0 + }, + "physical_size": { + "height": 0, + "width": 0 + }, + "rotation": 0, + "unit": "pixels" + } + }, + "status": { + "active": false, + "colordepth": 8, + "colorspace": "RGB", + "framerate": 0, + "interlaced": false, + "resolution": { + "height": 0, + "width": 0 + }, + "subsampling": "444" + } + }, + "number": 1, + "videoStatus": "No active video", + "audioStatus": "No active audio", + "hdcp": { + "encrypted": false, + "support_version": "1.4" + }, + "hdcpSupportVersion": true, + "$$hashKey": "object:399" + } + ] + } +} + + +Output1 to IP Input 1 + +{ + "id": "hdmi_output", + "username": "admin", + "password": "Atlona", + "config_set": { + "name": "hdmi_output", + "config": [ + { + "audio": { + "analog": { + "input": { + "enable": false, + "status": false + }, + "output": { + "enable": false, + "status": false + } + }, + "backup": { + "active_input": "ip_input2", + "change_grace_period": 0, + "input": "", + "mode": "Off" + }, + "input": "ip_input2", + "mute": false, + "status": { + "active": false, + "bitdepth": 0, + "channelcount": 0, + "codingtype": "LPCM", + "samplingfrequency": "44.1kHz" + }, + "volume": 15 + }, + "aux": { + "input": "" + }, + "name": "hdmi_output1", + "output": { + "cabledetect": true, + "edid": { + "manufactured": "Year 2017, Week 21", + "modes": [ + "720x400 @ 70Hz", + "640x480 @ 60Hz", + "800x600 @ 60Hz", + "1024x768 @ 60Hz", + "1280x1024 @ 75Hz", + "1280x720 @ 60Hz", + "1280x800 @ 60Hz", + "1280x960 @ 60Hz", + "1280x1024 @ 60Hz", + "1400x1050 @ 60Hz", + "1440x900 @ 60Hz", + "1600x1200 @ 60Hz", + "1680x1050 @ 60Hz", + "1920x1080 @ 60Hz" + ], + "preferred_mode": "1920x1080 @ 60Hz", + "product": "V423", + "raw": "00ffffffffffff0038a3ac6801010101151b0103805d3478eaef4ba5554d9c270e474aa1090081c081008140818090409500a940b300023a801871382d40582c4500a20b3200001e000000fd0030551c5c11000a202020202020000000fc00563432330a2020202020202020000000ff0037353035303931364e420a20200165020320f14d900504130312141f2021220716230907078301000065030c0010000e1f008051001e3040803700a20b3200001c662150b051001b3040703600a20b3200001e662156aa51001e30468f3300a20b3200001e011d8018711c1620582c2500a20b3200009e011d80d0721c1620102c2580a20b3200009e000000000049", + "serial": "75050916NB", + "vendor": "NEC" + }, + "hdcp": { + "encrypted": false, + "support_version": "1.4" + } + }, + "scrambling": { + "enabled": true, + "key": "scrambling" + }, + "standby": { + "auto_on": true, + "projector_cooldown": 0, + "timeout": 0, + "type": "DispSW AVon" + }, + "video": { + "backup": { + "active_input": "ip_input2", + "change_grace_period": 0, + "input": "", + "mode": "Off" + }, + "generator": { + "format": { + "active": true, + "colordepth": 12, + "colorspace": "RGB", + "framerate": 60, + "interlaced": false, + "resolution": { + "height": 1200, + "width": 1920 + }, + "subsampling": "444" + }, + "slate": { + "logo": "", + "mode": "off" + } + }, + "input": "ip_input1", + "output": { + "aspect_ratio": "keep aspect ratio", + "frame_rate_conversion": { + "mode": "sub frame latency" + }, + "resolution": "auto", + "wall": { + "edge_compensation": { + "bottom": 0, + "left": 0, + "mode": "none", + "right": 0, + "top": 0 + }, + "enabled": false, + "input_selection": { + "height": 1080, + "width": 1920, + "x": 0, + "y": 0 + }, + "physical_size": { + "height": 0, + "width": 0 + }, + "rotation": 0, + "unit": "pixels" + } + }, + "status": { + "active": false, + "colordepth": 8, + "colorspace": "RGB", + "framerate": 0, + "interlaced": false, + "resolution": { + "height": 0, + "width": 0 + }, + "subsampling": "444" + } + }, + "number": 1, + "videoStatus": "No active video", + "audioStatus": "No active audio", + "hdcp": { + "encrypted": false, + "support_version": "1.4" + }, + "hdcpSupportVersion": true, + "$$hashKey": "object:399", + "error": null + } + ] + } +} + + +Valid Inputs: + +* "" == not used +* "ip_input1", "ip_input2", "ip_input3" etc + diff --git a/modules/atlona/omni_stream/virtual_switcher.rb b/modules/atlona/omni_stream/virtual_switcher.rb new file mode 100644 index 00000000..4da9e559 --- /dev/null +++ b/modules/atlona/omni_stream/virtual_switcher.rb @@ -0,0 +1,209 @@ +# encoding: ASCII-8BIT +# frozen_string_literal: true + +module Atlona; end +module Atlona::OmniStream; end + +class Atlona::OmniStream::VirtualSwitcher + include ::Orchestrator::Constants + include ::Orchestrator::Transcoder + + descriptive_name 'Atlona Omnistream Switcher' + generic_name :Switcher + implements :logic + + def on_load + on_update + end + + def on_update + @routes ||= {} + @encoder_name = setting(:encoder_name) || :Encoder + @decoder_name = setting(:decoder_name) || :Decoder + end + + def switch(map, switch_video: true, switch_audio: true, enable_override: nil) + inputs = get_encoders + outputs = get_decoders + + map.each do |inp, outs| + begin + inp = inp.to_s + if inp == '0' + enable = enable_override.nil? ? false : enable_override + + # mute the audio and video + if switch_video + video_ip = "" + video_port = 1200 # just needs to be a valid port number + end + if switch_audio + audio_ip = "" + audio_port = 1200 + end + else + # disable video if there is no audio or video input + enable = enable_override.nil? ? true : enable_override + + input, session = inputs[inp] + if input.nil? + logger.warn "input not found switching #{inp} => #{outs}" + next + end + + session = input[:sessions][session] + + if switch_video + video = session[:video][:stream] + video_ip = video[:destination_address] + video_port = video[:destination_port] + end + unless video_ip.present? && video_port.present? + video_ip = nil + video_port = nil + end + + if switch_audio + audio = session[:audio][:stream] + audio_ip = audio[:destination_address] + audio_port = audio[:destination_port] + end + unless audio_ip.present? && audio_port.present? + audio_ip = nil + audio_port = nil + end + end + + Array(outs).each do |out| + @routes[out] = inp + output, index = outputs[out.to_s] + + if output.nil? + logger.warn "output #{out} not found switching #{inp} => #{outs}" + next + end + + output.switch(output: index, video_ip: video_ip, video_port: video_port, audio_ip: audio_ip, audio_port: audio_port, enable: enable) + end + rescue => e + logger.print_error(e, "switching #{inp} => #{outs}") + end + end + + self[:routes] = @routes.dup + true + end + + def switch_video(map) + switch(map, switch_audio: false) + end + + def switch_audio(map) + switch(map, switch_video: false) + end + + def mute_video(outputs, state = false) + state = is_affirmative?(state) + switch_video({'0' => outputs}, enable_override: state) + end + + def unmute_video(outputs) + mute_video(outputs, true) + end + + def mute_audio(outputs, mute = true) + outs = get_decoders + outputs.each do |out| + decoder, index = outs[out.to_s] + next if decoder.nil? + + decoder.mute(mute, output: index) + end + end + + def unmute_audio(outputs) + mute_audio(outputs, false) + end + + def get_mappings + inputs = get_encoders + outputs = get_decoders + { + inputs: inputs, + outputs: outputs + } + end + + protected + + # Enumerate the devices that make up this virtual switcher + def get_encoders + index = 1 + input = 1 + encoder_mapping = {} + info_mapping = {} + + system.all(@encoder_name).each do |mod| + # skip any offline devices + if mod.nil? + index += 1 + next + end + + num_sessions = mod[:num_sessions] + if mod[:type] == :encoder && num_sessions + (1..num_sessions).each do |num| + encoder_mapping[input.to_s] = [mod, num - 1] + info_mapping[input.to_s] = { + encoder: "#{@encoder_name}_#{index}", + session: num + } + + input += 1 + end + else + logger.warn "#{@encoder_name}_#{index} is not an encoder or offline" + end + + index += 1 + end + + self[:input_mappings] = info_mapping + encoder_mapping + end + + def get_decoders + index = 1 + output = 1 + decoder_mapping = {} + info_mapping = {} + + system.all(@decoder_name).each do |mod| + # skip any offline devices + if mod.nil? + index += 1 + next + end + + num_outputs = mod[:num_outputs] + if mod[:type] == :decoder && num_outputs + (1..num_outputs).each do |num| + decoder_mapping[output.to_s] = [mod, num] + info_mapping[output.to_s] = { + encoder: "#{@decoder_name}_#{index}", + output: num + } + + output += 1 + end + else + logger.warn "#{@decoder_name}_#{index} is not an decoder or offline" + end + + index += 1 + end + + self[:output_mappings] = info_mapping + decoder_mapping + end +end diff --git a/modules/atlona/omni_stream/ws_protocol.rb b/modules/atlona/omni_stream/ws_protocol.rb new file mode 100644 index 00000000..2fdc93a4 --- /dev/null +++ b/modules/atlona/omni_stream/ws_protocol.rb @@ -0,0 +1,343 @@ +# encoding: ASCII-8BIT +# frozen_string_literal: true + +require 'protocols/websocket' + +module Atlona; end +module Atlona::OmniStream; end + +class Atlona::OmniStream::WsProtocol + include ::Orchestrator::Constants + include ::Orchestrator::Transcoder + include ::Orchestrator::Security + + # supports encoders and decoders + descriptive_name 'Atlona Omnistream WS Protocol' + # Probably a good idea to differentiate them for support purposes + generic_name :Decoder + + tcp_port 80 + wait_response false + + def on_load + on_update + end + + # Called after dependency reload and settings updates + def on_update + @type = self[:type] = setting(:type) if @type.nil? + + @username = setting(:username) || 'admin' + @password = setting(:password) || 'Atlona' + + @video_in_default = setting(:video_in_default) || 1 + @audio_in_default = setting(:audio_in_default) || 2 + + # We'll pair auth with a query to send a command + @auth = { + username: @username, + password: @password + }.to_json[0..-2] + + # The output this module is interested in + @num_outputs = (setting(:num_outputs) || 1).to_i + end + + def connected + new_websocket_client + end + + def disconnected + # clear the keepalive ping + schedule.clear + end + + # Encoder + # hdmi_input => are the sources connected + # sessions => 1,2,3,4 + # => audio + video streams and addresses + ports + # + # ip_input => 1,2,3,4,5 + # => hdmi_output -- select an input + output + Query = {} + [ + # common + :systeminfo, :alarms, :net, + + # encoders + :hdmi_input, :sessions, + + # decoders + :ip_input, :hdmi_output + ].each do |cmd| + # Cache the query strings + # Remove the leading '{' character + Query[cmd] = { + id: cmd, + config_get: cmd + }.to_json[1..-1] + + # generate the query functions + define_method cmd do + data = "#{@auth},#{Query[cmd]}" + logger.debug { "requesting: #{data}" } + @ws.text(data) + end + end + + def audio_mute(value = true, output: 1) + raise 'not supported on encoders' unless @type == :decoder + + output -= 1 + id = 399 + output + + val = self[:outputs][output] + val[:audio][:mute] = true + val["$$hashKey"] = "object:#{id}" + + @ws.text({ + id: "hdmi_output", + username: @username, + password: @password, + config_set: { + name: "hdmi_output", + config: [val] + } + }.to_json) + + val + end + + def audio_unmute(output: 1) + mute(false, output: output) + end + + def mute(value = true, output: 1) + raise 'not supported on encoders' unless @type == :decoder + switch(enable: !value, output: output, video_ip: '', video_port: 1, audio_ip: '', audio_port: 1) + end + + def unmute + mute(false) + end + + def switch(output: 1, video_ip: nil, video_port: nil, audio_ip: nil, audio_port: nil, enable: true) + raise 'not supported on encoders' unless @type == :decoder + + out = output - 1 + val = self[:outputs][out] + + raise "unknown output #{output}" unless val + + # Select the inputs to configure + inputs = self[:ip_inputs] + audio_inp = val[:audio][:input] + video_inp = val[:video][:input] + + # An empty string means no stream is selected and we don't want to switch + audio_ip = nil unless audio_inp.present? + video_ip = nil unless video_inp.present? + + base_id = 14 + request = { + id: "ip_input", + username: @username, + password: @password, + config_set: { + name: "ip_input" + } + } + + # Grab the details of the ip_input that should be updated + if video_ip && video_port + number = video_inp[-1].to_i + id = base_id + number - 1 + + inp = inputs[video_inp] + inp["$$hashKey"] = "object:#{id}" + inp[:number] = number + if video_ip.empty? + inp[:enabled] = enable + else + inp[:enabled] = enable + inp[:multicast][:address] = video_ip + inp[:multicast][:tempAddress] = '' + inp[:port] = video_port + end + + request[:config_set][:config] = [inp] + logger.debug { "switch video:\n#{request}" } + @ws.text request.to_json + end + + if audio_ip && audio_port + number = audio_inp[-1].to_i + id = base_id + number - 1 + + inp = inputs[audio_inp] + inp["$$hashKey"] = "object:#{id}" + inp[:number] = number + if audio_ip.empty? + inp[:enabled] = enable + else + inp[:enabled] = enable + inp[:multicast][:address] = audio_ip + inp[:multicast][:tempAddress] = '' + inp[:port] = audio_port + end + + request[:config_set][:config] = [inp] + logger.debug { "switch audio:\n#{request}" } + @ws.text request.to_json + end + + raise 'no video or audio stream config provided' unless request[:config_set][:config] + true + end + + def select_input(output: 1, video_input: 1, audio_input: 2) + raise 'not supported on encoders' unless @type == :decoder + + output -= 1 + id = 399 + output + val = self[:outputs][output] + val["$$hashKey"] = "object:#{id}" + + if audio_input + if audio_input == 0 + val[:audio][:input] = "" + else + val[:audio][:input] = "ip_input#{audio_input}" + end + end + + if video_input + if video_input == 0 + val[:video][:input] = "" + else + val[:video][:input] = "ip_input#{video_input}" + end + end + + @ws.text({ + id: "hdmi_output", + username: @username, + password: @password, + config_set: { + name: "hdmi_output", + config: [val] + } + }.to_json) + + val + end + + protected + + def new_websocket_client + # NOTE:: you must use wss:// when using port 443 (TLS connection) + protocol = secure_transport? ? 'wss' : 'ws' + @ws = Protocols::Websocket.new(self, "#{protocol}://#{remote_address}/wsapp/") + @ws.start + end + + def received(data, resolve, command) + @ws.parse(data) + :success + rescue => e + logger.print_error(e, 'parsing websocket data') + disconnect + :abort + end + + # ==================== + # Websocket callbacks: + # ==================== + + # websocket ready + def on_open + logger.debug { "Websocket connected" } + + schedule.every('30s', :immediately) do + systeminfo + alarms + net + end + end + + def on_message(raw_string) + logger.debug { "received: #{raw_string}" } + resp = JSON.parse(raw_string, symbolize_names: true) + + # Warn if there was an error + if resp[:error] + logger.warn raw_string + return + end + + data = resp[:config] + + # if config was successfully updated then query the status + if data.nil? + id = resp[:id].to_sym + self.__send__(id) if self.respond_to?(id) + return + end + + # get + case resp[:id] + when 'systeminfo' + # type == :decoder or :encoder + @type = self[:type] = data[:type].downcase.to_sym + + self[:temperature] = data[:temperature] + self[:model] = data[:model] + self[:firmware] = data[:firmwareversion] + self[:uptime] = data[:uptime] + + if @type == :decoder + ip_input + hdmi_output + else + hdmi_input + sessions + end + + when 'net' + self[:mac_address] = data[0][:macaddress] + when 'alarms' + self[:alarms] = data + when 'hdmi_input' + self[:inputs] = data + when 'sessions' + sessions = [] + num_sessions = 0 + data.each do |sess| + next unless sess[:audio][:stream][:enabled] || sess[:video][:stream][:enabled] + sessions << sess + num_sessions += 1 + end + self[:sessions] = sessions + self[:num_sessions] = num_sessions + when 'ip_input' + ins = {} + data.each do |input| + ins[input[:name]] = input + end + self[:ip_inputs] = ins + when 'hdmi_output' + self[:outputs] = data + self[:num_outputs] = data.length + end + end + + # connection is closing + def on_close(event) + logger.debug { "Websocket closing... #{event.code} #{event.reason}" } + end + + # connection is closing + def on_error(error) + logger.warn "Websocket error: #{error.message}" + end +end diff --git a/modules/biamp/tesira.rb b/modules/biamp/tesira.rb index e7b62c69..ef9985a7 100755 --- a/modules/biamp/tesira.rb +++ b/modules/biamp/tesira.rb @@ -33,6 +33,7 @@ class Biamp::Tesira def on_load # Implement the Telnet protocol + defaults timeout: 15000 new_telnet_client config before_buffering: proc { |data| @telnet.buffer data @@ -77,7 +78,8 @@ def preset(number_or_name) do_send build(:DEVICE, :recallPresetByName, number_or_name) end end - + alias_method :trigger, :preset + def start_audio do_send "DEVICE startAudio" end diff --git a/modules/cisco/collaboration_endpoint/external_source.rb b/modules/cisco/collaboration_endpoint/external_source.rb new file mode 100644 index 00000000..1870708f --- /dev/null +++ b/modules/cisco/collaboration_endpoint/external_source.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +load File.join(__dir__, 'xapi', 'mapper.rb') + +module Cisco; end +module Cisco::CollaborationEndpoint; end + +module Cisco::CollaborationEndpoint::ExternalSource + include ::Cisco::CollaborationEndpoint::Xapi::Mapper + + module Hooks + def connected + super + register_feedback \ + '/Event/UserInterface/Presentation/ExternalSource' do |action| + source = action.dig 'Selected', 'SourceIdentifier' + unless source.nil? + self[:external_source] = source + signal_status(:external_source) + end + end + end + end + + def self.included(base) + base.prepend Hooks + end + + # TODO: protect methods (via ::Orchestrator::Security) that manipulate + # sources. Currently mapper does not support this from within a module. + command 'UserInterface Presentation ExternalSource Add' => :add_source, + SourceIdentifier: String, + ConnectorId: (1..7), + Name: String, + Type: [:pc, :camera, :desktop, :document_camera, :mediaplayer, + :other, :whiteboard] + + command 'UserInterface Presentation ExternalSource Remove' => :remove_source, + SourceIdentifier: String + + command 'UserInterface Presentation ExternalSource RemoveAll' => :clear_sources + + command 'UserInterface Presentation ExternalSource Select' => :select_source, + SourceIdentifier: String + + command 'UserInterface Presentation ExternalSource State Set' => :source_state, + SourceIdentifier: String, + State: [:Error, :Hidden, :NotReady, :Ready], + ErrorReason_: String + + command 'UserInterface Presentation ExternalSource List' => :list_sources +end diff --git a/modules/cisco/collaboration_endpoint/room_kit.rb b/modules/cisco/collaboration_endpoint/room_kit.rb new file mode 100644 index 00000000..ac322243 --- /dev/null +++ b/modules/cisco/collaboration_endpoint/room_kit.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +load File.join(__dir__, 'room_os.rb') +load File.join(__dir__, 'ui_extensions.rb') +load File.join(__dir__, 'external_source.rb') + +class Cisco::CollaborationEndpoint::RoomKit < Cisco::CollaborationEndpoint::RoomOs + include ::Cisco::CollaborationEndpoint::Xapi::Mapper + include ::Cisco::CollaborationEndpoint::UiExtensions + include ::Cisco::CollaborationEndpoint::ExternalSource + + descriptive_name 'Cisco Room Kit' + description <<~DESC + Control of Cisco RoomKit devices. + + API access requires a local user with the 'integrator' role to be + created on the codec. + DESC + + tokenize delimiter: Tokens::COMMAND_RESPONSE, + wait_ready: Tokens::LOGIN_COMPLETE + clear_queue_on_disconnect! + + def connected + super + + register_feedback '/Event/PresentationPreviewStarted' do + self[:local_presentation] = true + end + register_feedback '/Event/PresentationPreviewStopped' do + self[:local_presentation] = false + end + + self[:calls] = {} + self[:in_call] = false + register_feedback '/Status/Call' do |call| + calls = self[:calls].deep_merge call + calls.reject! do |_, props| + props[:status] == :Idle || props.include?(:ghost) + end + self[:calls] = calls + self[:in_call] = calls.present? + end + end + + status 'Audio Microphones Mute' => :mic_mute + status 'Audio Volume' => :volume + status 'Cameras SpeakerTrack' => :speaker_track + status 'RoomAnalytics PeoplePresence' => :presence_detected + status 'RoomAnalytics PeopleCount Current' => :people_count + status 'Conference DoNotDisturb' => :do_not_disturb + status 'Conference Presentation Mode' => :presentation + status 'Peripherals ConnectedDevice' => :peripherals + status 'Video Selfview Mode' => :selfview + status 'Video Input' => :video_input + status 'Video Output' => :video_output + status 'Standby State' => :standby + + command 'Audio Microphones Mute' => :mic_mute_on + command 'Audio Microphones Unmute' => :mic_mute_off + command 'Audio Microphones ToggleMute' => :mic_mute_toggle + def mic_mute(state = On) + is_affirmative? state ? mic_mute_on : mic_mute_off + end + + command 'Audio Sound Play' => :play_sound, + Sound: [:Alert, :Bump, :Busy, :CallDisconnect, :CallInitiate, + :CallWaiting, :Dial, :KeyInput, :KeyInputDelete, :KeyTone, + :Nav, :NavBack, :Notification, :OK, :PresentationConnect, + :Ringing, :SignIn, :SpecialInfo, :TelephoneCall, + :VideoCall, :VolumeAdjust, :WakeUp], + Loop_: [:Off, :On] + command 'Audio Sound Stop' => :stop_sound + + command 'Audio Volume Set' => :volume, + Level: (0..100) + + command 'Bookings List' => :bookings, + Days_: (1..365), + DayOffset_: (0..365), + Limit_: Integer, + Offset_: Integer + + command 'Call Accept' => :call_accept, CallId_: Integer + command 'Call Reject' => :call_reject, CallId_: Integer + command 'Call Disconnect' => :hangup, CallId_: Integer + command 'Dial' => :dial, + Number: String, + Protocol_: [:H320, :H323, :Sip, :Spark], + CallRate_: (64..6000), + CallType_: [:Audio, :Video] + + command 'Camera Preset Activate' => :camera_preset, + PresetId: (1..35) + command 'Camera Preset Store' => :camera_store_preset, + CameraId: (1..1), + PresetId_: (1..35), # Optional - codec will auto-assign if omitted + Name_: String, + TakeSnapshot_: [true, false], + DefaultPosition_: [true, false] + + command 'Camera PositionReset' => :camera_position_reset, + CameraId: (1..1), + Axis_: [:All, :Focus, :PanTilt, :Zoom] + command 'Camera Ramp' => :camera_move, + CameraId: (1..1), + Pan_: [:Left, :Right, :Stop], + PanSpeed_: (1..15), + Tilt_: [:Down, :Up, :Stop], + TiltSpeed_: (1..15), + Zoom_: [:In, :Out, :Stop], + ZoomSpeed_: (1..15), + Focus_: [:Far, :Near, :Stop] + + command 'Video Input SetMainVideoSource' => :camera_select, + ConnectorId_: (1..3), # Source can either be specified as the + Layout_: [:Equal, :PIP], # physical connector... + SourceId_: (1..3) # ...or the logical source ID + + command 'Video Selfview Set' => :selfview, + Mode_: [:On, :Off], + FullScreenMode_: [:On, :Off], + PIPPosition_: [:CenterLeft, :CenterRight, :LowerLeft, :LowerRight, + :UpperCenter, :UpperLeft, :UpperRight], + OnMonitorRole_: [:First, :Second, :Third, :Fourth] + + command! 'Cameras AutoFocus Diagnostics Start' => \ + :autofocus_diagnostics_start, + CameraId: (1..1) + command! 'Cameras AutoFocus Diagnostics Stop' => \ + :autofocus_diagnostics_stop, + CameraId: (1..1) + + command! 'Cameras SpeakerTrack Diagnostics Start' => \ + :speaker_track_diagnostics_start + command! 'Cameras SpeakerTrack Diagnostics Stop' => \ + :speaker_track_diagnostics_stop + + command 'Cameras SpeakerTrack Activate' => :speaker_track_activate + command 'Cameras SpeakerTrack Deactivate' => :speaker_track_deactivate + def speaker_track(state = On) + if is_affirmative? state + speaker_track_activate + else + speaker_track_deactivate + end + end + + command 'Phonebook Search' => :phonebook_search, + SearchString: String, + PhonebookType_: [:Corporate, :Local], + Limit_: Integer, + Offset_: Integer + + command 'Presentation Start' => :presentation_start, + PresentationSource_: (1..2), + SendingMode_: [:LocalRemote, :LocalOnly], + ConnectorId_: (1..2), + Instance_: [:New, *(1..6)] + command 'Presentation Stop' => :presentation_stop, + Instance_: (1..6), + PresentationSource_: (1..2) + + # Provide compatabilty with the router module for activating presentation. + def switch_to(input) + if [0, nil, :none, 'none', :blank, 'blank'].include? input + presentation_stop + else + presentation_start presentation_source: input + end + end + + command 'Standby Deactivate' => :powerup + command 'Standby HalfWake' => :half_wake + command 'Standby Activate' => :standby + command 'Standby ResetTimer' => :reset_standby_timer, Delay: (1..480) + def power(state = false) + if is_affirmative? state + powerup + elsif is_negatory? state + standby + elsif state.to_s =~ /wake/i + half_wake + else + logger.error "Invalid power state: #{state}" + end + end + + command! 'SystemUnit Boot' => :reboot, Action_: [:Restart, :Shutdown] +end diff --git a/modules/cisco/collaboration_endpoint/room_os.rb b/modules/cisco/collaboration_endpoint/room_os.rb new file mode 100644 index 00000000..e87670d8 --- /dev/null +++ b/modules/cisco/collaboration_endpoint/room_os.rb @@ -0,0 +1,415 @@ +# frozen_string_literal: true + +require 'json' +require 'securerandom' + +Dir[File.join(__dir__, '{xapi,util}', '*.rb')].each { |lib| load lib } + +module Cisco; end +module Cisco::CollaborationEndpoint; end + +class Cisco::CollaborationEndpoint::RoomOs + include ::Orchestrator::Constants + include ::Orchestrator::Security + include ::Cisco::CollaborationEndpoint::Xapi + include ::Cisco::CollaborationEndpoint::Util + + implements :ssh + descriptive_name 'Cisco Collaboration Endpoint' + generic_name :VidConf + + description <<~DESC + Low level driver for any Cisco Room OS device. This may be used + if direct access is required to the device API, or a required feature + is not provided by the device specific implementation. + + Where possible use the implementation for room device in use + (i.e. SX80, Room Kit etc). + DESC + + tokenize delimiter: Tokens::COMMAND_RESPONSE, + wait_ready: Tokens::LOGIN_COMPLETE + clear_queue_on_disconnect! + + + # ------------------------------ + # Callbacks + + def on_load + load_settings + end + + def on_unload; end + + def on_update + load_settings + + # Force a reconnect and event resubscribe following module updates. + disconnect + end + + def connected + init_connection + + register_control_system.then do + schedule.every('30s') { heartbeat timeout: 35 } + end + + push_config + + sync_config + end + + def disconnected + clear_device_subscriptions + + schedule.clear + end + + # Handle all incoming data from the device. + # + # In addition to acting an the normal Orchestrator callback, on_receive + # procs also pipe through here for initial JSON decoding. See #do_send. + def received(data, deferrable, command) + logger.debug { "<- #{data}" } + + do_parse = proc { Response.parse data } + response = data.length > 1024 ? task(&do_parse).value : do_parse.call + + if block_given? + # Let any pending command response handlers have first pass... + yield(response).tap do |command_result| + # Otherwise support interleaved async events + unhandled = [:ignore, nil].include? command_result + device_subscriptions.notify response if unhandled + end + else + device_subscriptions.notify response + :ignore + end + rescue Response::ParserError => error + case data.strip + when 'OK' + :success + when 'Command not recognized.' + logger.error { "Command not recognized: `#{command[:data]}`" } + :abort + else + logger.warn { "Malformed device response: #{error}" } + :fail + end + end + + + # ------------------------------ + # Exec methods + + # Execute an xCommand on the device. + # + # @param command [String] the command to execute + # @param args [Hash] the command arguments + # @return [::Libuv::Q::Promise] resolves when the command completes + def xcommand(command, args = {}) + send_xcommand command, args + end + + # Push a configuration settings to the device. + # + # May be specified as either a deeply nested hash of settings, or a + # pre-concatenated path along with a subhash for drilling through deeper + # parts of the tree. + # + # @param path [Hash, String] the configuration or top level path + # @param settings [Hash] the configuration values to apply + # @param [::Libuv::Q::Promise] resolves when the commands complete + def xconfiguration(path, settings = nil) + if settings.nil? + send_xconfigurations path + else + send_xconfigurations path => settings + end + end + + # Trigger a status update for the specified path. + # + # @param path [String] the status path + # @param [::Libuv::Q::Promise] resolves with the status response as a Hash + def xstatus(path) + send_xstatus path + end + + def self.extended(child) + child.class_eval do + protect_method :xcommand, :xconfigruation, :xstatus + end + end + + + protected + + + # ------------------------------ + # xAPI interactions + + # Perform the actual command execution - this allows device implementations + # to protect access to #xcommand and still refer the gruntwork here. + # + # @param comand [String] the xAPI command to execute + # @param args [Hash] the command keyword args + # @return [::Libuv::Q::Promise] that will resolve when execution is complete + def send_xcommand(command, args = {}) + request = Action.xcommand command, args + + # Multi-arg commands (external source registration, UI interaction etc) + # all need to be properly queued and sent without be overriden. In + # these cases, leave the outgoing commands unnamed. + opts = {} + opts[:name] = command if args.empty? + opts[:name] = "#{command} #{args.keys.first}" if args.size == 1 + + do_send request, **opts do |response| + # The result keys are a little odd: they're a concatenation of the + # last two command elements and 'Result', unless the command + # failed in which case it's just 'Result'. + # For example: + # xCommand Video Input SetMainVideoSource ... + # becomes: + # InputSetMainVideoSourceResult + result_key = command.split(' ').last(2).join('') + 'Result' + command_result = response.dig :CommandResponse, result_key.to_sym + failure_result = response.dig :CommandResponse, :Result + + result = command_result || failure_result + + if result + if result[:status] == 'OK' + result + else + logger.error result[:Reason] + :abort + end + else + logger.warn 'Unexpected response format' + :abort + end + end + end + + # Apply a single configuration on the device. + # + # @param path [String] the configuration path + # @param setting [String] the configuration parameter + # @param value [#to_s] the configuration value + # @return [::Libuv::Q::Promise] + def send_xconfiguration(path, setting, value) + request = Action.xconfiguration path, setting, value + + do_send request, name: "#{path} #{setting}" do |response| + result = response.dig :CommandResponse, :Configuration + + if result&.[](:status) == 'Error' + logger.error "#{result[:Reason]} (#{result[:XPath]})" + :abort + else + :success + end + end + end + + # Apply a set of configurations. + # + # @param config [Hash] a deeply nested hash of the configurations to apply + # @return [::Libuv::Q::Promise] + def send_xconfigurations(config) + # Reduce the config to a strucure of { [path] => value } + flatten = lambda do |h, path = [], settings = {}| + return settings.merge!(path => h) unless h.is_a? Hash + h.each { |key, subtree| flatten[subtree, path + [key], settings] } + settings + end + config = flatten[config] + + # The API only allows a single setting to be applied with each request. + interactions = config.map do |(*path, setting), value| + send_xconfiguration path.join(' '), setting, value + end + + thread.all(*interactions).then { :success } + end + + # Query the device's current status. + # + # @param path [String] + # @yield [response] a pre-parsed response object for the status query + # @return [::Libuv::Q:Promise] + def send_xstatus(path) + request = Action.xstatus path + + defer = thread.defer + + interaction = do_send request do |response| + path_components = Action.tokenize path + status_response = response.dig :Status, *path_components.map(&:to_sym) + + if !status_response.nil? + defer.resolve status_response + :success + else + error = response.dig :CommandResponse, :Status + logger.error "#{error[:Reason]} (#{error[:XPath]})" + :abort + end + end + + interaction.catch { |e| defer.reject e } + + defer.promise + end + + + # ------------------------------ + # Event subscription + + # Subscribe to feedback from the device. + # + # @param path [String, Array] the xPath to subscribe to updates for + # @param update_handler [Proc] a callback to receive updates for the path + def register_feedback(path, &update_handler) + logger.debug { "Subscribing to device feedback for #{path}" } + + unless device_subscriptions.contains? path + request = Action.xfeedback :register, path + # Always returns an empty response, nothing special to handle + result = do_send request + end + + device_subscriptions.insert path, &update_handler + + result || :success + end + + def unregister_feedback(path) + logger.debug { "Unsubscribing feedback for #{path}" } + + device_subscriptions.remove path + + request = if path == '/' + Action.xfeedback :deregisterall + else + Action.xfeedback :deregister, path + end + + do_send request + end + + def clear_device_subscriptions + unregister_feedback '/' + end + + def device_subscriptions + @device_subscriptions ||= FeedbackTrie.new + end + + + # ------------------------------ + # Base comms + + def init_connection + send "Echo off\n", priority: 96 do |response| + :success if response.include? "\e[?1034h" + end + + send "xPreferences OutputMode JSON\n", wait: false + end + + # Execute raw command on the device. + # + # @param command [String] the raw command to execute + # @param options [Hash] options for the transport layer + # @yield [response] + # a pre-parsed response object for the command, if used this block + # should return the response result + # @return [::Libuv::Q::Promise] + def do_send(command, **options) + request_id = generate_request_uuid + + request = "#{command} | resultId=\"#{request_id}\"\n" + + logger.debug { "-> #{request}" } + + send request, **options do |response, defer, cmd| + received response, defer, cmd do |json| + if json[:ResultId] != request_id + :ignore + elsif block_given? + yield json + else + :success + end + end + end + end + + def generate_request_uuid + SecureRandom.uuid + end + + + # ------------------------------ + # Module status + + # Load a setting into a status variable of the same name. + def load_setting(name, default:, persist: false) + value = setting(name) + define_setting(name, default) if value.nil? && persist + self[name] = value || default + end + + def load_settings + load_setting :peripheral_id, default: SecureRandom.uuid, persist: true + end + + # Bind arbitary device feedback to a status variable. + def bind_feedback(path, status_key) + register_feedback path do |value| + value = self[status_key].deep_merge value \ + if self[status_key].is_a?(Hash) && value.is_a?(Hash) + self[status_key] = value + end + end + + # Bind device status to a module status variable. + def bind_status(path, status_key) + bind_feedback "/Status/#{path.tr ' ', '/'}", status_key + send_xstatus(path).then do |value| + self[status_key] = value + end + end + + def push_config + config = setting(:device_config) || {} + send_xconfigurations config + end + + def sync_config + bind_feedback '/Configuration', :configuration + send "xConfiguration *\n", wait: false + end + + + # ------------------------------ + # Connectivity management + + def register_control_system + send_xcommand 'Peripherals Connect', + ID: self[:peripheral_id], + Name: 'ACAEngine', + Type: :ControlSystem + end + + def heartbeat(timeout:) + send_xcommand 'Peripherals HeartBeat', + ID: self[:peripheral_id], + Timeout: timeout + end +end diff --git a/modules/cisco/collaboration_endpoint/room_os_spec.rb b/modules/cisco/collaboration_endpoint/room_os_spec.rb new file mode 100644 index 00000000..b6dd8b26 --- /dev/null +++ b/modules/cisco/collaboration_endpoint/room_os_spec.rb @@ -0,0 +1,568 @@ +require 'thread' + +Orchestrator::Testing.mock_device 'Cisco::CollaborationEndpoint::RoomOs', + settings: { + peripheral_id: 'MOCKED_ID', + device_config: { + Audio: { + DefaultVolume: 100 + } + } + } do + # Patch in some tracking of request UUID's so we can form and validate + # device comms. + @manager.instance.class_eval do + generate_uuid = instance_method(:generate_request_uuid) + + attr_accessor :__request_ids + + define_method(:generate_request_uuid) do + generate_uuid.bind(self).call.tap do |id| + @__request_ids ||= Queue.new + @__request_ids << id + end + end + end + + def request_ids + @manager.instance.__request_ids + end + + def id_peek + @last_id || request_ids.pop(true).tap { |id| @last_id = id } + end + + def id_pop + @last_id.tap { @last_id = nil } || request_ids.pop(true) + end + + def section(message) + puts "\n\n#{'-' * 80}" + puts message + puts "\n" + end + + # ------------------------------------------------------------------------- + section 'Connection setup' + + transmit <<~BANNER + Welcome to + Cisco Codec Release Spark Room OS 2017-10-31 192c369 + SW Release Date: 2017-10-31 + *r Login successful + + OK + + BANNER + + expect(status[:connected]).to be true + + should_send "Echo off\n" + responds "\e[?1034h\r\nOK\r\n" + + should_send "xPreferences OutputMode JSON\n" + + # ------------------------------------------------------------------------- + section 'System registration' + + should_send "xCommand Peripherals Connect ID: \"MOCKED_ID\" Name: \"ACAEngine\" Type: ControlSystem | resultId=\"#{id_peek}\"\n" + responds( + <<~JSON + { + "CommandResponse":{ + "PeripheralsConnectResult":{ + "status":"OK" + } + }, + "ResultId": \"#{id_pop}\" + } + JSON + ) + + # ------------------------------------------------------------------------- + section 'Config push' + + should_send "xConfiguration Audio DefaultVolume: 100 | resultId=\"#{id_peek}\"\n" + responds( + <<~JSON + { + "ResultId": \"#{id_pop}\" + } + JSON + ) + + # ------------------------------------------------------------------------- + section 'Initial state sync' + + should_send "xFeedback register /Configuration | resultId=\"#{id_peek}\"\n" + responds( + <<~JSON + { + "ResultId": \"#{id_pop}\" + } + JSON + ) + + should_send "xConfiguration *\n" + responds( + <<~JSON + { + "Configuration":{ + "Audio":{ + "DefaultVolume":{ + "valueSpaceRef":"/Valuespace/INT_0_100", + "Value":"50" + }, + "Input":{ + "Line":[ + { + "id":"1", + "VideoAssociation":{ + "MuteOnInactiveVideo":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "VideoInputSource":{ + "valueSpaceRef":"/Valuespace/TTPAR_PresentationSources_2", + "Value":"2" + } + } + } + ], + "Microphone":[ + { + "id":"1", + "EchoControl":{ + "Dereverberation":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"Off" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "NoiseReduction":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + "Level":{ + "valueSpaceRef":"/Valuespace/INT_0_24", + "Value":"14" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + { + "id":"2", + "EchoControl":{ + "Dereverberation":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"Off" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "NoiseReduction":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + "Level":{ + "valueSpaceRef":"/Valuespace/INT_0_24", + "Value":"14" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + } + ] + }, + "Microphones":{ + "Mute":{ + "Enabled":{ + "valueSpaceRef":"/Valuespace/TTPAR_MuteEnabled", + "Value":"True" + } + } + } + } + } + } + JSON + ) + expect(status[:configuration].dig(:audio, :input, :microphone, 1, :mode)).to be true + + # ------------------------------------------------------------------------- + section 'Base comms (protected methods - ignore the access warnings)' + + # Append a request id and handle generic response parsing + exec(:do_send, 'xCommand Standby Deactivate') + .should_send("xCommand Standby Deactivate | resultId=\"#{id_peek}\"\n") + .responds( + <<~JSON + { + "CommandResponse":{ + "StandbyDeactivateResult":{ + "status":"OK" + } + }, + "ResultId": \"#{id_pop}\" + } + JSON + ) + expect(result).to be :success + + # Handle invalid device commands + exec(:do_send, 'Not a real command') + .should_send("Not a real command | resultId=\"#{id_pop}\"\n") + .responds("Command not recognized.\r\n") + expect { result }.to raise_error(Orchestrator::Error::CommandFailure) + + # Handle async response data + exec(:do_send, 'xCommand Standby Deactivate') + .should_send("xCommand Standby Deactivate | resultId=\"#{id_peek}\"\n") + .responds( + <<~JSON + { + "RandomAsyncData": "Foo" + } + JSON + ) + .responds( + <<~JSON + { + "CommandResponse":{ + "StandbyDeactivateResult":{ + "status":"OK" + } + }, + "ResultId": \"#{id_pop}\" + } + JSON + ) + expect(result).to be :success + + # Device event subscription + exec(:register_feedback, '/Status/Audio/Microphones/Mute') + .should_send("xFeedback register /Status/Audio/Microphones/Mute | resultId=\"#{id_peek}\"\n") + .responds( + <<~JSON + { + "ResultId": \"#{id_pop}\" + } + JSON + ) + expect(result).to be :success + + # Bind module status to device state + exec(:bind_feedback, '/Status/Audio/Volume', :volume) + .should_send("xFeedback register /Status/Audio/Volume | resultId=\"#{id_peek}\"\n") + .responds( + <<~JSON + { + "ResultId": \"#{id_pop}\" + } + JSON + ) + .responds( + <<~JSON + { + "Status":{ + "Audio":{ + "Volume":{ + "Value":"50" + } + } + } + } + JSON + ) + expect(status[:volume]).to be 50 + + # Bind xStatus to module state + exec(:bind_status, 'Standby State', :standby_state) + .should_send("xFeedback register /Status/Standby/State | resultId=\"#{id_peek}\"\n") + .responds( + <<~JSON + { + "ResultId": \"#{id_pop}\" + } + JSON + ) + .should_send("xStatus Standby State | resultId=\"#{id_peek}\"\n") + .responds( + <<~JSON + { + "Status":{ + "Standby":{ + "State":{ + "Value":"HalfWake" + } + } + }, + "ResultId": \"#{id_pop}\" + } + JSON + ) + .responds( + <<~JSON + { + "Status":{ + "Standby":{ + "State":{ + "Value":"Standby" + } + } + } + } + JSON + ) + expect(status[:standby_state]).to be true + + # ------------------------------------------------------------------------- + section 'Commands' + + # Basic command + exec(:xcommand, 'Standby Deactivate') + .should_send("xCommand Standby Deactivate | resultId=\"#{id_peek}\"\n") + .responds( + <<~JSON + { + "CommandResponse":{ + "StandbyDeactivateResult":{ + "status":"OK" + } + }, + "ResultId": \"#{id_pop}\" + } + JSON + ) + expect(result).to be :success + + # Command with arguments + exec(:xcommand, 'Video Input SetMainVideoSource', ConnectorId: 1, Layout: :PIP) + .should_send("xCommand Video Input SetMainVideoSource ConnectorId: 1 Layout: PIP | resultId=\"#{id_peek}\"\n") + .responds( + <<~JSON + { + "CommandResponse":{ + "InputSetMainVideoSourceResult":{ + "status":"OK" + } + }, + "ResultId": \"#{id_pop}\" + } + JSON + ) + expect(result).to be :success + + # Return device argument errors + exec(:xcommand, 'Video Input SetMainVideoSource', ConnectorId: 1, SourceId: 1) + .should_send("xCommand Video Input SetMainVideoSource ConnectorId: 1 SourceId: 1 | resultId=\"#{id_peek}\"\n") + .responds( + <<~JSON + { + "CommandResponse":{ + "InputSetMainVideoSourceResult":{ + "status":"Error", + "Reason":{ + "Value":"Must supply either SourceId or ConnectorId (but not both.)" + } + } + }, + "ResultId": \"#{id_pop}\" + } + JSON + ) + expect { result }.to raise_error(Orchestrator::Error::CommandFailure) + + # Return error from invalid / inaccessable xCommands + exec(:xcommand, 'Not A Real Command') + .should_send("xCommand Not A Real Command | resultId=\"#{id_peek}\"\n") + .responds( + <<~JSON + { + "CommandResponse":{ + "Result":{ + "status":"Error", + "Reason":{ + "Value":"Unknown command" + } + }, + "XPath":{ + "Value":"/Not/A/Real/Command" + } + }, + "ResultId": \"#{id_pop}\" + } + JSON + ) + expect { result }.to raise_error(Orchestrator::Error::CommandFailure) + + + # ------------------------------------------------------------------------- + section 'Configuration' + + # Basic configuration + exec(:xconfiguration, 'Video Input Connector 1', InputSourceType: :Camera) + .should_send("xConfiguration Video Input Connector 1 InputSourceType: Camera | resultId=\"#{id_peek}\"\n") + .responds( + <<~JSON + { + "ResultId": \"#{id_pop}\" + } + JSON + ) + expect(result).to be :success + + # Multuple settings return a unit :success when all ok + exec(:xconfiguration, 'Video Input Connector 1', InputSourceType: :Camera, Name: 'Borris', Quality: :Motion) + .should_send("xConfiguration Video Input Connector 1 InputSourceType: Camera | resultId=\"#{id_peek}\"\n") + .responds( + <<~JSON + { + "ResultId": \"#{id_pop}\" + } + JSON + ) + .should_send("xConfiguration Video Input Connector 1 Name: \"Borris\" | resultId=\"#{id_peek}\"\n") + .responds( + <<~JSON + { + "ResultId": \"#{id_pop}\" + } + JSON + ) + .should_send("xConfiguration Video Input Connector 1 Quality: Motion | resultId=\"#{id_peek}\"\n") + .responds( + <<~JSON + { + "ResultId": \"#{id_pop}\" + } + JSON + ) + expect(result).to be :success + + # Multuple settings with failure with return a promise that rejects + exec(:xconfiguration, 'Video Input Connector 1', InputSourceType: :Camera, Foo: 'Bar', Quality: :Motion) + .should_send("xConfiguration Video Input Connector 1 InputSourceType: Camera | resultId=\"#{id_peek}\"\n") + .responds( + <<~JSON + { + "ResultId": \"#{id_pop}\" + } + JSON + ) + .should_send("xConfiguration Video Input Connector 1 Foo: \"Bar\" | resultId=\"#{id_peek}\"\n") + .responds( + <<~JSON + { + "CommandResponse":{ + "Configuration":{ + "status":"Error", + "Reason":{ + "Value":"No match on address expression." + }, + "XPath":{ + "Value":"Configuration/Video/Input/Connector[1]/Foo" + } + } + }, + "ResultId": \"#{id_pop}\" + } + JSON + ) + .should_send("xConfiguration Video Input Connector 1 Quality: Motion | resultId=\"#{id_peek}\"\n") + .responds( + <<~JSON + { + "ResultId": \"#{id_pop}\" + } + JSON + ) + result.tap do |last_result| + expect(last_result.resolved?).to be true + expect { last_result.value }.to raise_error(CoroutineRejection) + end + + + # ------------------------------------------------------------------------- + section 'Status' + + # Status query + exec(:xstatus, 'Audio') + .should_send("xStatus Audio | resultId=\"#{id_peek}\"\n") + .responds( + <<~JSON + { + "Status":{ + "Audio":{ + "Input":{ + "Connectors":{ + "Microphone":[ + { + "id":"1", + "ConnectionStatus":{ + "Value":"Connected" + } + }, + { + "id":"2", + "ConnectionStatus":{ + "Value":"NotConnected" + } + } + ] + } + }, + "Microphones":{ + "Mute":{ + "Value":"On" + } + }, + "Output":{ + "Connectors":{ + "Line":[ + { + "id":"1", + "DelayMs":{ + "Value":"0" + } + } + ] + } + }, + "Volume":{ + "Value":"50" + } + } + }, + "ResultId": \"#{id_pop}\" + } + JSON + ) + + # Status results are provided in the return + exec(:xstatus, 'Time') + .should_send("xStatus Time | resultId=\"#{id_peek}\"\n") + .responds( + <<~JSON + { + "Status":{ + "Time":{ + "SystemTime":{ + "Value":"2017-11-27T15:14:25+1000" + } + } + }, + "ResultId": \"#{id_pop}\" + } + JSON + ) + expect(result['SystemTime']).to eq '2017-11-27T15:14:25+1000' +end diff --git a/modules/cisco/collaboration_endpoint/sx20.rb b/modules/cisco/collaboration_endpoint/sx20.rb new file mode 100644 index 00000000..0b309019 --- /dev/null +++ b/modules/cisco/collaboration_endpoint/sx20.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +load File.join(__dir__, 'room_os.rb') +load File.join(__dir__, 'ui_extensions.rb') +load File.join(__dir__, 'external_source.rb') + +class Cisco::CollaborationEndpoint::Sx20 < Cisco::CollaborationEndpoint::RoomOs + include ::Cisco::CollaborationEndpoint::Xapi::Mapper + include ::Cisco::CollaborationEndpoint::UiExtensions + include ::Cisco::CollaborationEndpoint::ExternalSource + + descriptive_name 'Cisco SX20' + description <<~DESC + Control of Cisco SX20 devices. + + API access requires a local user with the 'integrator' role to be + created on the codec. + DESC + + tokenize delimiter: Tokens::COMMAND_RESPONSE, + wait_ready: Tokens::LOGIN_COMPLETE + clear_queue_on_disconnect! + + def connected + super + + register_feedback '/Event/PresentationPreviewStarted' do + self[:local_presentation] = true + end + register_feedback '/Event/PresentationPreviewStopped' do + self[:local_presentation] = false + end + + self[:calls] = {} + register_feedback '/Status/Call' do |call| + calls = self[:calls].deep_merge call + calls.reject! do |_, props| + props[:status] == :Idle || props.include?(:ghost) + end + self[:calls] = calls + end + end + + status 'Audio Microphones Mute' => :mic_mute + status 'Audio Volume' => :volume + status 'RoomAnalytics PeoplePresence' => :presence_detected + status 'Conference DoNotDisturb' => :do_not_disturb + status 'Conference Presentation Mode' => :presentation + status 'Peripherals ConnectedDevice' => :peripherals + status 'Video Selfview Mode' => :selfview + status 'Video Input' => :video_input + status 'Video Output' => :video_output + status 'Standby State' => :standby + + command 'Audio Microphones Mute' => :mic_mute_on + command 'Audio Microphones Unmute' => :mic_mute_off + command 'Audio Microphones ToggleMute' => :mic_mute_toggle + def mic_mute(state = On) + is_affirmative? state ? mic_mute_on : mic_mute_off + end + + command 'Audio Sound Play' => :play_sound, + Sound: [:Alert, :Bump, :Busy, :CallDisconnect, :CallInitiate, + :CallWaiting, :Dial, :KeyInput, :KeyInputDelete, :KeyTone, + :Nav, :NavBack, :Notification, :OK, :PresentationConnect, + :Ringing, :SignIn, :SpecialInfo, :TelephoneCall, + :VideoCall, :VolumeAdjust, :WakeUp], + Loop_: [:Off, :On] + command 'Audio Sound Stop' => :stop_sound + + command 'Bookings List' => :bookings, + Days_: (1..365), + DayOffset_: (0..365), + Limit_: Integer, + Offset_: Integer + + command 'Call Accept' => :call_accept, CallId_: Integer + command 'Call Reject' => :call_reject, CallId_: Integer + command 'Call Disconnect' => :hangup, CallId_: Integer + command 'Dial' => :dial, + Number: String, + Protocol_: [:H320, :H323, :Sip, :Spark], + CallRate_: (64..6000), + CallType_: [:Audio, :Video] + + command 'Camera Preset Activate' => :camera_preset, + PresetId: (1..35) + command 'Camera Preset Store' => :camera_store_preset, + CameraId: (1..1), + PresetId_: (1..35), # Optional - codec will auto-assign if omitted + Name_: String, + TakeSnapshot_: [true, false], + DefaultPosition_: [true, false] + + command 'Camera PositionReset' => :camera_position_reset, + CameraId: (1..2), + Axis_: [:All, :Focus, :PanTilt, :Zoom] + command 'Camera Ramp' => :camera_move, + CameraId: (1..2), + Pan_: [:Left, :Right, :Stop], + PanSpeed_: (1..15), + Tilt_: [:Down, :Up, :Stop], + TiltSpeed_: (1..15), + Zoom_: [:In, :Out, :Stop], + ZoomSpeed_: (1..15), + Focus_: [:Far, :Near, :Stop] + + command 'Video Input SetMainVideoSource' => :camera_select, + ConnectorId_: (1..3), # Source can either be specified as the + Layout_: [:Equal, :PIP], # physical connector... + SourceId_: (1..3) # ...or the logical source ID + + command 'Video Selfview Set' => :selfview, + Mode_: [:On, :Off], + FullScreenMode_: [:On, :Off], + PIPPosition_: [:CenterLeft, :CenterRight, :LowerLeft, :LowerRight, + :UpperCenter, :UpperLeft, :UpperRight], + OnMonitorRole_: [:First, :Second, :Third, :Fourth] + + command 'Phonebook Search' => :phonebook_search, + SearchString: String, + PhonebookType_: [:Corporate, :Local], + Limit_: Integer, + Offset_: Integer + + command 'Presentation Start' => :presentation_start, + PresentationSource_: (1..2), + SendingMode_: [:LocalRemote, :LocalOnly], + ConnectorId_: (1..2), + Instance_: [:New, *(1..6)] + command 'Presentation Stop' => :presentation_stop, + Instance_: (1..6), + PresentationSource_: (1..4) + + # Provide compatabilty with the router module for activating presentation. + def switch_to(input) + if [0, nil, :none, 'none', :blank, 'blank'].include? input + presentation_stop + else + presentation_start presentation_source: input + end + end + + command 'Standby Deactivate' => :powerup + command 'Standby HalfWake' => :half_wake + command 'Standby Activate' => :standby + command 'Standby ResetTimer' => :reset_standby_timer, Delay: (1..480) + def power(state = false) + if is_affirmative? state + powerup + elsif is_negatory? state + standby + elsif state.to_s =~ /wake/i + half_wake + else + logger.error "Invalid power state: #{state}" + end + end + + command! 'SystemUnit Boot' => :reboot, Action_: [:Restart, :Shutdown] +end diff --git a/modules/cisco/collaboration_endpoint/sx80.rb b/modules/cisco/collaboration_endpoint/sx80.rb new file mode 100644 index 00000000..feb7dde0 --- /dev/null +++ b/modules/cisco/collaboration_endpoint/sx80.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +load File.join(__dir__, 'room_os.rb') +load File.join(__dir__, 'ui_extensions.rb') +load File.join(__dir__, 'external_source.rb') + +class Cisco::CollaborationEndpoint::Sx80 < Cisco::CollaborationEndpoint::RoomOs + include ::Cisco::CollaborationEndpoint::Xapi::Mapper + include ::Cisco::CollaborationEndpoint::UiExtensions + include ::Cisco::CollaborationEndpoint::ExternalSource + + descriptive_name 'Cisco SX80' + description <<~DESC + Control of Cisco SX80 devices. + + API access requires a local user with the 'integrator' role to be + created on the codec. + DESC + + tokenize delimiter: Tokens::COMMAND_RESPONSE, + wait_ready: Tokens::LOGIN_COMPLETE + clear_queue_on_disconnect! + + def connected + super + + register_feedback '/Event/PresentationPreviewStarted' do + self[:local_presentation] = true + end + register_feedback '/Event/PresentationPreviewStopped' do + self[:local_presentation] = false + end + + self[:calls] = {} + self[:in_call] = false + register_feedback '/Status/Call' do |call| + calls = self[:calls].deep_merge call + calls.reject! do |_, props| + props[:status] == :Idle || props.include?(:ghost) + end + self[:calls] = calls + self[:in_call] = calls.present? + end + end + + status 'Audio Microphones Mute' => :mic_mute + status 'Audio Volume' => :volume + status 'Cameras PresenterTrack' => :presenter_track + status 'Cameras SpeakerTrack' => :speaker_track + status 'RoomAnalytics PeoplePresence' => :presence_detected + status 'Conference DoNotDisturb' => :do_not_disturb + status 'Conference Presentation Mode' => :presentation + status 'Peripherals ConnectedDevice' => :peripherals + status 'Video Selfview Mode' => :selfview + status 'Video Input' => :video_input + status 'Video Output' => :video_output + status 'Standby State' => :standby + + command 'Audio Microphones Mute' => :mic_mute_on + command 'Audio Microphones Unmute' => :mic_mute_off + command 'Audio Microphones ToggleMute' => :mic_mute_toggle + def mic_mute(state = On) + is_affirmative? state ? mic_mute_on : mic_mute_off + end + + command 'Audio Sound Play' => :play_sound, + Sound: [:Alert, :Bump, :Busy, :CallDisconnect, :CallInitiate, + :CallWaiting, :Dial, :KeyInput, :KeyInputDelete, :KeyTone, + :Nav, :NavBack, :Notification, :OK, :PresentationConnect, + :Ringing, :SignIn, :SpecialInfo, :TelephoneCall, + :VideoCall, :VolumeAdjust, :WakeUp], + Loop_: [:Off, :On] + command 'Audio Sound Stop' => :stop_sound + + command 'Audio Volume Set' => :volume, + Level: (0..100) + + command 'Bookings List' => :bookings, + Days_: (1..365), + DayOffset_: (0..365), + Limit_: Integer, + Offset_: Integer + + command 'Call Accept' => :call_accept, CallId_: Integer + command 'Call Reject' => :call_reject, CallId_: Integer + command 'Call Disconnect' => :hangup, CallId_: Integer + + command 'Call DTMFSend' => :dtmf_send, + CallId: (0..65534), + DTMFString: String + + command 'Dial' => :dial, + Number: String, + Protocol_: [:H320, :H323, :Sip, :Spark], + CallRate_: (64..6000), + CallType_: [:Audio, :Video] + + command 'Camera Preset Activate' => :camera_preset, + PresetId: (1..35) + command 'Camera Preset Store' => :camera_store_preset, + CameraId: (1..7), + PresetId_: (1..35), # Optional - codec will auto-assign if omitted + Name_: String, + TakeSnapshot_: [true, false], + DefaultPosition_: [true, false] + + command 'Camera PositionReset' => :camera_position_reset, + CameraId: (1..7), + Axis_: [:All, :Focus, :PanTilt, :Zoom] + command 'Camera Ramp' => :camera_move, + CameraId: (1..7), + Pan_: [:Left, :Right, :Stop], + PanSpeed_: (1..15), + Tilt_: [:Down, :Up, :Stop], + TiltSpeed_: (1..15), + Zoom_: [:In, :Out, :Stop], + ZoomSpeed_: (1..15), + Focus_: [:Far, :Near, :Stop] + + command 'Video Input SetMainVideoSource' => :camera_select, + ConnectorId_: (1..5), # Source can either be specified as the + Layout_: [:Equal, :PIP], # physical connector... + SourceId_: (1..4) # ...or the logical source ID + + command 'Video Selfview Set' => :selfview, + Mode_: [:On, :Off], + FullScreenMode_: [:On, :Off], + PIPPosition_: [:CenterLeft, :CenterRight, :LowerLeft, :LowerRight, + :UpperCenter, :UpperLeft, :UpperRight], + OnMonitorRole_: [:First, :Second, :Third, :Fourth] + + command! 'Cameras AutoFocus Diagnostics Start' => \ + :autofocus_diagnostics_start, + CameraId: (1..7) + command! 'Cameras AutoFocus Diagnostics Stop' => \ + :autofocus_diagnostics_stop, + CameraId: (1..7) + + command! 'Cameras PresenterTrack ClearPosition' => :presenter_track_clear + command! 'Cameras PresenterTrack StorePosition' => :presenter_track_store + command 'Cameras PresenterTrack Set' => :presenter_track, + Mode: [:Off, :Follow, :Diagnostic, :Background, :Setup, :Persistant] + + command! 'Cameras SpeakerTrack Diagnostics Start' => \ + :speaker_track_diagnostics_start + command! 'Cameras SpeakerTrack Diagnostics Stop' => \ + :speaker_track_diagnostics_stop + + command 'Cameras SpeakerTrack Activate' => :speaker_track_activate + command 'Cameras SpeakerTrack Deactivate' => :speaker_track_deactivate + def speaker_track(state = On) + if is_affirmative? state + speaker_track_activate + else + speaker_track_deactivate + end + end + + command 'Conference DoNotDisturb Activate' => :do_not_disturb_activate, + Timeout_: (1..1440) + command 'Conference DoNotDisturb Deactivate' => :do_not_disturb_deactivate + + command 'Phonebook Search' => :phonebook_search, + SearchString: String, + PhonebookType_: [:Corporate, :Local], + Limit_: Integer, + Offset_: Integer + + command 'Presentation Start' => :presentation_start, + PresentationSource_: (1..4), + SendingMode_: [:LocalRemote, :LocalOnly], + ConnectorId_: (1..5), + Instance_: [:New, *(1..6)] + command 'Presentation Stop' => :presentation_stop, + Instance_: (1..6), + PresentationSource_: (1..4) + + # Provide compatabilty with the router module for activating presentation. + def switch_to(input) + if [0, nil, :none, 'none', :blank, 'blank'].include? input + presentation_stop + else + if self[:configuration]&.dig(:Video, :Input, :Connector, input, :InputSourceType) == :camera + camera_select connector_id: input + else + presentation_start presentation_source: input + end + end + end + + command 'Standby Deactivate' => :powerup + command 'Standby HalfWake' => :half_wake + command 'Standby Activate' => :standby + command 'Standby ResetTimer' => :reset_standby_timer, Delay: (1..480) + def power(state = false) + if is_affirmative? state + powerup + elsif is_negatory? state + standby + elsif state.to_s =~ /wake/i + half_wake + else + logger.error "Invalid power state: #{state}" + end + end + + command! 'SystemUnit Boot' => :reboot, Action_: [:Restart, :Shutdown] +end diff --git a/modules/cisco/collaboration_endpoint/ui_extensions.rb b/modules/cisco/collaboration_endpoint/ui_extensions.rb new file mode 100644 index 00000000..1b693db7 --- /dev/null +++ b/modules/cisco/collaboration_endpoint/ui_extensions.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +load File.join(__dir__, 'xapi', 'mapper.rb') + +module Cisco; end +module Cisco::CollaborationEndpoint; end + +module Cisco::CollaborationEndpoint::UiExtensions + include ::Cisco::CollaborationEndpoint::Xapi::Mapper + + module Hooks + def connected + super + register_feedback '/Event/UserInterface/Extensions/Widget/Action' do |action| + logger.debug action + end + end + end + + def self.included(base) + base.prepend Hooks + end + + command 'UserInterface Message Alert Clear' => :msg_alert_clear + command 'UserInterface Message Alert Display' => :msg_alert, + Text: String, + Title_: String, + Duration_: (0..3600) + + command 'UserInterface Message Prompt Clear' => :msg_prompt_clear + def msg_prompt(text, options, title: nil, feedback_id: nil, duration: nil) + # TODO: return a promise, then prepend a async traffic monitor so it + # can be resolved with the response, or rejected after the timeout. + send_xcommand \ + 'UserInterface Message Prompt Display', + { + Text: text, + Title: title, + FeedbackId: feedback_id, + Duration: duration + }.merge(Hash[('Option.1'..'Option.5').map(&:to_sym).zip options]) + end + + command 'UserInterface Message TextInput Clear' => :msg_text_clear + command 'UserInterface Message TextInput Display' => :msg_text, + Text: String, + FeedbackId: String, + Title_: String, + Duration_: (0..3600), + InputType_: [:SingleLine, :Numeric, :Password, :PIN], + KeyboardState_: [:Open, :Closed], + PlaceHolder_: String, + SubmitText_: String + + def ui_set_value(widget, value) + if value.nil? + send_xcommand 'UserInterface Extensions Widget UnsetValue', + WidgetId: widget + else + send_xcommand 'UserInterface Extensions Widget SetValue', + Value: value, WidgetId: widget + end + end + + protected + + def ui_extensions_list + send_xcommand 'UserInterface Extensions List' + end + + def ui_extensions_clear + send_xcommand 'UserInterface Extensions Clear' + end +end diff --git a/modules/cisco/collaboration_endpoint/util/case_insensitive_hash.rb b/modules/cisco/collaboration_endpoint/util/case_insensitive_hash.rb new file mode 100644 index 00000000..2cab7063 --- /dev/null +++ b/modules/cisco/collaboration_endpoint/util/case_insensitive_hash.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'active_support/core_ext/hash/indifferent_access' + +module Cisco; end +module Cisco::CollaborationEndpoint; end +module Cisco::CollaborationEndpoint::Util; end + +class Cisco::CollaborationEndpoint::Util::CaseInsensitiveHash < \ + ActiveSupport::HashWithIndifferentAccess + def [](key) + super convert_key(key) + end + + protected + + def convert_key(key) + super(key.try(:downcase) || key) + end +end diff --git a/modules/cisco/collaboration_endpoint/util/feedback_trie.rb b/modules/cisco/collaboration_endpoint/util/feedback_trie.rb new file mode 100644 index 00000000..73f76d08 --- /dev/null +++ b/modules/cisco/collaboration_endpoint/util/feedback_trie.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require_relative 'case_insensitive_hash' + +module Cisco; end +module Cisco::CollaborationEndpoint; end +module Cisco::CollaborationEndpoint::Util; end + +class Cisco::CollaborationEndpoint::Util::FeedbackTrie < Cisco::CollaborationEndpoint::Util::CaseInsensitiveHash + # Insert a response handler block to be notified of updates effecting the + # specified feedback path. + def insert(path, &handler) + node = tokenize(path).reduce(self) do |trie, token| + trie[token] ||= self.class.new + end + + node << handler + + self + end + + # Nuke a subtree below the path + def remove(path) + path_components = tokenize path + + if path_components.empty? + clear + handlers.clear + else + *parent_path, node_key = path_components + parent = parent_path.empty? ? self : dig(*parent_path) + parent&.delete node_key + end + + self + end + + def contains?(path) + !dig(*tokenize(path)).nil? + end + + # Propogate a response throughout the trie + def notify(response) + response.try(:each) do |key, value| + node = self[key] + next unless node + node.dispatch value + node.notify value + end + end + + protected + + # Append a rx handler block to this node. + def <<(blk) + handlers << blk + end + + # Dispatch to all handlers registered on this node. + def dispatch(value) + handlers.each { |handler| handler.call value } + end + + def tokenize(path) + if path.is_a? Array + path + else + path.split(/[\s\/\\]/).reject(&:empty?) + end + end + + def handlers + @handlers ||= [] + end +end diff --git a/modules/cisco/collaboration_endpoint/xapi/action.rb b/modules/cisco/collaboration_endpoint/xapi/action.rb new file mode 100644 index 00000000..5adc5de2 --- /dev/null +++ b/modules/cisco/collaboration_endpoint/xapi/action.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'set' + +module Cisco; end +module Cisco::CollaborationEndpoint; end +module Cisco::CollaborationEndpoint::Xapi; end + +# Pure utility methods for building Cisco xAPI actions. +module Cisco::CollaborationEndpoint::Xapi::Action + ACTION_TYPE = Set.new [ + :xConfiguration, + :xCommand, + :xStatus, + :xFeedback, + :xPreferences + ] + + FEEDBACK_ACTION = Set.new [ + :register, + :deregister, + :deregisterall, + :list + ] + + module_function + + # Serialize an xAPI action into transmittable command. + # + # @param type [ACTION_TYPE] the type of action to execute + # @param args [String, Array] the action args + # @param kwargs [Hash] an optional hash of keyword arguments for the action + # @return [String] + def create_action(type, *args, **kwargs) + unless ACTION_TYPE.include? type + raise ArgumentError, + "Invalid action type. Must be one of #{ACTION_TYPE}." + end + + kwargs.merge! args.pop if args.last.is_a? Hash + + kwargs = kwargs.compact.map do |name, value| + value = "\"#{value}\"" if value.is_a? String + "#{name}: #{value}" + end + + [type, args, kwargs].flatten.join ' ' + end + + # Serialize an xCommand into transmittable command. + # + # @param path [String, Array] command path + # @param args [Hash] an optional hash of keyword arguments + # @return [String] + def xcommand(path, args) + create_action :xCommand, path, **args + end + + # Serialize an xConfiguration action into a transmittable command. + # + # @param path [String, Array] the configuration path + # @param setting [String] the setting key + # @param value the configuration value to apply + # @return [String] + def xconfiguration(path, setting, value) + create_action :xConfiguration, path, setting => value + end + + # Serialize an xStatus request into transmittable command. + # + # @param path [String, Array] status path + # @return [String] + def xstatus(path) + create_action :xStatus, path + end + + # Serialize a xFeedback subscription request. + # + # @param action [:register, :deregister, :deregisterall, :list] + # @param path [String, Array] the feedback document path + # @return [String] + def xfeedback(action, path = nil) + unless FEEDBACK_ACTION.include? action + raise ArgumentError, + "Invalid feedback action. Must be one of #{FEEDBACK_ACTION}." + end + + if path + xpath = tokenize path if path.is_a? String + create_action :xFeedback, action, "/#{xpath.join '/'}" + else + create_action :xFeedback, action + end + end + + def tokenize(path) + # Allow space or slash seperated paths + path.split(/[\s\/\\]/).reject(&:empty?) + end +end diff --git a/modules/cisco/collaboration_endpoint/xapi/mapper.rb b/modules/cisco/collaboration_endpoint/xapi/mapper.rb new file mode 100644 index 00000000..a0ee8c63 --- /dev/null +++ b/modules/cisco/collaboration_endpoint/xapi/mapper.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module Cisco; end +module Cisco::CollaborationEndpoint; end +module Cisco::CollaborationEndpoint::Xapi; end + +# Minimal DSL for mapping Cisco's xAPI to methods. +module Cisco::CollaborationEndpoint::Xapi::Mapper + module ApiMapperMethods + # Bind an xCommand to a module method. + # + # This abuses ruby's ordered hashes and fat arrow hash to provide a + # neat, declarative syntax for building out Room OS device modules. + # + # Example: + # command 'Fake n Command' => :my_method, + # ParamA: [:enum, :of, :options], + # ParamB: String, + # OptionalParam_: Integer + # + # Will provide the method: + # def my_method(index, param_a, param_b, optional_param = nil) + # ... + # end + # + # @param mapping [Hash] + # - first k/v pair is a mapping from the xCommand => method_name + # - use 'n' within command path elements containing an index element + # (as per the device protocol guide), this will be lifted into an + # 'index' parameter + # - all other pairs are ParamName: + # - suffix optional params with an underscore + # @return [Symbol] the mapped method name + def command(mapping) + command_path, method_name = mapping.shift + + params = mapping.keys.map { |name| name.to_s.underscore } + opt_, req = params.partition { |name| name.ends_with? '_' } + opt = opt_.map { |name| name.chomp '_' } + + param_str = (req + opt.map { |name| "#{name}: nil" }).join ', ' + + # TODO: add support for index commands + command_str = command_path.split(' ').join(' ') + + types = Hash[(req + opt).zip(mapping.values)] + type_checks = types.map do |param, type| + case type + when Class + msg = "#{param} must be a #{type}" + cond = "#{param}.is_a?(#{type})" + when Range + msg = "#{param} must be within #{type}" + cond = "(#{type}).include?(#{param})" + else + msg = "#{param} must be one of #{type}" + cond = "#{type}.any? { |t| t.to_s.casecmp(#{param}.to_s) == 0 }" + end + "raise ArgumentError, '#{msg}' unless #{param}.nil? || #{cond}" + end + type_check_str = type_checks.join "\n" + + class_eval <<~METHOD + def #{method_name}(#{param_str}) + #{type_check_str} + args = binding.local_variables + .map { |p| [p.to_s.camelize.to_sym, binding.local_variable_get(p)] } + .to_h + send_xcommand '#{command_str}', args + end + METHOD + + method_name + end + + # Bind an xCommand to a protected method (admin execution only). + # + # @return [Symbol] + # @see #command + def command!(mapping) + protect_method command(mapping) + end + + # Define a binding between device state and module status variables. + # + # Similar to command bindings, this provides a declarative mapping + # from a device xpath to an exposed status variable. Subscriptions will + # be automatically setup as part of connection initialisation. + # + # Example: + # status 'Standby State' => :standby + # + # Will track the device standby state and push it to self[:standby] + # + # @param mapping [Hash] a set of xpath => status variable bindings + def status(mapping) + status_mappings.merge! mapping + end + + def status_mappings + @mappings ||= {} + end + end + + module ApiMapperHooks + def connected + super + self.class.status_mappings.each(&method(:bind_status)) + end + end + + module_function + + def included(base) + base.extend ApiMapperMethods + base.prepend ApiMapperHooks + end +end diff --git a/modules/cisco/collaboration_endpoint/xapi/response.rb b/modules/cisco/collaboration_endpoint/xapi/response.rb new file mode 100644 index 00000000..72009f9b --- /dev/null +++ b/modules/cisco/collaboration_endpoint/xapi/response.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'json' + +module Cisco; end +module Cisco::CollaborationEndpoint; end +module Cisco::CollaborationEndpoint::Xapi; end + +module Cisco::CollaborationEndpoint::Xapi::Response + class ParserError < StandardError; end + + module_function + + # Parse a raw device response. + # + # @param data [String] the raw device response to parse + # @return a nested structure containing the fully parsed response + # @raise [ParserError] if data is invalid + def parse(data) + response = JSON.parse data, symbolize_names: true + compress response + rescue JSON::ParserError => error + raise ParserError, error + end + + # Lift the 'Value' keys returned from raw response so their parent contains + # a direct value object rather than a hash of the value and type. + def compress(fragment) + case fragment + when Hash + value, valuespaceref = fragment.values_at(:Value, :valueSpaceRef) + if value&.is_a? String + valuespace = valuespaceref&.split('/')&.last&.to_sym + convert value, valuespace + else + fragment.transform_values { |item| compress item } + end + when Array + fragment.each_with_object({}) do |item, h| + id = item.delete(:id) + id = id.is_a?(String) && id[/^\d+$/]&.to_i || id + h[id] = compress item + end + else + fragment + end + end + + BOOLEAN ||= ->(val) { truthy? val } + BOOL_OR ||= lambda do |term| + sym = term.to_sym + ->(val) { val == term ? sym : BOOLEAN[val] } + end + + PARSERS ||= { + TTPAR_OnOff: BOOLEAN, + TTPAR_OnOffAuto: BOOL_OR['Auto'], + TTPAR_OnOffCurrent: BOOL_OR['Current'], + TTPAR_MuteEnabled: BOOLEAN + }.freeze + + # Map a raw response value to an appropriate datatype. + # + # @param value [String] the value to convert + # @param valuespace [Symbol] the Cisco value space reference + # @return the value as an appropriate core datatype + def convert(value, valuespace) + parser = PARSERS[valuespace] + if parser + parser.call(value) + else + begin + Integer(value) + rescue ArgumentError + if truthy? value + true + elsif falsey? value + false + elsif value =~ /\A[[:alpha:]]+\z/ + value.to_sym + else + value + end + end + end + end + + def truthy?(value) + (::Orchestrator::Constants::On_vars + [ + 'Standby', # ensure standby state is properly mapped + 'Available' + ]).include? value + end + + def falsey?(value) + (::Orchestrator::Constants::Off_vars + [ + 'Unavailable' + ]).include? value + end +end diff --git a/modules/cisco/collaboration_endpoint/xapi/tokens.rb b/modules/cisco/collaboration_endpoint/xapi/tokens.rb new file mode 100644 index 00000000..0428b09d --- /dev/null +++ b/modules/cisco/collaboration_endpoint/xapi/tokens.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Cisco; end +module Cisco::CollaborationEndpoint; end +module Cisco::CollaborationEndpoint::Xapi; end + +# Regexp's for tokenizing the xAPI command and response structure. +module Cisco::CollaborationEndpoint::Xapi::Tokens + JSON_RESPONSE ||= /(?<=^})|(?<=^{})[\r\n]+/ + + INVALID_COMMAND ||= /(?<=^Command not recognized\.)[\r\n]+/ + + SUCCESS ||= /(?<=^OK)[\r\n]+/ + + COMMAND_RESPONSE ||= Regexp.union([JSON_RESPONSE, INVALID_COMMAND, SUCCESS]) + + LOGIN_COMPLETE ||= /\*r Login successful[\r\n]+/ +end diff --git a/modules/cisco/tele_presence/sx_camera_common.rb b/modules/cisco/tele_presence/sx_camera_common.rb index cba9a2fe..bc27998f 100644 --- a/modules/cisco/tele_presence/sx_camera_common.rb +++ b/modules/cisco/tele_presence/sx_camera_common.rb @@ -13,14 +13,14 @@ def on_load self[:joy_right] = 3 self[:joy_center] = 0 - self[:pan_max] = 65535 # Right - self[:pan_min] = -65535 # Left + self[:pan_max] = 10000 # Right + self[:pan_min] = -10000 # Left self[:pan_center] = 0 - self[:tilt_max] = 65535 # UP - self[:tilt_min] = -65535 # Down + self[:tilt_max] = 2500 # UP + self[:tilt_min] = -2500 # Down self[:tilt_center] = 0 - self[:zoom_max] = 17284 # 65535 + self[:zoom_max] = 8500 # 65535 self[:zoom_min] = 0 super @@ -68,12 +68,7 @@ def power?(options = nil, &block) def home - # command("Camera PositionReset CameraId:#{@index}", name: :preset).then do - # Preset1 is a better home as it will usually pointed to a default position wheras PositionReset may not be a userfull view - recall_position(1).then do - autofocus - do_poll - end + command("Camera Preset ActivateDefaultPosition", name: :preset) end def autofocus @@ -246,8 +241,7 @@ def preset(name) def recall_position(number) number = in_range(number, 15, 1) - command('Camera PositionActivateFromPreset', params({ - :CameraId => @index, + command('Camera Preset Activate Preset', params({ :PresetId => number }), name: :preset).then do autofocus diff --git a/modules/cisco/tele_presence/sx_mixer.rb b/modules/cisco/tele_presence/sx_mixer.rb new file mode 100644 index 00000000..3918d539 --- /dev/null +++ b/modules/cisco/tele_presence/sx_mixer.rb @@ -0,0 +1,15 @@ +load File.expand_path('./sx_telnet.rb', File.dirname(__FILE__)) +load File.expand_path('./sx_mixer_common.rb', File.dirname(__FILE__)) + + +class Cisco::TelePresence::SxMixer < Cisco::TelePresence::SxTelnet + include Cisco::TelePresence::SxMixerCommon + + # Communication settings + tokenize delimiter: "\r", + wait_ready: "login:" + clear_queue_on_disconnect! + + descriptive_name 'Cisco TelePresence Mixer' + generic_name :Mixer +end diff --git a/modules/cisco/tele_presence/sx_mixer_common.rb b/modules/cisco/tele_presence/sx_mixer_common.rb new file mode 100644 index 00000000..8b394070 --- /dev/null +++ b/modules/cisco/tele_presence/sx_mixer_common.rb @@ -0,0 +1,101 @@ +# encoding: ASCII-8BIT +module Cisco::TelePresence::SxMixerCommon + def on_load + super + on_update + end + + def on_update + end + + def connected + self[:power] = true + super + do_poll + schedule.every('30s') do + logger.debug "-- Polling VC Volume" + do_poll + end + end + + def disconnected + self[:power] = false + super + schedule.clear + end + + def power(state) + self[:power] # Here for compatibility with other camera modules + end + + def power?(options = nil, &block) + block.call unless block.nil? + self[:power] + end + + def fader(_, value) + vol = in_range(value.to_i, 100, 0) + command('Audio Volume Set', params({ + level: vol + }), name: :volume).then do + self[:faderOutput] = vol + end + end + + def faders(ids:, level:, **_) + fader(nil, level) + end + + def mute(_, state) + value = is_affirmative?(state) ? 'Mute' : 'Unmute' + command("Audio Volume #{value}", name: :mute).then do + self[:faderOutput_mute] = value + end + end + + def mutes(ids:, muted:, **_) + mute(muted) + end + + + # --------------- + # STATUS REQUESTS + # --------------- + def volume? + status "Audio Volume", priority: 0, name: :volume? + end + + def muted? + status "Audio VolumeMute", priority: 0, name: :muted? + end + + def do_poll + volume? + muted? + end + + IsResponse = '*s'.freeze + IsComplete = '**'.freeze + def received(data, resolve, command) + logger.debug { "Cisco SX Mixer sent #{data}" } + result = Shellwords.split data + if command + if result[0] == IsComplete + return :success + elsif result[0] != IsResponse + return :ignore + end + end + if result[0] == IsResponse + type = result[2].downcase.gsub(':', '').to_sym + case type + when :volume + self[:faderOutput] = result[-1].to_i + when :volumemute + self[:faderOutput_mute] = result[-1].downcase != 'off' + end + return :ignore + end + return :success + end +end \ No newline at end of file diff --git a/modules/cisco/tele_presence/sx_series_common.rb b/modules/cisco/tele_presence/sx_series_common.rb index 2d27303f..79c3b5ea 100644 --- a/modules/cisco/tele_presence/sx_series_common.rb +++ b/modules/cisco/tele_presence/sx_series_common.rb @@ -23,6 +23,7 @@ def connected schedule.every('5s') do logger.debug "-- Polling Cisco SX" call_status + video_output_mode? if @count <= 0 mute_status @@ -108,7 +109,7 @@ def mute_status end SearchDefaults = { - :PhonebookType => :Local, # Should probably make this a setting + :PhonebookType => :Local, :Limit => 10, :ContactType => :Contact, :SearchField => :Name @@ -119,6 +120,11 @@ def search(text, opts = {}) opts[:SearchString] = text command(:phonebook, :search, params(opts), name: :phonebook, max_waits: 400) end + + def clear_search_results + self[:search_results] = [] + end + # Options include: auto, custom, equal, fullscreen, overlay, presentationlargespeaker, presentationsmallspeaker, prominent, single, speaker_full def layout(mode, target = :local) diff --git a/modules/dell/projector/s718ql.rb b/modules/dell/projector/s718ql.rb index 9e2b033d..844a74b9 100755 --- a/modules/dell/projector/s718ql.rb +++ b/modules/dell/projector/s718ql.rb @@ -1,4 +1,4 @@ -require 'protocols/snmp' +require 'protocols/simple_snmp' module Dell; end module Dell::Projector; end @@ -18,7 +18,7 @@ class Dell::Projector::S718ql snmp_timeout: 4000, snmp_options: { version: 'v2c', - community: 'public', + community: 'private', } }) @@ -28,29 +28,40 @@ def on_load # Meta data for inquiring interfaces self[:type] = :projector + + schedule.every('60s') { do_poll } on_update end - def on_update - new_client if @resolved_ip + def on_unload + @client.close end - def on_unload - @transport&.close - @transport = nil - @client&.close - @client = nil + def on_update + #new_client if @resolved_ip + options = setting(:snmp_options) || {} + proxy = Protocols::SimpleSnmp.new(self) + options[:proxy] = proxy + @client = NETSNMP::Client.new(options.to_h.symbolize_keys) end + #def on_unload + # @transport&.close + # @transport = nil + # @client&.close + # @client = nil + #end + def hostname_resolution(ip) @resolved_ip = ip # Create a new client once we know the IP address of the device. # Might have been a hostname in the settings. - new_client + #new_client end def new_client + return @transport&.close @client&.close @@ -65,7 +76,7 @@ def new_client def power(state, opt = nil) if is_affirmative?(state) logger.debug "-- requested to power on" - @client.set(oid: '1.3.6.1.4.1.2699.2.4.1.4.3.0', value: 11) + resp = @client.set(oid: '1.3.6.1.4.1.2699.2.4.1.4.3.0', value: 11) self[:power] = On else logger.debug "-- requested to power off" @@ -74,9 +85,12 @@ def power(state, opt = nil) end end - def power?(options = {}, &block) - state = @client.get(oid: '1.3.6.1.4.1.2699.2.4.1.4.3.0') - logger.debug { "Volume State #{state.inspect}" } + def power? + state = @client.get(oid: '1.3.6.1.4.1.2699.2.4.1.4.2.0') + logger.debug { "Power State #{state.inspect}" } + self[:power] = state == 11 || state == 10 + self[:warming] = state == 10 + self[:cooling] = state == 9 state end @@ -85,7 +99,8 @@ def power?(options = {}, &block) INPUTS = { :hdmi => 5, :hdmi2 => 14, - :hdmi3 => 17 + :hdmi3 => 17, + :network => 16 } INPUTS.merge!(INPUTS.invert) @@ -94,9 +109,9 @@ def switch_to(input) value = INPUTS[input] raise "unknown input '#{value}'" unless value - logger.debug "-- epson LCD, requested to switch to: #{input}" - @client.set(oid: '1.3.6.1.4.1.2699.2.4.1.6.1.1.3.1', value: value) - + logger.debug "Requested to switch to: #{input}" + response = @client.set(oid: '1.3.6.1.4.1.2699.2.4.1.6.1.1.3.1', value: value) + logger.debug "Recieved: #{response}" self[:input] = input # for a responsive UI self[:mute] = false end @@ -161,6 +176,16 @@ def query_error end def do_poll + power? + input? + mute? + end + + protected + def received(data, resolve, command) + # return the data which resolves the request promise. + # the proxy uses fibers to provide this to the NETSNMP client + data end end diff --git a/modules/digital_projection/evision_7500.rb b/modules/digital_projection/evision_7500.rb new file mode 100644 index 00000000..9b094222 --- /dev/null +++ b/modules/digital_projection/evision_7500.rb @@ -0,0 +1,126 @@ +module DigitalProjection; end + +# Documentation: http://www.digitalprojection.co.uk/dpdownloads/Protocol/Simplified-Protocol-Guide-Rev-H.pdf + +class DigitalProjection::Evision_7500 + include ::Orchestrator::Constants + include ::Orchestrator::Transcoder + + # Discovery Information + tcp_port 7000 + descriptive_name 'Digital Projection E-Vision Laser 4K' + generic_name :Display + + tokenize delimiter: "\r" + + def on_load + on_update + end + + def on_update + end + + def disconnected + schedule.clear + end + + def power(state) + target = is_affirmative?(state) + self[:power_target] = target + + logger.debug { "Target = #{target} and self[:power] = #{self[:power]}" } + if target == On && self[:power] != On + send_cmd("power = 1", name: :power) + elsif target == Off && self[:power] != Off + send_cmd("power = 0", name: :power) + end + end + + def power? + send_cmd("power ?", name: :power, priority: 0) + end + + INPUTS = { + :display_port => 0, + :hdmi => 1, + :hdmi2 => 2, + :hdbaset => 3, + :sdi3g => 4, + :hdmi3 => 5, + :hdmi4 => 6 + } + INPUTS.merge!(INPUTS.invert) + def switch_to(input) + input = input.to_sym if input.class == String + send_cmd("input = #{INPUTS[input]}", name: :input) + end + + def input? + send_cmd("input ?", name: :input, priority: 0) + end + + # this projector uses a laser instead of a lamp + def laser? + send_cmd("laser.hours ?", name: :laser_inq, priority: 0) + end + + def laser_reset + send_cmd("laser.reset", name: :laser_reset) + end + + def error? + send_cmd("errcode", name: :error, priority: 0) + end + + def freeze(state) + target = is_affirmative?(state) + self[:power_target] = target + + logger.debug { "Target = #{target} and self[:freeze] = #{self[:freeze]}" } + if target == On && self[:freeze] != On + send_cmd("freeze = 1", name: :freeze) + elsif target == Off && self[:freeze] != Off + send_cmd("freeze = 0", name: :freeze) + end + end + + def freeze? + send_cmd("freeze ?", name: :freeze, priority: 0) + end + + def send_cmd(cmd, options = {}) + req = "*#{cmd}\r" + logger.debug { "Sending: #{req}" } + send(req, options) + end + + def received(data, deferrable, command) + logger.debug { "Received: #{data}" } + + return :success if command.nil? || command[:name].nil? + + data = data.split + + if(data[1] == "NAK" || data[1] == "nack") # syntax error or other + return :failed + end + + case command[:name] + when :power + self[:power] = data[-1] == "1" + when :input + self[:input] = INPUTS[data[-1].to_i] + when :laser_inq + logger.debug { "Laser inquiry response" } + self[:laser] = data[-1].to_i + when :laser_reset + self[:laser] = 0 + when :error + error = data[3..-1].join(" ") + self[:error] = error + when :freeze + self[:freeze] = data[-1] == "1" + end + return :success + end +end diff --git a/modules/digital_projection/evision_7500_spec.rb b/modules/digital_projection/evision_7500_spec.rb new file mode 100644 index 00000000..c86e168b --- /dev/null +++ b/modules/digital_projection/evision_7500_spec.rb @@ -0,0 +1,46 @@ +Orchestrator::Testing.mock_device 'DigitalProjection::Evision_7500' do + exec(:power?) + .should_send("*power ?\r") # power query + .responds("ack power = 0\r") # respond with off + .expect(status[:power]).to be(false) + + exec(:power, true) + .should_send("*power = 1\r") # power query + .responds("ack power = 1\r") # respond with on + .expect(status[:power]).to be(true) + + exec(:input?) + .should_send("*input ?\r") + .responds("ack input = 0\r") # respond with on + .expect(status[:input]).to be(:display_port) + + exec(:switch_to, "hdmi") + .should_send("*input = 1\r") + .responds("ack input = 1\r") # respond with on + .expect(status[:input]).to be(:hdmi) + + exec(:freeze?) + .should_send("*freeze ?\r") # power query + .responds("ack freeze = 0\r") # respond with on + .expect(status[:freeze]).to be(false) + + exec(:freeze, true) + .should_send("*freeze = 1\r") # power query + .responds("ack power = 1\r") # respond with on + .expect(status[:freeze]).to be(true) + + exec(:laser?) + .should_send("*laser.hours ?\r") + .responds("ack laser.hours = 1000\r") + .expect(status[:laser]).to be(1000) + + exec(:laser_reset) + .should_send("*laser.reset\r") + .responds("ack laser.reset\r") # this is suppose to respond with a check mark + .expect(status[:laser]).to be(0) + + exec(:error?) + .should_send("*errcode\r") + .responds("ack errorcode = this is a sample error code\r") + .expect(status[:error]).to eq("this is a sample error code") +end diff --git a/modules/echo360/device_capture.rb b/modules/echo360/device_capture.rb new file mode 100644 index 00000000..d1bf1f2c --- /dev/null +++ b/modules/echo360/device_capture.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true +# encoding: ASCII-8BIT + +module Echo360; end + +# Documentation: https://aca.im/driver_docs/Echo360/EchoSystemCaptureAPI_v301.pdf + +class Echo360::DeviceCapture + include ::Orchestrator::Constants + include ::Orchestrator::Security + + # Discovery Information + descriptive_name 'Echo365 Device Capture' + generic_name :Capture + implements :service + + # Communication settings + keepalive true + inactivity_timeout 15000 + + def on_load + on_update + end + + def on_update + # Configure authentication + defaults({ + headers: { + authorization: [setting(:username), setting(:password)] + } + }) + + schedule.clear + schedule.every('15s') do + logger.debug '-- Polling Capture' + system_status + capture_status + end + end + + STATUS_CMDS = { + system_status: :system, + capture_status: :captures, + next: :next_capture, + current: :current_capture, + state: :monitoring + } + + STATUS_CMDS.each do |function, route| + define_method function do + get("/status/#{route}") do |response| + check(response) { |json| process_status json } + end + end + end + + protect_method :restart_application, :reboot, :captures, :upload + + def restart_application + post('/diagnostics/restart_all') { :success } + end + + def reboot + post('/diagnostics/reboot') { :success } + end + + def captures + get('/diagnostics/recovery/saved-content') do |response| + check(response) { |json| self[:captures] = json['captures']['capture'] } + end + end + + def upload(id) + post("/diagnostics/recovery/#{id}/upload") do |response| + response.status == 200 ? response.body : :abort + end + end + + # This will auto-start a recording + def capture(name, duration, profile = nil) + profile ||= self[:capture_profiles][0] + post('/capture/new_capture', body: { + description: name, + duration: duration.to_i, + capture_profile_name: profile + }) do |response| + response.status == 200 ? Hash.from_xml(response.body)['ok']['text'] : :abort + end + state + end + + def test_capture(name, duration, profile = nil) + profile ||= self[:capture_profiles][0] + post('/capture/confidence_monitor', body: { + description: name, + duration: duration.to_i, + capture_profile_name: profile + }) do |response| + response.status == 200 ? Hash.from_xml(response.body)['ok']['text'] : :abort + end + state + end + + def extend(duration) + post('/capture/confidence_monitor', body: { + duration: duration.to_i + }) do |response| + response.status == 200 ? Hash.from_xml(response.body)['ok']['text'] : :abort + end + end + + def pause + post('/capture/pause') do |response| + response.status == 200 ? Hash.from_xml(response.body)['ok']['text'] : :abort + end + end + + def start + post('/capture/record') do |response| + response.status == 200 ? Hash.from_xml(response.body)['ok']['text'] : :abort + end + end + + alias_method :resume, :start + alias_method :record, :start + + def stop + post('/capture/stop') do |response| + response.status == 200 ? Hash.from_xml(response.body)['ok']['text'] : :abort + end + end + + protected + + # Converts the response into the appropriate format and indicates success / failure + def check(response, defer = nil) + if response.status == 200 + begin + yield Hash.from_xml(response.body) + :success + rescue => e + logger.print_error e, 'error processing response' + defer.reject e if defer + :abort + end + else + defer.reject :failed if defer + :abort + end + end + + CHECK = %w(next current) + + # Grabs the status information and sets the keys. + # Keys ending in 's' are typically an array of the inner element + def process_status(data) + data['status'].each do |key, value| + if value.is_a?(String) + value = value.strip + value = nil if value.empty? + end + + if value && CHECK.include?(key) && value['schedule'].is_a?(String) && value['schedule'].strip.empty? + self[key] = nil + elsif key[-1] == 's' && value.is_a?(Hash) + inner = value[key[0..-2]] + if inner + self[key] = inner + else + self[key] = value + end + else + self[key] = value + end + end + end +end diff --git a/modules/echo360/device_capture_spec.rb b/modules/echo360/device_capture_spec.rb new file mode 100644 index 00000000..32bde5d7 --- /dev/null +++ b/modules/echo360/device_capture_spec.rb @@ -0,0 +1,168 @@ + +Orchestrator::Testing.mock_device 'Echo360::DeviceCapture' do + capture_status = <<~HEREDOC + + 2014-02-12T15:02:19.037Z + + 3.0 + + + Audio Only (Podcast). Balanced between file size & quality + Display Only (Podcast/Vodcast/EchoPlayer). Balanced between file size & quality + Display/Video (Podcast/Vodcast/EchoPlayer). Balanced between file size & quality + Display/Video (Podcast/Vodcast/EchoPlayer). Optimized for quality/full motion video + DualDisplay (Podcast/Vodcast/EchoPlayer). Optimized for file size & bandwidth + Dual Video (Podcast/Vodcast/EchoPlayer) -Balance between file size & quality + Dual Video (Podcast/Vodcast/EchoPlayer) -High Quality + Video Only (Podcast/Vodcast/EchoPlayer). Balanced between file size & quality + + + Display/Video (Podcast/Vodcast/EchoPlayer). Balanced between file size & quality + + + media + 2014-02-12T23:00:00.000Z + 3000 + + Underwater Basket Weaving 101 (UWBW-101-100) Spring 2014 +
Underwater Basket Weaving 101 (UWBW-101-100) Spring 2014
+ + John Doe + + + Display/Video (Podcast/Vodcast/EchoPlayer). Optimized for quality/full motion video + archive + + + + balanced + stereo + -6 + 44100 + 0 + false + + + 1 + dvi + 50 + 50 + 50 + 10.0 + 960 + 720 + true + true + + + 2 + composite + 50 + 50 + 50 + 29.97 + 704 + 480 + true + false + ntsc + + + audio + aac + true + + 128000 + lc + + + + graphics1 + h264 + + vbr + 736000 + 1104000 + base + 50 + + + + graphics2 + h264 + + vbr + 1056000 + 1584000 + base + 150 + + + + audio-archive + + file + audio.aac + + + + graphics1-archive + + file + display.h264 + + + + graphics2-archive + + file + video.h264 + + + + + +
+
+ + + +
+ HEREDOC + + # NOTE:: ignore warnings about private functions + resp = Hash.from_xml(capture_status) + exec(:process_status, resp) + + expect(status[:api_versions]).to eq('3.0') + + captures = <<~HEREDOC + + + Underwater Basket Weaving 101 (UWBW-101-100) Spring 2014 + 2014-02-12T15:30:00.000Z + 3000 +
Underwater Basket Weaving 101 (UWBW-101-100) Spring 2014
+ + + John Doe + + +
+ + Some other capture + 2014-02-13T15:30:00.000Z + 1500 +
Some other capture
+ + + Steve + + +
+
+ HEREDOC + + resp = Hash.from_xml(captures) + expect(resp['captures']['capture'][0]['id']).to eq('0797b8dd-4c2d-415a-adf9-daf7f10e1759') +end diff --git a/modules/epson/projector/esc_vp21.rb b/modules/epson/projector/esc_vp21.rb index 32d148fc..7cd2add9 100755 --- a/modules/epson/projector/esc_vp21.rb +++ b/modules/epson/projector/esc_vp21.rb @@ -117,16 +117,16 @@ def volume(vol, options = {}) # # Mute Audio and Video # - def mute - logger.debug "-- epson Proj, requested to mute" - do_send(:MUTE, :ON, {:name => :video_mute}) # Audio + Video + def mute(state) + state = is_affirmative?(state) ? :ON : :OFF + + logger.debug { "-- epson Proj, requested to mute #{state}" } + do_send(:MUTE, state, {:name => :video_mute}) # Audio + Video do_send(:MUTE) # request status end def unmute - logger.debug "-- epson Proj, requested to unmute" - do_send(:MUTE, :OFF, {:name => :video_mute}) - do_send(:MUTE) + mute(false) end # Audio mute diff --git a/modules/extron/recorder/smp300_series.rb b/modules/extron/recorder/smp300_series.rb index 7a66f660..4d8c8502 100644 --- a/modules/extron/recorder/smp300_series.rb +++ b/modules/extron/recorder/smp300_series.rb @@ -15,6 +15,7 @@ class Extron::Recorder::SMP300Series < Extron::Base # This should consume the whole copyright message tokenize delimiter: "\r\n", wait_ready: /Copyright.*/i + # NOTE:: The channel arguments are here for compatibility with other recording devices def information(channel = 1) # Responds with "***<437342288>*<00:00:00>*<155:40:43>" @@ -22,7 +23,12 @@ def information(channel = 1) end def record(channel = 1) - do_send("\eY1RCDR", name: :record_action) + do_send("\eY1RCDR", name: :record_action, delay: 5000, delay_on_receive: 5000, wait: true) + end + + def stream(enable = true, channel = 1) + action = enable ? 1 : 0 + do_send("\e#{channel}*#{action}STRC", name: :record_action) end def stop(channel = 1) @@ -37,6 +43,10 @@ def status(channel = 1) do_send("\eYRCDR", name: :status) end + def stream_status(channel = 1) + do_send("\e#{channel}STRC", name: :stream_status) + end + # only works with scheduled recordings def extend(minutes, channel = 1) do_send("\eE#{minutes}RCDR", name: :extend) @@ -58,6 +68,7 @@ def recording_duration def do_poll information status + stream_status end protected @@ -71,21 +82,23 @@ def received(data, resolve, command) return :success end - if data[0] == '<' - parts = data[1..-2].split('>*<') + if data[0..3] == 'Inf*' + parts = data[5..-2].split('>*<') + logger.debug "#{parts.inspect}" self[:recording_channels] = parts[1] self[:recording_to] = parts[2] self[:time_remaining] = parts[-1] - self[:recording_time] = parts[-2] + self[:duration] = parts[-2] self[:free_space] = parts[-3] elsif data.start_with? 'RcdrY' - self[:channel1] = case data[-1].to_i + self[:status] = self[:channel1] = case data[-1].to_i + when 0 clear_recording_poller :idle when 1 if @recording.nil? - @recording = schedule.every(1000) { recording_duration } + @recording = schedule.in(5000) { @recording = schedule.every(1000) { do_poll } } end :recording when 2 @@ -94,7 +107,9 @@ def received(data, resolve, command) end elsif data.start_with? 'Inf35' self[:duration] = data.split('*')[1] - end + elsif data.start_with? 'Strc' + self[:streaming] = (data.split('*')[1] == '1') + end :success end diff --git a/modules/extron/switcher/dtp.rb b/modules/extron/switcher/dtp.rb index d10cad90..56b5a36e 100644 --- a/modules/extron/switcher/dtp.rb +++ b/modules/extron/switcher/dtp.rb @@ -181,7 +181,7 @@ def mute(group, value = true, index = nil) end def unmute(group, index = nil) - mute_audio(group, false, index) + mute(group, false, index) #do_send("\eD#{group}*0GRPM", :group_type => :mute) # Response: GrpmD#{group}*+00000 end @@ -203,7 +203,7 @@ def faders(ids:, level:, **_) def query_fader(groups, type = :volume) Array(groups).each do |group| - do_send("\eG#{group}AU", group_type: type, wait: true) + do_send("\eH#{group}AU", group_type: type, wait: true) end end diff --git a/modules/foxtel/iq2.rb b/modules/foxtel/iq2.rb index 19e28b28..6ce71cc1 100644 --- a/modules/foxtel/iq2.rb +++ b/modules/foxtel/iq2.rb @@ -65,8 +65,8 @@ def channel(number) menu: '1,37000,1,1,16,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,22,6,10,6,28,6,22,6,3212', setup: '1,37000,1,1,16,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,16,6,16,6,16,6,10,6,3237', enter: '1,37000,1,1,16,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,16,6,16,6,28,6,10,6,3224', - channel_up: '1,37000,1,1,16,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,22,6,10,6,10,6,3243', - channel_down:'1,37000,1,1,16,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,16,6,16,6,22,6,16,6,3224', + channel_up: '1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,22,6,10,6,10,6,3231', + channel_down:'1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,22,6,10,6,16,6,3225', guide: '1,37000,1,1,16,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,28,6,10,6,28,6,10,6,3218' } @@ -82,4 +82,4 @@ def channel(number) def do_send(cmd) system[@ir_driver].ir(@ir_index, cmd) end -end \ No newline at end of file +end diff --git a/modules/hitachi/projector/cp_tw_series_basic.rb b/modules/hitachi/projector/cp_tw_series_basic.rb index cec2cf6b..46f604ab 100755 --- a/modules/hitachi/projector/cp_tw_series_basic.rb +++ b/modules/hitachi/projector/cp_tw_series_basic.rb @@ -170,7 +170,8 @@ def filter_hours_reset InputCodes = { 0x03 => :hdmi, - 0x0d => :hdmi2 + 0x0d => :hdmi2, + 0x11 => :hdbaset } ErrorCodes = { diff --git a/modules/i_b_m/domino/bookings.rb b/modules/i_b_m/domino/bookings.rb deleted file mode 100644 index 7b3a34c1..00000000 --- a/modules/i_b_m/domino/bookings.rb +++ /dev/null @@ -1,230 +0,0 @@ -# encoding: ASCII-8BIT - - -# For rounding up to the nearest 15min -# See: http://stackoverflow.com/questions/449271/how-to-round-a-time-down-to-the-nearest-15-minutes-in-ruby -class ActiveSupport::TimeWithZone - def ceil(seconds = 60) - return self if seconds.zero? - Time.at(((self - self.utc_offset).to_f / seconds).ceil * seconds).in_time_zone + self.utc_offset - end -end - - -module IBM; end -module IBM::Domino; end - - -class IBM::Domino::Bookings - include ::Orchestrator::Constants - - descriptive_name 'IBM Domino Room Bookings' - generic_name :Bookings - implements :logic - - # The room we are interested in - default_settings({ - update_every: '2m' - }) - - - def on_load - on_update - end - - def on_update - self[:hide_all] = setting(:hide_all) || false - self[:touch_enabled] = setting(:touch_enabled) || false - self[:name] = self[:room_name] = setting(:room_name) || system.name - - self[:control_url] = setting(:booking_control_url) || system.config.support_url - self[:booking_controls] = setting(:booking_controls) - self[:booking_catering] = setting(:booking_catering) - self[:booking_hide_details] = setting(:booking_hide_details) - self[:booking_hide_availability] = setting(:booking_hide_availability) - self[:booking_hide_user] = setting(:booking_hide_user) - self[:booking_hide_description] = setting(:booking_hide_description) - self[:booking_hide_timeline] = setting(:booking_hide_timeline) - - # Is there catering available for this room? - self[:catering] = setting(:catering_system_id) - if self[:catering] - self[:menu] = setting(:menu) - end - - # Load the last known values (persisted to the DB) - self[:waiter_status] = (setting(:waiter_status) || :idle).to_sym - self[:waiter_call] = self[:waiter_status] != :idle - - self[:catering_status] = setting(:last_catering_status) || {} - self[:order_status] = :idle - - self[:last_meeting_started] = setting(:last_meeting_started) - self[:cancel_meeting_after] = setting(:cancel_meeting_after) - - fetch_bookings - schedule.clear - schedule.every(setting(:update_every) || '5m') { fetch_bookings } - end - - - def set_light_status(status) - lightbar = system[:StatusLight] - return if lightbar.nil? - - case status.to_sym - when :unavailable - lightbar.colour(:red) - when :available - lightbar.colour(:green) - when :pending - lightbar.colour(:orange) - else - lightbar.colour(:off) - end - end - - - # ====================================== - # Waiter call information - # ====================================== - def waiter_call(state) - status = is_affirmative?(state) - - self[:waiter_call] = status - - # Used to highlight the service button - if status - self[:waiter_status] = :pending - else - self[:waiter_status] = :idle - end - - define_setting(:waiter_status, self[:waiter_status]) - end - - def call_acknowledged - self[:waiter_status] = :accepted - define_setting(:waiter_status, self[:waiter_status]) - end - - - # ====================================== - # Catering Management - # ====================================== - def catering_status(details) - self[:catering_status] = details - - # We'll turn off the green light on the waiter call button - if self[:waiter_status] != :idle && details[:progress] == 'visited' - self[:waiter_call] = false - self[:waiter_status] = :idle - define_setting(:waiter_status, self[:waiter_status]) - end - - define_setting(:last_catering_status, details) - end - - def commit_order(order_details) - self[:order_status] = :pending - status = self[:catering_status] - - if status && status[:progress] == 'visited' - status = status.dup - status[:progress] = 'cleaned' - self[:catering_status] = status - end - - if self[:catering] - sys = system - @oid ||= 1 - systems(self[:catering])[:Orders].add_order({ - id: "#{sys.id}_#{@oid}", - created_at: Time.now.to_i, - room_id: sys.id, - room_name: sys.name, - order: order_details - }) - end - end - - def order_accepted - self[:order_status] = :accepted - end - - def order_complete - self[:order_status] = :idle - end - - - - # ====================================== - # ROOM BOOKINGS: - # ====================================== - def fetch_bookings - self[:today] = system[:Calendar].events.value.collect do |event| - { - :Start => event[:starting].utc.iso8601[0..18], - :End => event[:ending].utc.iso8601[0..18], - :Subject => event[:summary], - :owner => event[:organizer] || '' - # :setup => 0, - # :breakdown => 0 - } - end - end - - - # ====================================== - # Meeting Helper Functions - # ====================================== - - def start_meeting(meeting_ref) - self[:last_meeting_started] = meeting_ref - self[:meeting_pending] = meeting_ref - self[:meeting_ending] = false - self[:meeting_pending_notice] = false - define_setting(:last_meeting_started, meeting_ref) - end - - def cancel_meeting(start_time) - calendar = system[:Calendar] - events = calendar.events.value - events.keep_if do |event| - event[:start].to_i == start_time - end - events.each do |event| - calendar.remove(event) - end - end - - # If last meeting started !== meeting pending then - # we'll show a warning on the in room touch panel - def set_meeting_pending(meeting_ref) - self[:meeting_ending] = false - self[:meeting_pending] = meeting_ref - self[:meeting_pending_notice] = true - end - - # Meeting ending warning indicator - # (When meeting_ending !== last_meeting_started then the warning hasn't been cleared) - # The warning is only displayed when meeting_ending === true - def set_end_meeting_warning(meeting_ref = nil, extendable = false) - if self[:last_meeting_started].nil? || self[:meeting_ending] != (meeting_ref || self[:last_meeting_started]) - self[:meeting_ending] = true - - # Allows meeting ending warnings in all rooms - self[:last_meeting_started] = meeting_ref if meeting_ref - self[:meeting_canbe_extended] = extendable - end - end - - def clear_end_meeting_warning - self[:meeting_ending] = self[:last_meeting_started] - end - # --------- - - def create_meeting(options) - - end -end diff --git a/modules/kentix/multi_sensor.rb b/modules/kentix/multi_sensor.rb new file mode 100644 index 00000000..02eff2c1 --- /dev/null +++ b/modules/kentix/multi_sensor.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true +# encoding: ASCII-8BIT + +# TODO:: We can use traps instead of polling +# require 'aca/trap_dispatcher' + +module Kentix; end + +# Documentation: https://aca.im/driver_docs/Kentix/Kentix-KMS-LAN-API-1_0.pdf +# https://aca.im/driver_docs/Kentix/kentixdevices.mib + +class Kentix::MultiSensor + include ::Orchestrator::Constants + + descriptive_name 'Kentix MultiSensor' + generic_name :Sensor + implements :service + + default_settings communication_key: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + + def on_load + on_update + end + + def on_update + # default is a hash of an empty string + @key = setting(:communication_key) + end + + def connected + schedule.clear + schedule.every('10s', true) do + get_state + end + end + + def get_state + post('/api/1.0/', body: { + command: 2200, + type: :get, + auth: @key, + version: '1.0' + }.to_json, headers: { + 'Content-Type' => 'application/json' + }, name: :state) do |response| + if response.status == 200 + data = ::JSON.parse(response.body, symbolize_names: true) + if data[:error] + logger.debug { "error response\n#{data}" } + :abort + else + self[:last_updated] = data[:timestamp] + data[:data][:system][:device].each do |key, value| + self[key] = value + end + :success + end + else + :abort + end + end + end +end diff --git a/modules/lg/lcd/model_ls5.rb b/modules/lg/lcd/model_ls5.rb index f3a46790..4f76f4ea 100644 --- a/modules/lg/lcd/model_ls5.rb +++ b/modules/lg/lcd/model_ls5.rb @@ -4,7 +4,10 @@ module Lg::Lcd; end # Documentation: https://aca.im/driver_docs/LG/LS5_models.pdf # also https://aca.im/driver_docs/LG/SM_models.pdf # -# There is a secret menu that allows you to disable power management +# To ensure that the display does not go network offline when told to power off, this setting needs to be set: +# General>Power>PM mode:Screen off always +# +# For older displays, the same setting is in a secret menu that is accessed via the IR remote: # 1. Press and hold the 'Setting' button on the remote for 7 seconds # 2. Press: 0 0 0 0 OK (Press Zero four times and then OK) # 3. From the signage setup, turn off DPM @@ -56,6 +59,7 @@ def connected wake_on_lan(true) no_signal_off(false) auto_off(false) + local_button_lock(true) do_poll end @@ -81,6 +85,7 @@ def disconnected no_signal_off: 'g', auto_off: 'n', dpm: 'j', + local_button_lock: 'o', aspect_ratio: 'c' } Lookup = Command.invert @@ -255,6 +260,12 @@ def configure_dpm(time_out = 4) # The action should be set to: screen off always end + def local_button_lock(enable = true) + #0=off, 1=lock all except Power buttons, 2=lock all buttons. Default to 2 as power off from local button results in network offline + val = is_affirmative?(enable) ? 2 : 0 + do_send(Command[:local_button_lock], val, :t, name: :local_button_lock) + end + def no_signal_off(enable = false) val = is_affirmative?(enable) ? 1 : 0 do_send(Command[:no_signal_off], val, :f, name: :disable_no_sig_off) @@ -357,6 +368,8 @@ def received(data, resolve, command) logger.debug { "No Signal Auto Off changed!" } when :auto_off logger.debug { "Auto Off changed!" } + when :local_button_lock + logger.debug { "Local Button Lock changed!" } else return :ignore end diff --git a/modules/lightware/switcher/lightware_protocol.rb b/modules/lightware/switcher/lightware_protocol.rb index 7a3c2918..f6389d7f 100644 --- a/modules/lightware/switcher/lightware_protocol.rb +++ b/modules/lightware/switcher/lightware_protocol.rb @@ -88,6 +88,10 @@ def switch_video(map) switch map end + def switch_audio(map) + switch map + end + def routing_state?(**options) send("{VC}\r\n", options) end diff --git a/modules/lutron/lighting.rb b/modules/lutron/lighting.rb old mode 100755 new mode 100644 index b7534822..2223a051 --- a/modules/lutron/lighting.rb +++ b/modules/lutron/lighting.rb @@ -115,6 +115,7 @@ def daylight(area, mode) def button_press(area, button) send_cmd 'DEVICE', area, button, 3 end + alias trigger button_press def led(area, device, state) val = if state.is_a?(Integer) @@ -134,10 +135,6 @@ def led?(area, device) # ============= # COMPATIBILITY # ============= - def trigger(area, scene) - scene(area, scene, @trigger_type) - end - def light_level(area, level, component = nil, fade = 1000) if component level(area, level, fade, component) diff --git a/modules/maxhub/tv.rb b/modules/maxhub/tv.rb new file mode 100644 index 00000000..83462f82 --- /dev/null +++ b/modules/maxhub/tv.rb @@ -0,0 +1,154 @@ +# encoding: ASCII-8BIT +# frozen_string_literal: true + +module Maxhub; end + +class Maxhub::Tv + include ::Orchestrator::Constants + include ::Orchestrator::Transcoder + + # Discovery Information + tcp_port 8899 + descriptive_name 'Maxhub P75PC-G1 TV' + generic_name :Display + + tokenize delimiter: "\xDD\xEE\xFF", indicator: "\xAA\xBB\xCC" + + def on_load + on_update + self[:volume_min] = 0 + self[:volume_max] = 100 + end + + def on_unload + end + + def on_update + end + + def connected + end + + def disconnected + schedule.clear + end + + def power(state) + target = is_affirmative?(state) + self[:power_target] = target + + logger.debug { "Target = #{target} and self[:power] = #{self[:power]}" } + if target == On && self[:power] != On + send_cmd("01000001", name: :power_cmd, delay: 2000, timeout: 10000) + elsif target == Off && self[:power] != Off + send_cmd("01010002", name: :power_cmd, timeout: 10000) + end + end + + def power? + send_cmd("01020003", name: :power_inq, priority: 0) + end + + def volume(vol) + val = in_range(vol, self[:volume_max], self[:volume_min]) + self[:volume_target] = val + send_cmd("0300#{val.to_s(16).rjust(2, '0')}00", wait: false) + volume? + end + + def volume? + send_cmd("03020005", name: :volume, priority: 0) + end + + def mute_audio + send_cmd("03010004", wait: false) + mute? + end + + def unmute_audio + send_cmd("03010105", wait: false) + mute? + end + + def mute? + send_cmd("03030006", name: :mute, priority: 0) + end + + INPUTS_CMD = { + :tv => "02010003", + :av => "02020004", + :vga3 => "020B000D", + :vga1 => "02010003", + :vga2 => "02040006", + :hdmi1 => "02060008", + :hdmi2 => "02070009", + :hdmi3 => "02050007", + :pc => "0208000A", + :android => "020A000C", + :hdmi4k => "020D000F", + :whdi => "020C000E", + :ypbpr => "020F0005", + :androidslot => "020E0005" + } + + INPUTS_INQ = { + "81010082" => "tv", + "81020083"=> "av", + "81030084" => "vga1", + "81040085" => "vga2", + "81050086" => "hdmi3", + "81060087" => "hdmi1", + "81070088" => "hdmi2", + "81080089" => "pc", + "810A008B" => "android", + "810D008E" => "hdmi4k", + "810C008D" => "whdi", + "810B008C" => "vga3" + } + + def switch_to(input) + self[:input_target] = input + input = input.to_sym if input.class == String + send_cmd(INPUTS_CMD[input], wait: false) + input? + end + + def input? + send_cmd("02000002", name: :input, priority: 0) + end + + def send_cmd(cmd, options = {}) + req = "AABBCC#{cmd}DDEEFF" + logger.debug { "tell -- 0x#{req} -- #{options[:name]}" } + options[:hex_string] = true + send(req, options) + end + + def received(data, deferrable, command) + hex = byte_to_hex(data) + return :success if command.nil? || command[:name].nil? + return :ignore if (hex == "80000080" || hex == "80010081") && command[:name] != :power_cmd && command[:name] != :power_inq + + case command[:name] + when :power_cmd + if (self[:power_target] == On && hex == "80000080") || (self[:power_target] == Off && hex == "80010081") + self[:power] = self[:power_target] + else + return :ignore + end + when :power_inq + self[:power] = On if hex == "80000080" + self[:power] = Off if hex == "80010081" + when :input + self[:input] = INPUTS_INQ[hex] + when :volume + self[:volume] = byte_to_hex(data[-2]).to_i(16) + when :mute + self[:mute] = On if hex == "82010083" + self[:mute] = Off if hex == "82010184" + end + + logger.debug { "Received 0x#{hex}\n" } + return :success + end +end diff --git a/modules/maxhub/tv_spec.rb b/modules/maxhub/tv_spec.rb new file mode 100644 index 00000000..d4ef4af7 --- /dev/null +++ b/modules/maxhub/tv_spec.rb @@ -0,0 +1,51 @@ +# encoding: ASCII-8BIT +# frozen_string_literal: true + +Orchestrator::Testing.mock_device 'Maxhub::Tv' do + exec(:power?) + .should_send("\xAA\xBB\xCC\x01\x02\x00\x03\xDD\xEE\xFF") + .responds("\xAA\xBB\xCC\x80\x01\x00\x81\xDD\xEE\xFF") + .expect(status[:power]).to be(false) + + wait(2000) + + exec(:power, true) + .should_send("\xAA\xBB\xCC\x01\x00\x00\x01\xDD\xEE\xFF") + .responds("\xAA\xBB\xCC\x80\x00\x00\x80\xDD\xEE\xFF") + .expect(status[:power]).to be(true) + + exec(:input?) + .should_send("\xAA\xBB\xCC\x02\x00\x00\x02\xDD\xEE\xFF") + .responds("\xAA\xBB\xCC\x81\x05\x00\x86\xDD\xEE\xFF") + .expect(status[:input]).to be("hdmi3") + + exec(:switch_to, "pc") + .should_send("\xAA\xBB\xCC\x02\x08\x00\x0A\xDD\xEE\xFF") + .responds("\xAA\xBB\xCC\x81\x08\x00\x89\xDD\xEE\xFF") + .expect(status[:input]).to be("pc") + + exec(:mute?) + .should_send("\xAA\xBB\xCC\x03\x03\x00\x06\xDD\xEE\xFF") + .responds("\xAA\xBB\xCC\x82\x01\x01\x84\xDD\xEE\xFF") + .expect(status[:mute]).to be(false) + + exec(:mute_audio) + .should_send("\xAA\xBB\xCC\x03\x01\x00\x04\xDD\xEE\xFF") + .responds("\xAA\xBB\xCC\x82\x01\x00\x83\xDD\xEE\xFF") + .expect(status[:mute]).to be(true) + + exec(:unmute_audio) + .should_send("\xAA\xBB\xCC\x03\x01\x01\x05\xDD\xEE\xFF") + .responds("\xAA\xBB\xCC\x82\x01\x01\x84\xDD\xEE\xFF") + .expect(status[:mute]).to be(false) + + exec(:volume?) + .should_send("\xAA\xBB\xCC\x03\x02\x00\x05\xDD\xEE\xFF") + .responds("\xAA\xBB\xCC\x82\x00\x06\x09\xDD\xEE\xFF") + .expect(status[:volume]).to be(6) + + exec(:volume, 99) + .should_send("\xAA\xBB\xCC\x03\x00\x63\x00\xDD\xEE\xFF") + .responds("\xAA\xBB\xCC\x82\x00\x63\x00\xDD\xEE\xFF") + .expect(status[:volume]).to be(99) +end diff --git a/modules/panasonic/camera/he50.rb b/modules/panasonic/camera/he50.rb index f3c27af3..0911cbbb 100644 --- a/modules/panasonic/camera/he50.rb +++ b/modules/panasonic/camera/he50.rb @@ -142,7 +142,7 @@ def pantilt(pan = nil, tilt = nil) # Recall a preset from the database def preset(name) - values = @presets[name.to_sym] + values = @presets[name] if values pantilt(values[:pan], values[:tilt]) zoom(values[:zoom]) diff --git a/modules/panasonic/lcd/protocol2.rb b/modules/panasonic/lcd/protocol2.rb index f706c4ca..f7c25247 100755 --- a/modules/panasonic/lcd/protocol2.rb +++ b/modules/panasonic/lcd/protocol2.rb @@ -9,6 +9,10 @@ module Panasonic::LCD; end # Documentation: # * Protocol: https://aca.im/driver_docs/Panasonic/lcd_protocol2.pdf # * Commands: https://aca.im/driver_docs/Panasonic/panasonic_commands.pdf +# The display must be set to Protocol2 using the IR remote: +# - Press SETUP +# - Select OSD language and press ENTER for >3secs +# - Select Options > LAN Control Protocol: Protocol 2 class Panasonic::LCD::Protocol2 include ::Orchestrator::Constants @@ -44,6 +48,7 @@ def on_load def on_update @username = setting(:username) || 'dispadmin' @password = setting(:password) || '@Panasonic' + @polling_enabled = setting(:polling_enabled) end def connected @@ -70,12 +75,12 @@ def power(state, opt = nil) self[:power_stable] = false if is_affirmative?(state) self[:power_target] = On - do_send(:power_on, retries: 10, name: :power, delay_on_receive: 8000) + do_send(:power_on, retries: 10, name: :power, delay: 10000, timeout: 15000) logger.debug "requested to power on" do_send(:power_query) else self[:power_target] = Off - do_send(:power_off, retries: 10, name: :power, delay_on_receive: 8000) + do_send(:power_off, retries: 10, name: :power, delay: 8000) logger.debug "requested to power off" do_send(:power_query) end @@ -146,7 +151,7 @@ def volume? def do_poll power?(priority: 0).then do - if self[:power] + if self[:power] && @polling_enabled muted? volume? end @@ -178,6 +183,7 @@ def received(data, resolve, command) # Data is default received as a stri # We're actually handling the connection check performed by makebreak # This ensure that the connection is closed if command.nil? + logger.debug 'disconnecting as no command to process' disconnect return :success end diff --git a/modules/panasonic/lcd/rs232.rb b/modules/panasonic/lcd/rs232.rb new file mode 100644 index 00000000..83059a3a --- /dev/null +++ b/modules/panasonic/lcd/rs232.rb @@ -0,0 +1,260 @@ +# encoding: ASCII-8BIT +# frozen_string_literal: true + +require 'digest/md5' + +module Panasonic; end +module Panasonic::LCD; end + +# Documentation: +# * Protocol: https://aca.im/driver_docs/Panasonic/lcd_protocol2.pdf +# * Commands: https://aca.im/driver_docs/Panasonic/panasonic_commands.pdf + +class Panasonic::LCD::Rs232 + include ::Orchestrator::Constants + include ::Orchestrator::Transcoder + + # Discovery Information + tcp_port 1024 + descriptive_name 'Panasonic LCD RS-232' + generic_name :Display + + # Communication settings + tokenize delimiter: "\x03" + + + def on_load + self[:power] = false + self[:power_stable] = true # Stable by default (allows manual on and off) + + # Meta data for inquiring interfaces + self[:type] = :lcd + + # The projector drops the connection when there is no activity + schedule.every('60s') { do_poll if self[:connected] } + on_update + end + + def on_update + #@username = setting(:username) || 'dispadmin' + #@password = setting(:password) || '@Panasonic' + end + + def connected + + end + + def disconnected + end + + COMMANDS = { + power_on: 'PON', + power_off: 'POF', + power_query: 'QPW', + input: 'IMS', + volume: 'AVL', + volume_query: 'QAV', + audio_mute: 'AMT', + audio_mute_query: 'QAM', + current_input: 'QMI' + } + COMMANDS.merge!(COMMANDS.invert) + + # + # Power commands + # + def power(state, opt = nil) + self[:power_stable] = false + if is_affirmative?(state) + self[:power_target] = On + do_send(:power_on, retries: 10, name: :power, delay: 10000, timeout: 15000) + logger.debug "requested to power on" + power? + else + self[:power_target] = Off + do_send(:power_off, retries: 10, name: :power, delay: 8000) + logger.debug "requested to power off" + power? + end + end + + def power?(**options, &block) + options[:emit] = block if block_given? + do_send(:power_query, options) + end + + # + # Input selection + # + INPUTS = { + hdmi1: 'HM1', + hdmi: 'HM1', + hdmi2: 'HM2', + vga: 'PC1', + dvi: 'DV1', + hdbaset: 'DL1' + } + INPUTS.merge!(INPUTS.invert) + + def switch_to(input) + input = input.to_sym + return unless INPUTS.has_key? input + + # Projector doesn't automatically unmute + unmute if self[:mute] + power(true) if (self[:power] == false) + + logger.debug { "requested to switch to: #{input}" } + do_send(:input, INPUTS[input], retries: 10, delay_on_receive: 2000).then do + # Can't query current input + self[:input] = input + end + end + + def input? + do_send(:current_inputi, name: :input?) + end + + # + # Mute Audio + # + def mute_audio(val = true) + actual = val ? 1 : 0 + logger.debug "requested to mute #{val}" + do_send(:audio_mute, actual) # Audio + Video + do_poll + end + alias_method :mute, :mute_audio + + def unmute_audio + mute false + end + alias_method :unmute, :unmute_audio + + def muted? + do_send(:audio_mute_query, name: :muted?) + end + + def volume(level) + # Unable to query current volume + do_send(:volume, level.to_s.rjust(3, '0'), name: :volume?).then { self[:volume] = level.to_i } + end + + def volume? + do_send :volume_query + end + + def do_poll + power?(priority: 0, name: :power?).then do + if self[:power] + input? + volume? + muted? + end + end + end + + ERRORS = { + ERR1: '1: Undefined control command', + ERR2: '2: Out of parameter range', + ERR3: '3: Busy state or no-acceptable period', + ERR4: '4: Timeout or no-acceptable period', + ERR5: '5: Wrong data length', + ERRA: 'A: Password mismatch', + ER401: '401: Command cannot be executed', + ER402: '402: Invalid parameter is sent' + } + + def received(data, resolve, command) # Data is default received as a string + logger.debug { "sent #{data} for #{command ? command[:data] : 'unknown'}" } + + # This is the ready response + if data[0] == ' ' + # Ignore this as it is not a response, we can now make a request + return :ignore + end + + # remove the leading 00 + data = data[2..-1] + + # Error Response (00ER401) + if data.start_with?('ER') + error = data.to_sym + self[:last_error] = ERRORS[error] + + # Check for busy or timeout + if error == :ERR3 || error == :ERR4 + logger.warn "Display busy: #{self[:last_error]}" + return :retry + else + logger.error "Display error: #{self[:last_error]}" + return :abort + end + end + + cmd = COMMANDS[data] + case cmd + when :power_on + self[:power] = true + ensure_power_state + when :power_off + self[:power] = false + ensure_power_state + when '001' + self[:power] = true + ensure_power_state + else + return :success unless command + res = data.split(':')[1] + case command[:name] + when :power_query + self[:power] = res.to_i == 1 + ensure_power_state + when :audio_mute_query + self[:audio_mute] = res.to_i == 1 + when :volume_query + self[:volume] = res.to_i + when :current_input + self[:input] = INPUTS[res] + end + end + + :success + end + + + protected + + + def ensure_power_state + if !self[:power_stable] && self[:power] != self[:power_target] + power(self[:power_target]) + else + self[:power_stable] = true + end + end + + def do_send(command, param = nil, **options) + if param.is_a? Hash + options = param + param = nil + end + + # Default to the command name if name isn't set + options[:name] = command unless options[:name] + # options[:disconnect] = true + + if param.nil? + cmd = COMMANDS[command] + else + cmd = "#{COMMANDS[command]}:#{param}" + end + + full_cmd = "#{hex_to_byte('02')}#{cmd}#{hex_to_byte('030D')}" + logger.debug { "requesting: #{full_cmd}" } + + # Will only accept a single request at a time. + send(full_cmd, options) + end + +end diff --git a/modules/panasonic/lcd/touch_rs232.rb b/modules/panasonic/lcd/touch_rs232.rb new file mode 100644 index 00000000..50c83bfb --- /dev/null +++ b/modules/panasonic/lcd/touch_rs232.rb @@ -0,0 +1,257 @@ +# encoding: ASCII-8BIT +# frozen_string_literal: true + +require 'digest/md5' + +module Panasonic; end +module Panasonic::LCD; end + +# Documentation: +# * Protocol: https://aca.im/driver_docs/Panasonic/lcd_protocol2.pdf +# * Commands: https://aca.im/driver_docs/Panasonic/panasonic_commands.pdf + +class Panasonic::LCD::TouchRs232 + include ::Orchestrator::Constants + include ::Orchestrator::Transcoder + + # Discovery Information + tcp_port 1024 + descriptive_name 'Panasonic Touch LCD RS-232' #TP-65BFE1 at Arup + generic_name :Display + + # Communication settings + tokenize delimiter: "\x03" + + + def on_load + self[:power] = false + self[:power_stable] = true # Stable by default (allows manual on and off) + + # Meta data for inquiring interfaces + self[:type] = :lcd + + # The projector drops the connection when there is no activity + schedule.every('60s') { do_poll if self[:connected] } + on_update + end + + def on_update + #@username = setting(:username) || 'dispadmin' + #@password = setting(:password) || '@Panasonic' + end + + def connected + + end + + def disconnected + end + + COMMANDS = { + power_on: 'PON', + power_off: 'POF', + power_query: 'QPW', + input: 'IMS', + volume: 'AVL', + volume_query: 'QAV', + audio_mute: 'AMT', + audio_mute_query: 'QAM', + current_input: 'QMI' + } + COMMANDS.merge!(COMMANDS.invert) + + # + # Power commands + # + def power(state, opt = nil) + self[:power_stable] = false + if is_affirmative?(state) + self[:power_target] = On + do_send(:power_on, retries: 10, name: :power, delay: 10000, timeout: 15000) + logger.debug "requested to power on" + do_send(:power_query) + else + self[:power_target] = Off + do_send(:power_off, retries: 10, name: :power, delay: 8000) + logger.debug "requested to power off" + do_send(:power_query) + end + end + + def power?(**options, &block) + options[:emit] = block if block_given? + do_send(:power_query, options) + end + + # + # Input selection + # + INPUTS = { + hdmi1: 'HM1', + hdmi: 'HM1', + hdmi2: 'HM2', + vga: 'PC1', + dvi: 'DV1', + hdbaset: 'DL1' + } + INPUTS.merge!(INPUTS.invert) + + def switch_to(input) + input = input.to_sym + return unless INPUTS.has_key? input + + # Projector doesn't automatically unmute + unmute if self[:mute] + + logger.debug { "requested to switch to: #{input}" } + do_send(:input, INPUTS[input], retries: 10, delay_on_receive: 2000).then do + # Can't query current input + self[:input] = input + end + end + + def input? + do_send(:current_input) + end + + # + # Mute Audio + # + def mute_audio(val = true) + actual = val ? 1 : 0 + logger.debug "requested to mute #{val}" + do_send(:audio_mute, actual) # Audio + Video + do_poll + end + alias_method :mute, :mute_audio + + def unmute_audio + mute false + end + alias_method :unmute, :unmute_audio + + def muted? + do_send(:audio_mute_query) + end + + def volume(level) + # Unable to query current volume + do_send(:volume, level.to_s.rjust(3, '0')).then { self[:volume] = level.to_i } + end + + def volume? + do_send :volume_query + end + + def do_poll + power?(priority: 0).then do + if self[:power] + input? + volume? + muted? + end + end + end + + ERRORS = { + ERR1: '1: Undefined control command', + ERR2: '2: Out of parameter range', + ERR3: '3: Busy state or no-acceptable period', + ERR4: '4: Timeout or no-acceptable period', + ERR5: '5: Wrong data length', + ERRA: 'A: Password mismatch', + ER401: '401: Command cannot be executed', + ER402: '402: Invalid parameter is sent' + } + + def received(data, resolve, command) # Data is default received as a string + logger.debug { "sent #{data} for #{command ? command[:data] : 'unknown'}" } + + # This is the ready response + if data[0] == ' ' + # Ignore this as it is not a response, we can now make a request + return :ignore + end + + # remove the leading 00 + data = data[2..-1] + + # Error Response (00ER401) + if data.start_with?('ER') + error = data.to_sym + self[:last_error] = ERRORS[error] + + # Check for busy or timeout + if error == :ERR3 || error == :ERR4 + logger.warn "Display busy: #{self[:last_error]}" + return :retry + else + logger.error "Display error: #{self[:last_error]}" + return :abort + end + end + + cmd = COMMANDS[data] + case cmd + when :power_on + self[:power] = true + ensure_power_state + when :power_off + self[:power] = false + ensure_power_state + when '001' + self[:power] = true + ensure_power_state + else + res = data.split(':')[1] + case command[:name] + when :power_query + self[:power] = res.to_i == 1 + ensure_power_state + when :audio_mute_query + self[:audio_mute] = res.to_i == 1 + when :volume_query + self[:volume] = res.to_i + when :current_input + self[:input] = INPUTS[res] + end + end + + :success + end + + + protected + + + def ensure_power_state + if !self[:power_stable] && self[:power] != self[:power_target] + power(self[:power_target]) + else + self[:power_stable] = true + end + end + + def do_send(command, param = nil, **options) + if param.is_a? Hash + options = param + param = nil + end + + # Default to the command name if name isn't set + options[:name] = command unless options[:name] + # options[:disconnect] = true + + if param.nil? + cmd = COMMANDS[command] + else + cmd = "#{COMMANDS[command]}:#{param}" + end + #Standard Panasonic requires CR/LF - Touch LCD throw error on LF after CMD + full_cmd = hex_to_byte('02') << cmd << hex_to_byte('03') + + # Will only accept a single request at a time. + send(full_cmd, options) + end + +end diff --git a/modules/panasonic/projector/rs232.rb b/modules/panasonic/projector/rs232.rb new file mode 100644 index 00000000..548c738b --- /dev/null +++ b/modules/panasonic/projector/rs232.rb @@ -0,0 +1,247 @@ +# encoding: ASCII-8BIT +# frozen_string_literal: true + +require 'digest/md5' + +module Panasonic; end +module Panasonic::Projector; end + +# Documentation: +# * Protocol: https://aca.im/driver_docs/Panasonic/lcd_protocol2.pdf +# * Commands: https://aca.im/driver_docs/Panasonic/panasonic_commands.pdf + +class Panasonic::Projector::Rs232 + include ::Orchestrator::Constants + include ::Orchestrator::Transcoder + + # Discovery Information + tcp_port 1024 + descriptive_name 'Panasonic Projector RS-232' + generic_name :Display + + # Communication settings + tokenize delimiter: "\x03", indicator: "\x02" + + + def on_load + self[:power] = false + self[:power_stable] = true # Stable by default (allows manual on and off) + + # Meta data for inquiring interfaces + self[:type] = :lcd + + # The projector drops the connection when there is no activity + schedule.every('60s') { do_poll if self[:connected] } + on_update + end + + def on_update + #@username = setting(:username) || 'dispadmin' + #@password = setting(:password) || '@Panasonic' + end + + def connected + + end + + def disconnected + end + + COMMANDS = { + power_on: 'PON', + power_off: 'POF', + power_query: 'QPW', + input: 'IIS', + volume: 'AVL', + volume_query: 'QAV', + audio_mute: 'AMT', + audio_mute_query: 'QAM', + current_input: 'QIN' + } + COMMANDS.merge!(COMMANDS.invert) + + # + # Power commands + # + def power(state, opt = nil) + self[:power_stable] = false + if is_affirmative?(state) + self[:power_target] = On + do_send(:power_on, retries: 10, name: :power, delay: 10000, timeout: 15000) + logger.debug "requested to power on" + do_send(:power_query) + else + self[:power_target] = Off + do_send(:power_off, retries: 10, name: :power, delay: 8000) + logger.debug "requested to power off" + do_send(:power_query) + end + end + + def power?(**options, &block) + options[:emit] = block if block_given? + do_send(:power_query, options) + end + + # + # Input selection + # + INPUTS = { + hdmi1: 'HD1', + hdmi: 'HD1', + hdmi2: 'HD2', + vga: 'RG1', + dvi: 'RG2', + hdbaset: 'DL1' + } + INPUTS.merge!(INPUTS.invert) + + def switch_to(input) + input = input.to_sym + return unless INPUTS.has_key? input + + # Projector doesn't automatically unmute + unmute if self[:mute] + power(true) if (self[:power] == false) + + logger.debug { "requested to switch to: #{input}" } + do_send(:input, INPUTS[input], retries: 10, delay_on_receive: 2000).then do + # Can't query current input + self[:input] = input + end + end + + def input? + do_send(:current_input) + end + + # + # Mute Audio + # + def mute_audio(val = true) + actual = val ? 1 : 0 + logger.debug "requested to mute #{val}" + do_send(:audio_mute, actual) # Audio + Video + do_poll + end + alias_method :mute, :mute_audio + + def unmute_audio + mute false + end + alias_method :unmute, :unmute_audio + + def muted? + do_send(:audio_mute_query) + end + + def volume(level) + # Unable to query current volume + do_send(:volume, level.to_s.rjust(3, '0')).then { self[:volume] = level.to_i } + end + + def volume? + do_send :volume_query + end + + def do_poll + power?(priority: 0).then do + if self[:power] + input? + #volume? + muted? + end + end + end + + ERRORS = { + ERR1: '1: Undefined control command', + ERR2: '2: Out of parameter range', + ERR3: '3: Busy state or no-acceptable period', + ERR4: '4: Timeout or no-acceptable period', + ERR5: '5: Wrong data length', + ERRA: 'A: Password mismatch', + ER401: '401: Command cannot be executed', + ER402: '402: Invalid parameter is sent' + } + + def received(data, resolve, command) # Data is default received as a string + logger.debug { "sent \"#{data}\" for #{command ? command[:data] : 'unknown'}" } + + # Error Response (00ER401) + if data.start_with?('ER') + error = data.to_sym + self[:last_error] = ERRORS[error] + + # Check for busy or timeout + if error == :ERR3 || error == :ERR4 + logger.warn "Display busy: #{self[:last_error]}" + return :retry + else + logger.error "Display error: #{self[:last_error]}" + return :abort + end + end + + cmd = COMMANDS[data] + case cmd + when :power_on + self[:power] = true + ensure_power_state + when :power_off + self[:power] = false + ensure_power_state + else + return :success unless command + res = data + case command[:name] + when :power_query + self[:power] = res.to_i == 1 + ensure_power_state + when :audio_mute_query + self[:audio_mute] = res.to_i == 1 + when :volume_query + self[:volume] = res.to_i + when :current_input + self[:input] = INPUTS[res] + end + end + + :success + end + + + protected + + + def ensure_power_state + if !self[:power_stable] && self[:power] != self[:power_target] + power(self[:power_target]) + else + self[:power_stable] = true + end + end + + def do_send(command, param = nil, **options) + if param.is_a? Hash + options = param + param = nil + end + + # Default to the command name if name isn't set + options[:name] = command unless options[:name] + # options[:disconnect] = true + + if param.nil? + cmd = COMMANDS[command] + else + cmd = "#{COMMANDS[command]}:#{param}" + end + + full_cmd = hex_to_byte('02') << cmd << hex_to_byte('030D') + + # Will only accept a single request at a time. + send(full_cmd, options) + end + +end diff --git a/modules/qsc/QSC Remote Examples.txt b/modules/qsc/QSC Remote Examples.txt new file mode 100644 index 00000000..1c58ff12 --- /dev/null +++ b/modules/qsc/QSC Remote Examples.txt @@ -0,0 +1,84 @@ +QSC Remote Examples + + +Fader Query +component_get "B-BC8-102-CS", "PGM:Level" + +Response: +{ + "jsonrpc": "2.0", + "result": { + "Name": "B-BC8-102-CS", + "Controls": [ + { + "Name": "PGM:Level", + "String": "-10.0dB", + "Value": -10.0, + "Position": 0.66666668, + "Choices": [ + + ], + "Color": "", + "Indeterminate": false, + "Invisible": false, + "Disabled": false, + "Legend": "" + } + ] + }, + "id": 1 +} + + +Query Mute +component_get "B-BC8-102-CS", "PGM:Mute" + +Response: +{ + "jsonrpc": "2.0", + "result": { + "Name": "B-BC8-102-CS", + "Controls": [ + { + "Name": "PGM:Mute", + "String": "false", + "Value": 0.0, + "Position": 0.0, + "Choices": [ + + ], + "Color": "", + "Indeterminate": false, + "Invisible": false, + "Disabled": false, + "Legend": "" + } + ] + } + "id": 1 +} + + + +Mute Mic + component_set "B-BC8-102-CS", {"Name":"PGM:Mute", "Value": true} + +Response: +{ + "jsonrpc": "2.0", + "result": true, + "id": 1 +} + + +Change Volume + + component_set "B-BC8-102-CS", {"Name":"PGM:Level", "Value": -5} +OR + component_set "B-BC8-102-CS", {"Name":"PGM:Level", "Position": 0.6} + + +Select Source + + component_set "B-BC8-102-CS", {"Name":"PGM:Select", "Value": "1"} + diff --git a/modules/qsc/q_sys_camera.rb b/modules/qsc/q_sys_camera.rb new file mode 100644 index 00000000..62441ae2 --- /dev/null +++ b/modules/qsc/q_sys_camera.rb @@ -0,0 +1,65 @@ +# encoding: ASCII-8BIT +# frozen_string_literal: true + +module Qsc; end + +class Qsc::QSysCamera + include ::Orchestrator::Constants + + # Discovery Information + implements :logic + descriptive_name 'QSC PTZ Camera Proxy' + generic_name :Camera + + def on_load + on_update + end + + def on_update + @mod_id = setting(:driver) || :Mixer + @component = setting(:component) + end + + def power(state) + powered = is_affirmative?(state) + camera.mute('toggle_privacy', state, @component) + end + + def adjust_tilt(direction) + direction = direction.to_sym + + case direction + when :down + camera.mute('tilt_down', true, @component) + when :up + camera.mute('tilt_up', true, @component) + else # stop + camera.mute('toggle_privacy', false, @component) + camera.mute('tilt_down', false, @component) + end + end + + def adjust_pan(direction) + direction = direction.to_sym + + case direction + when :right + camera.mute('pan_right', true, @component) + when :left + camera.mute('pan_left', true, @component) + else # stop + camera.mute('pan_right', false, @component) + camera.mute('pan_left', false, @component) + end + end + + def home + camera.component_trigger(@component, 'preset_home_load') + end + + protected + + def camera + system[@mod_id] + end +end diff --git a/modules/qsc/q_sys_control.rb b/modules/qsc/q_sys_control.rb index f2e5eab1..4c206272 100644 --- a/modules/qsc/q_sys_control.rb +++ b/modules/qsc/q_sys_control.rb @@ -3,7 +3,6 @@ module Qsc; end -# Documentation: https://aca.im/driver_docs/QSC/QRCDocumentation.pdf # The older V1 protocol # http://q-syshelp.qschome.com/Content/External%20Control/Q-Sys%20External%20Control/007%20Q-Sys%20External%20Control%20Protocol.htm @@ -177,6 +176,9 @@ def unmute(mute_id, index = nil) mute(mute_id, false, index) end + def mute_toggle(mute_id, index = nil) + mute(mute_id, !self["fader#{mute_id}_mute"], index) + end def snapshot(name, index, ramp_time = 1.5) send "ssl #{name} #{index} #{ramp_time}\n", wait: false @@ -315,9 +317,9 @@ def received(data, resolve, command) case type when :fader - self["fader#{control_id}"] = (value.to_f * 10).to_i + self["#{control_id}"] = (value.to_f * 10).to_i when :mute - self["fader#{control_id}_mute"] = value.to_i == 1 + self["#{control_id}"] = value.to_i == 1 end else value = resp[2] @@ -350,9 +352,9 @@ def received(data, resolve, command) case type when :fader - self["fader#{control_id}"] = (value.to_f * 10).to_i + self["#{control_id}"] = (value.to_f * 10).to_i when :mute - self["fader#{control_id}_mute"] = value == 1 + self["#{control_id}"] = value == 1 end end else diff --git a/modules/qsc/q_sys_remote.rb b/modules/qsc/q_sys_remote.rb index e6089217..9df473a2 100644 --- a/modules/qsc/q_sys_remote.rb +++ b/modules/qsc/q_sys_remote.rb @@ -1,5 +1,10 @@ +# encoding: ASCII-8BIT +# frozen_string_literal: true + module Qsc; end +# Documentation: https://aca.im/driver_docs/QSC/QRCDocumentation.pdf + class Qsc::QSysRemote include ::Orchestrator::Constants include ::Orchestrator::Transcoder @@ -15,7 +20,7 @@ class Qsc::QSysRemote tokenize delimiter: "\0" - JsonRpcVer = '2.0'.freeze + JsonRpcVer = '2.0' Errors = { -32700 => 'Parse error. Invalid JSON was received by the server.', -32600 => 'Invalid request. The JSON sent is not a valid Request object.', @@ -36,10 +41,16 @@ class Qsc::QSysRemote def on_load + on_update + end + + def on_update + @db_based_faders = setting(:db_based_faders) + @integer_faders = setting(:integer_faders) end def connected - schedule.every('1m') do + schedule.every('20s') do logger.debug "Maintaining Connection" no_op end @@ -91,7 +102,7 @@ def control_set(name, value, ramp = nil, **options) end def control_get(*names, **options) - do_send(next_id, cmd: :"Control.Get", params: names, **options) + do_send(next_id, cmd: :"Control.Get", params: names.flatten, **options) end @@ -100,13 +111,9 @@ def control_get(*names, **options) # ------------------ def component_get(name, *controls, **options) # Example usage: - # component_get 'My APM', 'ent.xfade.gain', 'ent.xfade.gain2' - - controls.collect! do |ctrl| - { - :Name => ctrl - } - end + # component_get 'My AMP', 'ent.xfade.gain', 'ent.xfade.gain2' + controls = controls.flatten + controls.collect! { |ctrl| { :Name => ctrl } } do_send(next_id, cmd: :"Component.Get", params: { :Name => name, @@ -114,9 +121,11 @@ def component_get(name, *controls, **options) }, **options) end - def component_set(name, *values, **options) + def component_set(name, value, **options) # Example usage: # component_set 'My APM', { :Name => 'ent.xfade.gain', :Value => -100 }, {...} + values = value.is_a?(Array) ? value : [value] + # NOTE:: Can't use Array() helper on hashes as they are converted to arrays. do_send(next_id, cmd: :"Component.Set", params: { :Name => name, @@ -124,6 +133,13 @@ def component_set(name, *values, **options) }, **options) end + def component_trigger(component, trigger, **options) + do_send(next_id, cmd: :"Component.Trigger", params: { + :Name => component, + :Controls => [{ :Name => trigger }] + }, **options) + end + def get_components(**options) do_send(next_id, cmd: :"Component.GetComponents", **options) end @@ -226,7 +242,7 @@ def mixer(name, inouts, mute = false, *_, **options) sec: :Outputs } } - def fader(name, level, index, type = :matrix_out, **options) + def matrix_fader(name, level, index, type = :matrix_out, **options) info = Faders[type] params = { @@ -245,7 +261,7 @@ def fader(name, level, index, type = :matrix_out, **options) end # Named params version - def faders(ids:, level:, index:, type: :matrix_out, **options) + def matrix_faders(ids:, level:, index:, type: :matrix_out, **options) fader(ids, level, index, type, **options) end @@ -260,7 +276,7 @@ def faders(ids:, level:, index:, type: :matrix_out, **options) pri: :Outputs } } - def mute(name, value, index, type = :matrix_out, **options) + def matrix_mute(name, value, index, type = :matrix_out, **options) info = Mutes[type] params = { @@ -279,21 +295,116 @@ def mute(name, value, index, type = :matrix_out, **options) end # Named params version - def mutes(ids:, muted: true, index:, type: :matrix_out, **options) + def matrix_mutes(ids:, muted: true, index:, type: :matrix_out, **options) mute(ids, muted, index, type, **options) end + # --------------------- + # COMPATIBILITY METHODS + # --------------------- + + def fader(fader_id, level, component = nil, type = :fader, use_value: false) + faders = Array(fader_id) + if component + if @db_based_faders || use_value + level = level.to_f / 10.0 if @integer_faders && !use_value + fads = faders.map { |fad| {Name: fad, Value: level} } + else + level = level.to_f / 1000.0 if @integer_faders + fads = faders.map { |fad| {Name: fad, Position: level} } + end + component_set(component, fads, name: "level_#{faders[0]}").then do + component_get(component, faders) + end + else + reqs = faders.collect { |fad| control_set(fad, level) } + reqs.last.then { control_get(faders) } + end + end + + def faders(ids:, level:, index: nil, type: :fader, **_) + fader(ids, level, index, type) + end + + def mute(fader_id, value = true, component = nil, type = :fader) + val = is_affirmative?(value) + fader(fader_id, val, component, type, use_value: true) + end + + def mutes(ids:, muted: true, index: nil, type: :fader, **_) + mute(ids, muted, index, type) + end + + def unmute(fader_id, component = nil, type = :fader) + mute(fader_id, false, component, type) + end + + def query_fader(fader_id, component = nil, type = :fader) + faders = Array(fader_id) + + if component + component_get(component, faders) + else + control_get(faders) + end + end + + def query_faders(ids:, index: nil, type: :fader, **_) + query_fader(ids, component, type) + end + + def query_mute(fader_id, component = nil, type = :fader) + query_fader(fader_id, component, type) + end + + def query_mutes(ids:, index: nil, type: :fader, **_) + query_fader(ids, component, type) + end + # ------------------- # RESPONSE PROCESSING # ------------------- + DECODE_OPTIONS = { + symbolize_names: true + }.freeze + def received(data, resolve, command) logger.debug { "QSys sent: #{data}" } - response = JSON.parse(data) + response = JSON.parse(data, DECODE_OPTIONS) logger.debug { JSON.pretty_generate(response) } + err = response[:error] + if err + logger.warn "Error code #{err[:code]} - #{Errors[err[:code]]}\n#{err[:message]}" + return :abort + end + + result = response[:result] + case result + when Hash + controls = result[:Controls] + + if controls + # Probably Component.Get + process(controls, name: result[:Name]) + elsif result[:Platform] + # StatusGet + self[:platform] = result[:Platform] + self[:state] = result[:State] + self[:design_name] = result[:DesignName] + self[:design_code] = result[:DesignCode] + self[:is_redundant] = result[:IsRedundant] + self[:is_emulator] = result[:IsEmulator] + self[:status] = result[:Status] + end + when Array + # Control.Get + process(result) + end + return :success end @@ -301,13 +412,54 @@ def received(data, resolve, command) protected + BoolVals = ['true', 'false'] + def process(values, name: nil) + component = name.present? ? "_#{name}" : '' + values.each do |value| + name = value[:Name] + val = value[:Value] + + next unless val + + pos = value[:Position] + str = value[:String] + + if BoolVals.include?(str) + self["fader#{name}#{component}_mute"] = str == 'true' + else + # Seems like string values can be independant of the other values + # This should mostly work to detect a string value + if val == 0.0 && pos == 0.0 && str[0] != '0' + self["#{name}#{component}"] = str + next + end + + if pos + # Float between 0 and 1 + if @integer_faders + self["fader#{name}#{component}"] = (pos * 1000).to_i + else + self["fader#{name}#{component}"] = pos + end + elsif val.is_a?(String) + self["#{name}#{component}"] = val + else + if @integer_faders + self["fader#{name}#{component}"] = (val * 10).to_i + else + self["fader#{name}#{component}"] = val + end + end + end + end + end + def next_id @id += 1 @id end def do_send(id = nil, cmd:, params: {}, **options) - # Build the request req = { jsonrpc: JsonRpcVer, @@ -316,6 +468,8 @@ def do_send(id = nil, cmd:, params: {}, **options) } req[:id] = id if id + logger.debug { "requesting: #{req}" } + # Append the null terminator cmd = req.to_json cmd << "\0" diff --git a/modules/samsung/displays/md_series.rb b/modules/samsung/displays/md_series.rb index 543114f3..03ea5a0f 100755 --- a/modules/samsung/displays/md_series.rb +++ b/modules/samsung/displays/md_series.rb @@ -280,6 +280,25 @@ def auto_power(enable, options = {}) end + # + # Colour control + [ + :contrast, + :brightness, + :sharpness, + :colour, + :tint, + :red_gain, + :green_gain, + :blue_gain + ].each do |command| + define_method command do |val, **options| + val = in_range(val.to_i, 100) + do_send(command, val, options) + end + end + + protected diff --git a/modules/shure/microphone/mxw.rb b/modules/shure/microphone/mxw.rb index eef32179..302ec26c 100644 --- a/modules/shure/microphone/mxw.rb +++ b/modules/shure/microphone/mxw.rb @@ -21,7 +21,6 @@ def on_load end def on_update - end def connected @@ -39,13 +38,23 @@ def disconnected def received(data, resolve, command) logger.debug { "-- received: #{data}" } - + response = data.split(' ') + return if response[0] != 'REP' + + property = response[1] + value = response[2] + + case property + when 'MUTE_BUTTON_STATUS' + self[:mute_button] = value == 'ON' + end + return :success end def do_poll - # TODO + do_send('GET MUTE_BUTTON_STATUS') end diff --git a/modules/shure/mixer/p300.rb b/modules/shure/mixer/p300.rb new file mode 100644 index 00000000..57645cd8 --- /dev/null +++ b/modules/shure/mixer/p300.rb @@ -0,0 +1,154 @@ +module Shure; end +module Shure::Mixer; end + +# Documentation: http://www.shure.pl/dms/shure/products/mixer/user_guides/shure_intellimix_p300_command_strings/shure_intellimix_p300_command_strings.pdf + +class Shure::Mixer::P300 + include ::Orchestrator::Constants + include ::Orchestrator::Transcoder + + # Discovery Information + tcp_port 2202 + descriptive_name 'Shure P300 IntelliMix Audio Conferencing Processor' + generic_name :Mixer + + tokenize indicator: "< REP ", delimiter: " >" + + def on_load + on_update + + self[:output_gain_max] = 1400 + self[:output_gain_min] = 0 + end + + def on_update + end + + def connected + end + + def disconnected + schedule.clear + end + + def do_poll + end + + def reboot + send_cmd("REBOOT", name: :reboot) + end + + def preset(number) + send_cmd("PRESET #{number}", name: :present_cmd) + end + alias_method :trigger, :preset + + def preset? + send_inq("PRESET", name: :preset_inq, priority: 0) + end + alias_method :trigger?, :preset? + + def flash_leds + send_cmd("FLASH ON", name: :flash_cmd) + end + + def gain(group, value) + val = in_range(value, self[:output_gain_max], self[:output_gain_min]) + + faders = group.is_a?(Array) ? group : [group] + + faders.each do |fad| + send_cmd("#{fad.to_s.rjust(2, '0')} AUDIO_GAIN_HI_RES #{val.to_s.rjust(4, '0')}", group_type: :fader_cmd, wait: true) + end + end + alias_method :fader, :gain + + def gain?(group) + faders = group.is_a?(Array) ? group : [group] + + faders.each do |fad| + send_inq("#{fad.to_s.rjust(2, '0')} AUDIO_GAIN_HI_RES", group_type: :fader_inq, wait: true, priority: 0) + end + end + alias_method :fader?, :gain? + + def mute(group, value = true) + state = is_affirmative?(value) ? "ON" : "OFF" + + faders = group.is_a?(Array) ? group : [group] + + faders.each do |fad| + send_cmd("#{fad.to_s.rjust(2, '0')} AUDIO_MUTE #{state}", group_type: :mute_cmd, wait: true) + end + end + + def unmute(group) + mute(group, false) + end + + def mute?(group) + faders = group.is_a?(Array) ? group : [group] + + faders.each do |fad| + send_inq("#{fad.to_s.rjust(2, '0')} AUDIO_MUTE", group_type: :mute_inq, wait: true, priority: 0) + end + end + + # not sure what the difference between this mute is + def mute_all(value = true) + state = is_affirmative?(value) ? "ON" : "OFF" + + send_cmd("DEVICE_AUDIO_MUTE #{state}", name: :mute) + end + + def unmute_all + mute_all(false) + end + + def error? + send_inq("LAST_ERROR_EVENT", name: :error) + end + + def send_inq(cmd, options = {}) + req = "< GET #{cmd} >" + logger.debug { "Sending: #{req}" } + send(req, options) + end + + def send_cmd(cmd, options = {}) + req = "< SET #{cmd} >" + logger.debug { "Sending: #{req}" } + send(req, options) + end + + def received(data, deferrable, command) + logger.debug { "Received: #{data}" } + + # Exit function early if command is nil or + # if command is not nil and both name and group_type are nil + return :success if command.nil? || (command[:name].nil? && command[:group_type].nil?) + + data = data.split + + if command[:name] != :error + cmd = data[-2].to_sym + else + cmd = :LAST_ERROR_EVENT + end + + case cmd + when :PRESET + self[:preset] = data[-1].to_i + when :DEVICE_AUDIO_MUTE + self[:mute] = data[-1] == "ON" + when :AUDIO_MUTE + self["channel#{data[0].to_i}_mute"] = data[-1] == "ON" + when :AUDIO_GAIN_HI_RES + self["channel#{data[0].to_i}_gain"] = data[-1].to_i + when :LAST_ERROR_EVENT + error = data[1..-1].join(" ") + self[:error] = error + end + return :success + end +end diff --git a/modules/shure/mixer/p300_spec.rb b/modules/shure/mixer/p300_spec.rb new file mode 100644 index 00000000..4b862256 --- /dev/null +++ b/modules/shure/mixer/p300_spec.rb @@ -0,0 +1,54 @@ +Orchestrator::Testing.mock_device 'Shure::Mixer::P300' do + exec(:trigger?) + .should_send("< GET PRESET >") + .responds("< REP PRESET 06 >") + .expect(status[:preset]).to be(6) + + exec(:trigger, 8) + .should_send("< SET PRESET 8 >") + .responds("< REP PRESET 8 >") + .expect(status[:preset]).to be(8) + + exec(:flash_leds) + .should_send("< SET FLASH ON >") + .responds("< REP FLASH ON >") + + exec(:fader?, 0) + .should_send("< GET 00 AUDIO_GAIN_HI_RES >") + .responds("< REP 00 AUDIO_GAIN_HI_RES 0022 >") + .expect(status[:channel0_gain]).to be(22) + + exec(:fader?, [1,2,3]) + .should_send("< GET 01 AUDIO_GAIN_HI_RES >") + .responds("< REP 01 AUDIO_GAIN_HI_RES 0001 >") + .should_send("< GET 02 AUDIO_GAIN_HI_RES >") + .responds("< REP 02 AUDIO_GAIN_HI_RES 1111 >") + .should_send("< GET 03 AUDIO_GAIN_HI_RES >") + .responds("< REP 03 AUDIO_GAIN_HI_RES 0321 >") + + exec(:fader, [1,2,3], 39) + .should_send("< SET 01 AUDIO_GAIN_HI_RES 0039 >") + .responds("< REP 01 AUDIO_GAIN_HI_RES 0039 >") + .should_send("< SET 02 AUDIO_GAIN_HI_RES 0039 >") + .responds("< REP 02 AUDIO_GAIN_HI_RES 0039 >") + .should_send("< SET 03 AUDIO_GAIN_HI_RES 0039 >") + .responds("< REP 03 AUDIO_GAIN_HI_RES 0039 >") + + exec(:mute?, 10) + .should_send("< GET 10 AUDIO_MUTE >") + .responds("< REP 10 AUDIO_MUTE OFF >") + .expect(status[:channel10_mute]).to be(false) + + exec(:mute, [1,2,3]) + .should_send("< SET 01 AUDIO_MUTE ON >") + .responds("< REP 01 AUDIO_MUTE ON >") + .should_send("< SET 02 AUDIO_MUTE ON >") + .responds("< REP 02 AUDIO_MUTE ON >") + .should_send("< SET 03 AUDIO_MUTE ON >") + .responds("< REP 03 AUDIO_MUTE ON >") + + exec(:error?) + .should_send("< GET LAST_ERROR_EVENT >") + .responds("< REP LAST_ERROR_EVENT this is a sample error >") + .expect(status[:error]).to eq("this is a sample error") +end diff --git a/modules/sony/projector/fh.rb b/modules/sony/projector/fh.rb new file mode 100644 index 00000000..41f1a29b --- /dev/null +++ b/modules/sony/projector/fh.rb @@ -0,0 +1,150 @@ +# encoding: ASCII-8BIT +# frozen_string_literal: true + +require 'shellwords' + +module Sony; end +module Sony::Projector; end + +# Device Protocol Documentation: https://drive.google.com/a/room.tools/file/d/1C0gAWNOtkbrHFyky_9LfLCkPoMcYU9lO/view?usp=sharing + +class Sony::Projector::Fh + include ::Orchestrator::Constants + include ::Orchestrator::Transcoder + + + # Discovery Information + descriptive_name 'Sony Projector FH Series' + generic_name :Display + + # Communication settings + tokenize delimiter: "\x0D" + delay on_receive: 50, between_sends: 50 + + + def on_load + self[:type] = :projector + end + + + def connected + schedule.every('60s') { do_poll } + end + + def disconnected + schedule.clear + end + + def power(state) + state = is_affirmative?(state) + target = state ? "on" : "off" + set("power", target).then { self[:power] = state } + end + + def power? + get("power_status").then do |response| + self[:power] = response == "on" + end + end + + def mute(state = true) + state = is_affirmative?(state) + target = state ? "on" : "off" + set("blank", target).then { self[:mute] = state } + end + + def unmute + mute(false) + end + + def mute? + get("blank").then do |response| + self[:mute] = response == "on" + end + end + + INPUTS = { + hdmi: 'hdmi1', #Input C + dvi: 'dvi1', #Input B + video: 'video1', + svideo: 'svideo1', + rgb: 'rgb1', #Input A + hdbaset:'hdbaset1', #Input D + inputa: 'input_a', + inputb: 'input_b', + inputc: 'input_c', + inputd: 'input_d', + inpute: 'input_e' + } + INPUTS.merge!(INPUTS.invert) + + def switch_to(input) + target=input.to_sym + set("input", INPUTS[target]).then { self[:input] = target } + end + + def input? + get("input").then do |response| + self[:input] = response.to_sym + end + end + + def lamp_time? + #get "timer" + end + + + # + # Automatically creates a callable function for each command + # http://blog.jayfields.com/2007/10/ruby-defining-class-methods.html + # http://blog.jayfields.com/2008/02/ruby-dynamically-define-method.html + # + [:contrast, :brightness, :color, :hue, :sharpness].each do |command| + # Query command + define_method :"#{command}?" do + get "#{command}" + end + + # Set value command + define_method command do |level| + level = in_range(level, 0x64) + set command, level + end + end + + protected + + def received(response, resolve, command) + logger.debug { "Sony proj sent: #{response.inspect}" } + + data = response.strip.downcase.shellsplit + logger.debug { "Sony proj sent: #{data}" } + + return :success if data[0] == "ok" + return :abort if data[0] == "err_cmd" + #return data[1] if data.length > 1 + data[0] + end + + def do_poll + power?.finally do + if self[:power] + input? + mute? + lamp_time? + end + end + end + + def get(path, **options) + cmd = "#{path} ?\r\n" + logger.debug { "requesting: #{cmd}" } + send(cmd, options) + end + + def set(path, arg, **options) + cmd = "#{path} \"#{arg}\"\r\n" + logger.debug { "sending: #{cmd}" } + send(cmd, options) + end +end diff --git a/modules/sony/projector/serial_control.rb b/modules/sony/projector/serial_control.rb index 26a70666..d595e7fa 100755 --- a/modules/sony/projector/serial_control.rb +++ b/modules/sony/projector/serial_control.rb @@ -62,8 +62,13 @@ def power?(**options, &block) # Input selection # INPUTS = { - hdmi: [0x00, 0x04], - hdmi2: [0x00, 0x05] + hdmi: [0x00, 0x03], #aka inputb + inputa: [0x00, 0x02], + inputb: [0x00, 0x03], + inputc: [0x00, 0x04], + inputd: [0x00, 0x05], + usb: [0x00, 0x06], # usb type B + network: [0x00, 0x07] # network } INPUTS.merge!(INPUTS.invert) @@ -91,7 +96,7 @@ def lamp_time? def mute(val = true) logger.debug 'requested to mute' - actual = is_affirmative?(val) ? [0x00, 0x01] : [0x00, 0x00] + actual = is_affirmative?(val) ? [0x00, 0x00] : [0x00, 0x01] do_send(:set, :mute, actual, delay_on_receive: 500) end diff --git a/modules/tv_one/corio_master.rb b/modules/tv_one/corio_master.rb index aa87f8c8..4f2fd0be 100644 --- a/modules/tv_one/corio_master.rb +++ b/modules/tv_one/corio_master.rb @@ -1,127 +1,280 @@ +# frozen_string_literal: true + module TvOne; end # Documentation: https://aca.im/driver_docs/TV%20One/CORIOmaster-Commands-v1.7.0.pdf class TvOne::CorioMaster - include ::Orchestrator::Constants # these provide optional helper methods - include ::Orchestrator::Transcoder # (not used in this module) + include ::Orchestrator::Constants + include ::Orchestrator::Transcoder - # Discovery Information tcp_port 10001 - descriptive_name 'TV One CORIOmaster video wall' + descriptive_name 'tvOne CORIOmaster image processor' generic_name :VideoWall - # Communication settings - tokenize delimiter: "\r\n", wait_ready: "Interface Ready" - delay between_sends: 150 + tokenize wait_ready: 'Interface Ready', callback: :tokenize + default_settings username: 'admin', password: 'adminpw' - def on_load - on_update - end - def on_unload - end - - def on_update - @username = setting(:username) || 'admin' - @password = setting(:password) || 'adminpw' - end + # ------------------------------ + # Calllbacks def connected schedule.every('60s') do do_poll end - login + init_connection.then do + query 'CORIOmax.Serial_Number', expose_as: :serial_number + query 'CORIOmax.Software_Version', expose_as: :firmware + end end def disconnected - # Disconnected will be called before connect if initial connect fails schedule.clear end - def login - send "login(#{@username},#{@password})\r\n", priotity: 99 + + # ------------------------------ + # Main API + + def preset(id) + set('Preset.Take', id).then do + # The full query of window params can take up to ~15 seconds. To + # speed things up a little for other modules that depend on this + # state, cache window info against preset ID's. These are then used + # to provide instant status updates. + # + # As the device only supports a single connection the only time the + # cache will contain stale data is following editing of presets, in + # which case window state will update silently in the background. + @window_cache ||= {} + + update_cache = deep_query('Windows').then do |windows| + logger.debug "window cache for preset #{id} updated" + @window_cache[id] = windows + end + + update_state = lambda do |windows| + self[:windows] = windows + self[:preset] = id + end + + if @window_cache.include? id + logger.debug 'loading cached window state' + update_state.call @window_cache[id] + else + logger.debug "no cached window state available for preset #{id}" + update_cache.then(&update_state) + end + end end + alias switch_to preset - def reboot - send "System.Reset()\r\n" + def switch(signal_map) + interactions = signal_map.flat_map do |slot, windows| + Array(windows).map do |id| + id = id.to_s[/\d+/].to_i unless id.is_a? Integer + window id, 'Input', slot + end + end + thread.finally(*interactions) end - def preset(number = nil) - if number - send "Preset.Take = #{number}\r\n", name: :preset - else - send "Preset.Take\r\n" + def window(id, property, value) + set("Window#{id}.#{property}", value).then do + self[:windows] = (self[:windows] || {}).deep_merge( + :"window#{id}" => { property.downcase.to_sym => value } + ) end end - alias_method :switch_to, :preset - # For switcher like compatibility - def switch(map) - map.each do |key, value| - preset key + + protected + + + def init_connection + username = setting :username + password = setting :password + + exec('login', username, password, priority: 99).then { sync_state } + end + + def do_poll + logger.debug 'polling device' + query 'Preset.Take', expose_as: :preset + end + + # Get the presets available for recall - for some inexplicible reason this + # has a wildly different API to the rest of the system state... + def query_preset_list(expose_as: nil) + exec('Routing.Preset.PresetList').then do |preset_list| + presets = preset_list.each_with_object({}) do |preset, h| + key, val = preset.split '=' + id = key[/\d+/].to_i + name, canvas, time = val.split ',' + h[id] = { + name: name, + canvas: canvas, + time: time.to_i + } + end + + self[expose_as.to_sym] = presets unless expose_as.nil? + + presets end end + def sync_state + thread.finally( + query('Preset.Take', expose_as: :preset), + query_preset_list( expose_as: :presets), + deep_query('Windows', expose_as: :windows), + deep_query('Canvases', expose_as: :canvases), + deep_query('Layouts', expose_as: :layouts) + ) + end - # Set or query window properties - def window(id, property, value = nil) - command = "Window#{id}.#{property}" - if value - send "#{command} = #{value}\r\n", name: :"#{command}" - else - send "#{command}\r\n" + # ------------------------------ + # Base device comms + + def exec(command, *params, **opts) + logger.debug { "executing #{command}" } + + defer = thread.defer + + opts[:on_receive] = lambda do |*args| + received(*args) { |val| defer.resolve val } end + + send "#{command}(#{params.join ','})\r\n", opts + + defer.promise end + def set(path, val, **opts) + logger.debug { "setting #{path} to #{val}" } - # Runs any command provided - def send_command(cmd) - send "#{cmd}\r\n", wait: false + defer = thread.defer + + opts[:name] ||= path.to_sym + send("#{path} = #{val}\r\n", opts).then do + defer.resolve val + end + + defer.promise end + def query(path, expose_as: nil, **opts) + logger.debug { "querying #{path}" } - protected + defer = thread.defer + opts[:on_receive] = lambda do |*args| + received(*args) do |val| + self[expose_as.to_sym] = val unless expose_as.nil? + defer.resolve val + end + end - def do_poll - logger.debug "-- Polling CORIOmaster" - preset + send "#{path}\r\n", opts + defer.promise end - def received(data, resolve, command) - if data[1..5] == 'Error' - logger.warn "CORIO error: #{data}" + def deep_query(path, expose_as: nil, **opts) + logger.debug { "deep querying #{path}" } + + defer = thread.defer - # Attempt to login if we are not currently - if data =~ /Not Logged In/i - login + query(path, opts).then do |val| + if val.is_a? Hash + val.each_pair do |k, v| + val[k] = deep_query(k).value if v == '<...>' + end end + self[expose_as] = val unless expose_as.nil? + defer.resolve val + end + + defer.promise + end + + def parse_response(lines, command) + kv_pairs = lines.map do |line| + k, v = line.chop.split ' = ' + [k, v] + end - return :abort if command + updates = kv_pairs.to_h.transform_values! do |val| + case val + when /^-?\d+$/ then Integer val + when 'NULL' then nil + when /(Off)|(No)/ then false + when /(On)|(Yes)/ then true + else val + end + end + + if updates.size == 1 && updates.include?(command) + # Single property query + updates.values.first + elsif !updates.empty? && updates.values.all?(&:nil?) + # Property list + updates.keys else - logger.debug { "CORIO sent: #{data}" } + # Property set + updates.reject { |k, _| k.end_with? '()' } + .transform_keys! do |x| + x.sub(/^#{command}\.?/, '').downcase!.to_sym + end end + end - if command - if data[0] == '!' - result = data.split(' ') - case result[1].to_sym - when :"Preset.Take" - self[:preset] = result[-1].to_i - end + def tokenize(buffer) + result_line_start = buffer.index(/^!/) + return false unless result_line_start + + result_line_end = buffer.index("\r\n", result_line_start) + + if result_line_end + result_line_end + 2 + else + false + end + end + + def received(data, resolve, command) + logger.debug { "received: #{data}" } + + *body, result = data.lines + type, message = /^!(\w+)\W*(.*)\r\n$/.match(result).captures + + case type + when 'Done' + if command[:data] =~ /^#{message}/i + yield parse_response body, message if block_given? :success else :ignore end - else + when 'Info' + logger.info message + yield message if block_given? :success + when 'Error' + logger.error message + :fail + when 'Event' + logger.warn { "unhandled event: #{message}" } + :ignore + else + logger.error { "unhandled response: #{data}" } + :abort end end end - diff --git a/modules/tv_one/corio_master_spec.rb b/modules/tv_one/corio_master_spec.rb new file mode 100644 index 00000000..7f3a7f09 --- /dev/null +++ b/modules/tv_one/corio_master_spec.rb @@ -0,0 +1,185 @@ +Orchestrator::Testing.mock_device 'TvOne::CorioMaster', + settings: { + username: 'admin', + password: 'adminpw' + } do + # Util to clear out any state_sync queries + def sync_state + should_send "Preset.Take\r\n" + responds <<~RX + Preset.Take = 1\r + !Done Preset.Take\r + RX + should_send "Routing.Preset.PresetList()\r\n" + responds <<~RX + !Done Routing.Preset.PresetList()\r + RX + should_send "Windows\r\n" + responds <<~RX + !Done Windows\r + RX + should_send "Canvases\r\n" + responds <<~RX + !Done Canvases\r + RX + should_send "Layouts\r\n" + responds <<~RX + !Done Layouts\r + RX + end + + transmit <<~INIT + // ===================\r + // CORIOmaster - CORIOmax\r + // ===================\r + // Command Interface Ready\r + Please login. Use 'login(username,password)'\r + INIT + + should_send "login(admin,adminpw)\r\n" + responds "!Info : User admin Logged In\r\n" + expect(status[:connected]).to be(true) + + sync_state + + should_send "CORIOmax.Serial_Number\r\n" + responds <<~RX + CORIOmax.Serial_Number = 2218031005149\r + !Done CORIOmax.Serial_Number\r + RX + expect(status[:serial_number]).to be(2218031005149) + + should_send "CORIOmax.Software_Version\r\n" + responds <<~RX + CORIOmax.Software_Version = V1.30701.P4 Master\r + !Done CORIOmax.Software_Version\r + RX + expect(status[:firmware]).to eq('V1.30701.P4 Master') + + + exec(:exec, 'System.Reset') + .should_send("System.Reset()\r\n") + .responds <<~RX + !Info: Rebooting...\r + RX + expect(result).to eq('Rebooting...') + + exec(:set, 'Window1.Input', 'Slot3.In1') + .should_send("Window1.Input = Slot3.In1\r\n") + .responds <<~RX + Window1.Input = Slot3.In1\r + !Done Window1.Input\r + RX + expect(result).to eq('Slot3.In1') + + exec(:query, 'Window1.Input', expose_as: :status_var_test) + .should_send("Window1.Input\r\n") + .responds <<~RX + Window1.Input = Slot3.In1\r + !Done Window1.Input\r + RX + expect(result).to eq('Slot3.In1') + expect(status[:status_var_test]).to eq('Slot3.In1') + + exec(:deep_query, 'Windows') + .should_send("Windows\r\n") + .responds( + <<~RX + Windows.Window1 = <...>\r + Windows.Window2 = <...>\r + !Done Windows\r + RX + ) + .should_send("window1\r\n") + .responds( + <<~RX + Window1.FullName = Window1\r + Window1.Alias = NULL\r + Window1.Input = Slot3.In1\r + Window1.Canvas = Canvas1\r + Window1.CanWidth = 1280\r + Window1.CanHeight = 720\r + !Done Window1\r + RX + ) + .should_send("window2\r\n") + .responds( + <<~RX + Window2.FullName = Window2\r + Window2.Alias = NULL\r + Window2.Input = Slot3.In2\r + Window2.Canvas = Canvas1\r + Window2.CanWidth = 1280\r + Window2.CanHeight = 720\r + !Done Window2\r + RX + ) + expect(result).to eq( + window1: { + fullname: 'Window1', + alias: nil, + input: 'Slot3.In1', + canvas: 'Canvas1', + canwidth: 1280, + canheight: 720 + }, + window2: { + fullname: 'Window2', + alias: nil, + input: 'Slot3.In2', + canvas: 'Canvas1', + canwidth: 1280, + canheight: 720 + } + ) + + exec(:query_preset_list) + .should_send("Routing.Preset.PresetList()\r\n") + .responds( + <<~RX + Routing.Preset.PresetList[1]=Sharing-Standard,Canvas1,0\r + Routing.Preset.PresetList[2]=Standard-4-Screen,Canvas1,0\r + Routing.Preset.PresetList[3]=Standard-10-Screen,Canvas1,0\r + Routing.Preset.PresetList[11]=Clear,Canvas1,0\r + !Done Routing.Preset.PresetList()\r + RX + ) + expect(result).to eq( + 1 => { name: 'Sharing-Standard', canvas: 'Canvas1', time: 0 }, + 2 => { name: 'Standard-4-Screen', canvas: 'Canvas1', time: 0 }, + 3 => { name: 'Standard-10-Screen', canvas: 'Canvas1', time: 0 }, + 11 => { name: 'Clear', canvas: 'Canvas1', time: 0 } + ) + + + exec(:preset, 1) + .should_send("Preset.Take = 1\r\n") + .responds( + <<~RX + Preset.Take = 1\r + !Done Preset.Take\r + RX + ) + wait_tick + sync_state + expect(status[:preset]).to be(1) + + exec(:switch, 'Slot1.In1' => 1, 'Slot1.In2' => 2) + .should_send("Window1.Input = Slot1.In1\r\n") + .responds( + <<~RX + Window1.Input = Slot1.In1\r + !Done Window1.Input\r + RX + ) + .should_send("Window2.Input = Slot1.In2\r\n") + .responds( + <<~RX + Window2.Input = Slot1.In2\r + !Done Window2.Input\r + RX + ) + wait_tick + expect(status[:windows][:window1][:input]).to eq('Slot1.In1') + expect(status[:windows][:window2][:input]).to eq('Slot1.In2') +end diff --git a/modules/zencontrol/lighting.rb b/modules/zencontrol/lighting.rb new file mode 100644 index 00000000..c05b5be7 --- /dev/null +++ b/modules/zencontrol/lighting.rb @@ -0,0 +1,67 @@ +# encoding: ASCII-8BIT +# frozen_string_literal: true + +module Zencontrol; end + +# Documentation: https://aca.im/driver_docs/zencontrol/lighting_udp.pdf + +class Zencontrol::Lighting + include ::Orchestrator::Constants + include ::Orchestrator::Transcoder + + # Discovery Information + udp_port 5108 + descriptive_name 'Zencontrol Lighting' + generic_name :Lighting + + # Communication settings + wait_response false + + def on_load + on_update + end + + def on_update + @version = setting(:version) || 1 + controller = setting(:controller_id)&.to_i + + if controller + @controller = int_to_array(controller, bytes: 6) + else + @controller = [0xFF] * 6 + end + end + + # Using indirect commands + def trigger(area, scene) + # Area 128 – 191 == Address 0 – 63 + # Area 192 – 207 == Group 0 – 15 + # Area 255 == Broadcast + # + # Scene 0 - 15 + area = in_range(area.to_i, 127) + 128 + scene = in_range(scene.to_i, 15) + 16 + do_send(area, scene) + end + + # Using direct command + def light_level(area, level, channel = nil, fade = nil) + area = in_range(area.to_i, 127) + level = in_range(level.to_i, 255) + do_send(area, level.to_i) + end + + def received(data, resolve, command) + logger.debug { "received 0x#{byte_to_hex(data)}" } + :success + end + + + protected + + + def do_send(address, command, **options) + cmd = [@version, *@controller, address, command] + send(cmd, options) + end +end diff --git a/modules/zencontrol/lighting_spec.rb b/modules/zencontrol/lighting_spec.rb new file mode 100644 index 00000000..64a1fbc3 --- /dev/null +++ b/modules/zencontrol/lighting_spec.rb @@ -0,0 +1,8 @@ +# encoding: ASCII-8BIT +# frozen_string_literal: true + +Orchestrator::Testing.mock_device 'Zencontrol::Lighting' do + # Set Group 15 to Arc Level 240 on all controllers + exec(:light_level, 0x4F, 240) + .should_send("\x01\xFF\xFF\xFF\xFF\xFF\xFF\x4F\xF0") +end