diff --git a/.rubocop.yml b/.rubocop.yml index 02e1a87..71cccfe 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,3 +1,6 @@ +inherit_from: + - .rubocop_todo.yml + AllCops: TargetRubyVersion: 2.7 NewCops: enable diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 0000000..8980c02 --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,5 @@ +Metrics/MethodLength: + Max: 20 + +Metrics/ClassLength: + Max: 200 \ No newline at end of file diff --git a/README.md b/README.md index f2dcbcd..1450c5d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,21 @@ # NovaCloud Client (WIP) -Sprint 01 delivered the core HTTP client for the NovaCloud Open Platform. The gem now: + Sprint 01 delivered the core HTTP client for the NovaCloud Open Platform. The gem now: - Manages configuration once (`app_key`, `app_secret`, `service_domain`). - Handles authentication headers automatically via Faraday middleware. - Maps HTTP errors to a typed exception hierarchy. - Normalizes GET/POST payloads and parses JSON responses. +Sprint 02 expands on this foundation with dedicated resource helpers (`client.players`, `client.control`) and typed response objects (e.g., `NovacloudClient::Objects::Player`). + +## Resource Overview + +- **Players**: `list`, `statuses`, `running_status` +- **Control**: `brightness`, `volume`, `video_source`, `screen_power`, `screen_status`, `screenshot`, `reboot`, `request_result` +- **Screens** (VNNOXCare): `list`, `monitor`, `detail` +- **Logs**: `control_history` + ## Quick Start ```ruby @@ -18,11 +27,22 @@ client = NovacloudClient::Client.new( service_domain: "open-us.vnnox.com" ) -response = client.request( - http_method: :get, - endpoint: "/v2/player/list", - params: { start: 0, count: 20 } +players = client.players.list(count: 20) +first_player = players.first + +statuses = client.players.statuses(player_ids: players.map(&:player_id)) + +request = client.control.brightness( + player_ids: players.map(&:player_id), + brightness: 80, + notice_url: "https://example.com/callback" ) + +result = client.control.request_result(request_id: request.request_id) +puts result.all_successful? + +screens = client.screens.list(status: 1) +puts screens.first.name ``` ### Development @@ -33,4 +53,12 @@ bundle exec rspec bundle exec rubocop ``` -Sprint 02 will add resource helpers (e.g., `client.players.list`) and response objects built on these foundations. +### Documentation + +Run YARD to generate HTML API documentation for the gem: + +```bash +bundle exec yard doc +``` + +Then browse the docs via the generated `doc/index.html` or launch a local server with `bundle exec yard server --reload`. diff --git a/lib/novacloud_client/client.rb b/lib/novacloud_client/client.rb index 2fa6933..d6587f8 100644 --- a/lib/novacloud_client/client.rb +++ b/lib/novacloud_client/client.rb @@ -7,6 +7,10 @@ require_relative "configuration" require_relative "middleware/authentication" require_relative "middleware/error_handler" +require_relative "resources/players" +require_relative "resources/control" +require_relative "resources/screens" +require_relative "resources/logs" module NovacloudClient # Central entry point for interacting with the NovaCloud API. @@ -37,6 +41,22 @@ def request(http_method:, endpoint:, params: {}) parse_body(response) end + def players + @players ||= Resources::Players.new(self) + end + + def control + @control ||= Resources::Control.new(self) + end + + def screens + @screens ||= Resources::Screens.new(self) + end + + def logs + @logs ||= Resources::Logs.new(self) + end + private def build_connection diff --git a/lib/novacloud_client/objects/base.rb b/lib/novacloud_client/objects/base.rb new file mode 100644 index 0000000..8dae91d --- /dev/null +++ b/lib/novacloud_client/objects/base.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "time" + +module NovacloudClient + module Objects + # Base class providing attribute assignment and coercion helpers. + class Base + def initialize(attributes = {}) + assign_attributes(attributes) + end + + private + + def assign_attributes(attributes) + attributes.each do |key, value| + writer = "#{key}=" + if respond_to?(writer) + public_send(writer, value) + next + end + + normalized_writer = normalize_writer(key) + next if normalized_writer == writer + + public_send(normalized_writer, value) if respond_to?(normalized_writer) + end + end + + def parse_timestamp(value) + return nil if value.nil? + return value if value.is_a?(Time) + + Time.parse(value.to_s) + rescue ArgumentError + value + end + + def normalize_writer(key) + normalized = key.to_s + normalized = normalized.gsub(/([A-Z\d]+)([A-Z][a-z])/, "\\1_\\2") + .gsub(/([a-z\d])([A-Z])/, "\\1_\\2") + .tr("-", "_") + .downcase + "#{normalized}=" + end + end + end +end diff --git a/lib/novacloud_client/objects/control_log_entry.rb b/lib/novacloud_client/objects/control_log_entry.rb new file mode 100644 index 0000000..8ca994e --- /dev/null +++ b/lib/novacloud_client/objects/control_log_entry.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative "base" + +module NovacloudClient + module Objects + # Represents an execution record for a remote control command. + class ControlLogEntry < Base + attr_accessor :status, :type + attr_reader :execute_time + + def execute_time=(value) + @execute_time = parse_timestamp(value) + end + + def success? + status.to_i.zero? ? false : status.to_i == 1 + end + end + end +end diff --git a/lib/novacloud_client/objects/control_result.rb b/lib/novacloud_client/objects/control_result.rb new file mode 100644 index 0000000..e4a8ac4 --- /dev/null +++ b/lib/novacloud_client/objects/control_result.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require_relative "base" + +module NovacloudClient + module Objects + # Represents the result of a control or queued request returning success/fail lists. + class ControlResult < Base + attr_reader :successes, :failures + + def initialize(attributes = {}) + @successes = [] + @failures = [] + super + end + + def success=(value) + @successes = Array(value) + end + + def fail=(value) + @failures = Array(value) + end + + def all_successful? + failures.empty? + end + + def partial_success? + successes.any? && failures.any? + end + + def all_failed? + successes.empty? + end + + def success_count + successes.size + end + + def failure_count + failures.size + end + + # Maintain API compatibility with camelCase keys. + def success + successes + end + + def fail + failures + end + end + end +end diff --git a/lib/novacloud_client/objects/player.rb b/lib/novacloud_client/objects/player.rb new file mode 100644 index 0000000..d73cd33 --- /dev/null +++ b/lib/novacloud_client/objects/player.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require_relative "base" + +module NovacloudClient + module Objects + # Represents a player returned from NovaCloud player APIs. + class Player < Base + attr_accessor :player_id, :player_type, :name, :sn, :version, :ip + attr_reader :last_online_time, :online_status + + def last_online_time=(value) + @last_online_time = parse_timestamp(value) + end + + def online_status=(value) + @online_status = value.to_i + end + + def online? + online_status == 1 + end + + def offline? + !online? + end + + def synchronous? + player_type.to_i == 1 + end + + def asynchronous? + player_type.to_i == 2 + end + end + end +end diff --git a/lib/novacloud_client/objects/player_status.rb b/lib/novacloud_client/objects/player_status.rb new file mode 100644 index 0000000..024030f --- /dev/null +++ b/lib/novacloud_client/objects/player_status.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative "base" + +module NovacloudClient + module Objects + # Represents basic status information for a player. + class PlayerStatus < Base + attr_accessor :player_id, :sn + attr_reader :online_status, :last_online_time + + def online_status=(value) + @online_status = value.to_i + end + + def last_online_time=(value) + @last_online_time = parse_timestamp(value) + end + + def online? + online_status == 1 + end + + def offline? + !online? + end + end + end +end diff --git a/lib/novacloud_client/objects/queued_request.rb b/lib/novacloud_client/objects/queued_request.rb new file mode 100644 index 0000000..d718fd5 --- /dev/null +++ b/lib/novacloud_client/objects/queued_request.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require_relative "control_result" + +module NovacloudClient + module Objects + # Represents the enqueue result for asynchronous player commands. + class QueuedRequest < ControlResult + attr_reader :request_id + + # Queue responses include a request ID used to poll for results later. + def request_id=(value) + @request_id = value&.to_s + end + end + end +end diff --git a/lib/novacloud_client/objects/screen.rb b/lib/novacloud_client/objects/screen.rb new file mode 100644 index 0000000..8180687 --- /dev/null +++ b/lib/novacloud_client/objects/screen.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative "base" + +module NovacloudClient + module Objects + # Represents a screen/device entry from VNNOXCare APIs. + class Screen < Base + attr_accessor :sid, :name, :mac, :sn, :address, :longitude, :latitude, + :status, :camera, :brightness, :env_brightness + + def online? + status.to_i == 1 + end + + def camera_enabled? + camera.to_i == 1 + end + end + end +end diff --git a/lib/novacloud_client/objects/screen_detail.rb b/lib/novacloud_client/objects/screen_detail.rb new file mode 100644 index 0000000..7626f1c --- /dev/null +++ b/lib/novacloud_client/objects/screen_detail.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative "base" + +module NovacloudClient + module Objects + # Represents detailed telemetry for a screen, including nested hardware info. + class ScreenDetail < Base + attr_accessor :identifier, :input_source, :mac, :master_control, :module, + :monitor_card, :receiving_card, :screen, :sid, :smart_module, :sn + + NESTED_HASH_FIELDS = %i[input_source master_control module monitor_card receiving_card screen smart_module].freeze + + def initialize(attributes = {}) + super + ensure_nested_hashes! + end + + private + + def ensure_nested_hashes! + NESTED_HASH_FIELDS.each do |field| + value = public_send(field) + public_send("#{field}=", value || {}) + end + end + end + end +end diff --git a/lib/novacloud_client/objects/screen_monitor.rb b/lib/novacloud_client/objects/screen_monitor.rb new file mode 100644 index 0000000..9f76dee --- /dev/null +++ b/lib/novacloud_client/objects/screen_monitor.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require_relative "base" + +module NovacloudClient + module Objects + # Represents high-level monitoring metrics for a screen device. + class ScreenMonitor < Base + attr_accessor :display_device, :brightness, :env_brightness, :height, :width, :sn + end + end +end diff --git a/lib/novacloud_client/resources/base.rb b/lib/novacloud_client/resources/base.rb new file mode 100644 index 0000000..2ccd9f0 --- /dev/null +++ b/lib/novacloud_client/resources/base.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module NovacloudClient + module Resources + # Shared helpers for resource classes built on top of the client. + class Base + attr_reader :client + + def initialize(client) + @client = client + end + + private + + def get(endpoint, params: {}) + client.request(http_method: :get, endpoint: endpoint, params: params) + end + + def post(endpoint, params: {}) + client.request(http_method: :post, endpoint: endpoint, params: params) + end + + def validate_player_ids!(ids, max: 100) + raise ArgumentError, "player_ids cannot be empty" if ids.nil? || ids.empty? + raise ArgumentError, "maximum #{max} player IDs allowed" if ids.size > max + end + end + end +end diff --git a/lib/novacloud_client/resources/control.rb b/lib/novacloud_client/resources/control.rb new file mode 100644 index 0000000..deeb10b --- /dev/null +++ b/lib/novacloud_client/resources/control.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require_relative "base" +require_relative "../objects/control_result" +require_relative "../objects/queued_request" + +module NovacloudClient + module Resources + # Resource wrapper for control commands. + # + # @example Set player brightness + # client.control.brightness( + # player_ids: ["player-1"], + # brightness: 80, + # notice_url: "https://example.com/callback" + # ) + # + # @example Check a queued request + # request = client.control.reboot(player_ids: ["player-1"], notice_url: callback) + # client.control.request_result(request_id: request.request_id) + class Control < Base + MAX_BATCH = 100 + + # Adjust brightness asynchronously for a batch of players. + # + # @param player_ids [Array] NovaCloud player identifiers (max #{MAX_BATCH}) + # @param brightness [Integer] percentage value between 0 and 100 + # @param notice_url [String] HTTPS callback endpoint for async results + # @return [NovacloudClient::Objects::QueuedRequest] + # @raise [ArgumentError] when validation fails + def brightness(player_ids:, brightness:, notice_url:) + validate_percentage!(brightness, "brightness") + enqueue_control( + endpoint: "/v2/player/real-time-control/brightness", + player_ids: player_ids, + notice_url: notice_url, + extra_payload: { brightness: brightness } + ) + end + + # Adjust volume asynchronously for a batch of players. + # + # @param player_ids [Array] NovaCloud player identifiers (max #{MAX_BATCH}) + # @param volume [Integer] percentage value between 0 and 100 + # @param notice_url [String] HTTPS callback endpoint for async results + # @return [NovacloudClient::Objects::QueuedRequest] + # @raise [ArgumentError] when validation fails + def volume(player_ids:, volume:, notice_url:) + validate_percentage!(volume, "volume") + enqueue_control( + endpoint: "/v2/player/real-time-control/volume", + player_ids: player_ids, + notice_url: notice_url, + extra_payload: { volume: volume } + ) + end + + # Switch the video input source (e.g., HDMI1) for the selected players. + # + # @param player_ids [Array] NovaCloud player identifiers (max #{MAX_BATCH}) + # @param source [String] identifier for the input source defined by NovaCloud (e.g., "HDMI1") + # @param notice_url [String] HTTPS callback endpoint for async results + # @return [NovacloudClient::Objects::QueuedRequest] + # @raise [ArgumentError] when validation fails + def video_source(player_ids:, source:, notice_url:) + validate_presence!(source, "source") + enqueue_control( + endpoint: "/v2/player/real-time-control/video-source", + player_ids: player_ids, + notice_url: notice_url, + extra_payload: { videoSource: source } + ) + end + + # Toggle screen power state for the selected players. + # + # @param player_ids [Array] NovaCloud player identifiers (max #{MAX_BATCH}) + # @param state [Symbol, String, Integer, TrueClass, FalseClass] desired power state + # (:on, :off, true, false, 1, or 0) + # @param notice_url [String] HTTPS callback endpoint for async results + # @return [NovacloudClient::Objects::QueuedRequest] + # @raise [ArgumentError] when validation fails + def screen_power(player_ids:, state:, notice_url:) + payload = { option: normalize_power_state(state) } + enqueue_control( + endpoint: "/v2/player/real-time-control/power", + player_ids: player_ids, + notice_url: notice_url, + extra_payload: payload + ) + end + + # Request a black screen/normal screen toggle for the selected players. + # + # @param player_ids [Array] NovaCloud player identifiers (max #{MAX_BATCH}) + # @param status [String, Symbol] desired screen mode (:open, :close, "OPEN", "CLOSE") + # @param notice_url [String] HTTPS callback endpoint for async results + # @return [NovacloudClient::Objects::QueuedRequest] + # @raise [ArgumentError] when validation fails + def screen_status(player_ids:, status:, notice_url:) + payload = { status: normalize_screen_status(status) } + enqueue_control( + endpoint: "/v2/player/real-time-control/screen-status", + player_ids: player_ids, + notice_url: notice_url, + extra_payload: payload + ) + end + + # Trigger screenshots for the selected players. + # + # @param player_ids [Array] NovaCloud player identifiers (max #{MAX_BATCH}) + # @param notice_url [String] HTTPS callback endpoint receiving screenshot info + # @return [NovacloudClient::Objects::QueuedRequest] + # @raise [ArgumentError] when validation fails + def screenshot(player_ids:, notice_url:) + enqueue_control( + endpoint: "/v2/player/real-time-control/screen-capture", + player_ids: player_ids, + notice_url: notice_url + ) + end + + # Reboot one or more players asynchronously. + # + # @param player_ids [Array] NovaCloud player identifiers (max #{MAX_BATCH}) + # @param notice_url [String] HTTPS callback endpoint for async results + # @return [NovacloudClient::Objects::QueuedRequest] + # @raise [ArgumentError] when validation fails + def reboot(player_ids:, notice_url:) + enqueue_control( + endpoint: "/v2/player/real-time-control/reboot", + player_ids: player_ids, + notice_url: notice_url + ) + end + + # Fetch the aggregated result of a previously queued control request. + # + # @param request_id [String] identifier returned by the queueing endpoints + # @return [NovacloudClient::Objects::ControlResult] + # @raise [ArgumentError] when the request ID is blank + def request_result(request_id:) + raise ArgumentError, "request_id is required" if request_id.to_s.strip.empty? + + response = get("/v2/player/control/request-result", params: { requestId: request_id }) + Objects::ControlResult.new(response) + end + + private + + def validate_percentage!(value, field) + return if value.is_a?(Integer) && value.between?(0, 100) + + raise ArgumentError, "#{field} must be an integer between 0 and 100" + end + + def validate_notice_url!(notice_url) + raise ArgumentError, "notice_url is required" if notice_url.to_s.strip.empty? + end + + def validate_presence!(value, field) + raise ArgumentError, "#{field} is required" if value.to_s.strip.empty? + end + + def enqueue_control(endpoint:, player_ids:, notice_url:, extra_payload: {}) + validate_player_ids!(player_ids, max: MAX_BATCH) + validate_notice_url!(notice_url) + + payload = { playerIds: player_ids } + payload.merge!(extra_payload) + payload[:noticeUrl] = notice_url + + response = post(endpoint, params: payload) + Objects::QueuedRequest.new(response) + end + + def normalize_power_state(state) + case state + when true, :on, "on", "ON", 1 then 1 + when false, :off, "off", "OFF", 0 then 0 + else + raise ArgumentError, "state must be one of :on, :off, true, false, or 0/1" + end + end + + def normalize_screen_status(status) + case status + when :open, "open", "OPEN" then "OPEN" + when :close, "close", "CLOSE", :closed then "CLOSE" + else + raise ArgumentError, "status must be OPEN or CLOSE" + end + end + end + end +end diff --git a/lib/novacloud_client/resources/logs.rb b/lib/novacloud_client/resources/logs.rb new file mode 100644 index 0000000..87b12ad --- /dev/null +++ b/lib/novacloud_client/resources/logs.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require_relative "base" +require_relative "../objects/control_log_entry" + +module NovacloudClient + module Resources + # Resource wrapper for log-related endpoints. + class Logs < Base + # Retrieve remote control execution history for a player. + # + # @param player_id [String] NovaCloud player identifier + # @param start [Integer] pagination offset (defaults to 0) + # @param count [Integer] number of records to request (defaults to 20) + # @param task_type [Integer, nil] optional numeric filter defined by NovaCloud + # @return [Array] + def control_history(player_id:, start: 0, count: 20, task_type: nil) + validate_presence!(player_id, "player_id") + + params = { + "playerId" => player_id, + "start" => start.to_s, + "count" => count.to_s + } + params["taskType"] = task_type.to_s if task_type + + response = get("/v2/logs/remote-control", params: params) + rows = response.fetch("rows", []) + rows.map { |attrs| Objects::ControlLogEntry.new(attrs) } + end + + private + + def validate_presence!(value, field) + raise ArgumentError, "#{field} is required" if value.to_s.strip.empty? + end + end + end +end diff --git a/lib/novacloud_client/resources/players.rb b/lib/novacloud_client/resources/players.rb new file mode 100644 index 0000000..b2f161b --- /dev/null +++ b/lib/novacloud_client/resources/players.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require_relative "base" +require_relative "../objects/player" +require_relative "../objects/player_status" +require_relative "../objects/queued_request" + +module NovacloudClient + module Resources + # Resource wrapper for player-related endpoints. + # + # @example Fetch the first page of configured players + # client.players.list(count: 50) + # + # @example Fetch online status by player IDs + # client.players.statuses(player_ids: ["player-1", "player-2"]) + # + # @example Request running status callbacks + # client.players.running_status( + # player_ids: ["player-1"], + # commands: ["screenshot"], + # notice_url: "https://example.com/callback" + # ) + class Players < Base + MAX_BATCH = 100 + + # Retrieve players with optional pagination and fuzzy name filtering. + # + # @param start [Integer] pagination offset provided by NovaCloud (defaults to 0) + # @param count [Integer] number of records to request (NovaCloud default is 20) + # @param name [String, nil] optional fuzzy match on the player name + # @return [Array] + def list(start: 0, count: 20, name: nil) + params = { start: start, count: count } + params[:name] = name if name + + response = get("/v2/player/list", params: params) + rows = response.fetch("rows", []) + rows.map { |attrs| Objects::Player.new(attrs) } + end + + # Retrieve current online status for a set of players. + # + # @param player_ids [Array, nil] NovaCloud player identifiers (max #{MAX_BATCH}) + # @param player_sns [Array, nil] player serial numbers (max #{MAX_BATCH}) + # @return [Array] + # @raise [ArgumentError] when no identifiers are provided + def statuses(player_ids: nil, player_sns: nil) + payload = build_status_payload(player_ids: player_ids, player_sns: player_sns) + response = post("/v2/player/current/online-status", params: payload) + Array(response).map { |attrs| Objects::PlayerStatus.new(attrs) } + end + + # Enqueue a running-status command and receive a queued request reference. + # + # @param player_ids [Array] NovaCloud player identifiers (max #{MAX_BATCH}) + # @param commands [Array] running status command names defined by NovaCloud + # @param notice_url [String] HTTPS callback endpoint for async results + # @return [NovacloudClient::Objects::QueuedRequest] + # @raise [ArgumentError] when validation fails + def running_status(player_ids:, commands:, notice_url:) + validate_player_ids!(player_ids, max: MAX_BATCH) + raise ArgumentError, "commands cannot be empty" if commands.nil? || commands.empty? + raise ArgumentError, "notice_url is required" if notice_url.to_s.strip.empty? + + payload = { + playerIds: player_ids, + commands: commands, + noticeUrl: notice_url + } + + response = post("/v2/player/current/running-status", params: payload) + Objects::QueuedRequest.new(response) + end + + private + + def build_status_payload(player_ids:, player_sns:) + payload = {} + if player_ids + validate_player_ids!(player_ids, max: MAX_BATCH) + payload[:playerIds] = player_ids + end + if player_sns + validate_player_ids!(player_sns, max: MAX_BATCH) + payload[:playerSns] = player_sns + end + + raise ArgumentError, "provide player_ids or player_sns" if payload.empty? + + payload + end + end + end +end diff --git a/lib/novacloud_client/resources/screens.rb b/lib/novacloud_client/resources/screens.rb new file mode 100644 index 0000000..7fdeb6e --- /dev/null +++ b/lib/novacloud_client/resources/screens.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require_relative "base" +require_relative "../objects/screen" +require_relative "../objects/screen_monitor" +require_relative "../objects/screen_detail" + +module NovacloudClient + module Resources + # Resource wrapper around VNNOXCare screen inventory endpoints. + class Screens < Base + # Retrieve paginated list of screens and monitoring metadata. + # + # @param start [Integer] pagination offset provided by VNNOXCare (defaults to 0) + # @param count [Integer] number of records to request (defaults to 20) + # @param status [Integer, nil] optional status filter defined by VNNOXCare (e.g., 1 for online) + # @return [Array] + def list(start: 0, count: 20, status: nil) + params = { start: start, count: count } + params[:status] = status if status + + response = get("/v2/device-status-monitor/screen/list", params: params) + items = response.fetch("items", []) + items.map { |attrs| Objects::Screen.new(attrs) } + end + + # Retrieve detailed monitoring metrics for a single screen by serial number. + # + # @param sn [String] screen serial number registered in VNNOXCare + # @return [NovacloudClient::Objects::ScreenMonitor] + # @raise [ArgumentError] when serial number is blank + def monitor(serial_number: nil, **kwargs) + serial = serial_number || kwargs[:sn] + validate_presence!(serial, "serial_number") + + response = get("/v2/device-status-monitor/screen/monitor/#{serial}") + Objects::ScreenMonitor.new(response) + end + + # Retrieve deep detail payloads for up to 10 screens at once. + # + # @param sn_list [Array] list of screen serial numbers (max 10) + # @return [Array] + # @raise [ArgumentError] when validation fails + def detail(sn_list:) + validate_sn_list!(sn_list) + + response = post("/v2/device-status-monitor/all", params: { snList: sn_list }) + values = response.fetch("value", []) + values.map { |attrs| Objects::ScreenDetail.new(attrs) } + end + + private + + def validate_sn_list!(sn_list) + validate_presence!(sn_list, "sn_list") + raise ArgumentError, "sn_list cannot be empty" if sn_list.empty? + raise ArgumentError, "maximum 10 screen serial numbers allowed" if sn_list.size > 10 + end + + def validate_presence!(value, field) + raise ArgumentError, "#{field} is required" if value.to_s.strip.empty? + end + end + end +end diff --git a/spec/novacloud_client/resources/control_spec.rb b/spec/novacloud_client/resources/control_spec.rb new file mode 100644 index 0000000..38b40ab --- /dev/null +++ b/spec/novacloud_client/resources/control_spec.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +RSpec.describe NovacloudClient::Resources::Control do + subject(:resource) { described_class.new(client) } + + let(:client) do + NovacloudClient::Client.new( + app_key: "key", + app_secret: "secret", + service_domain: "api.example.com" + ) + end + + describe "#brightness" do + it "validates numeric range" do + expect do + resource.brightness(player_ids: %w[p1], brightness: 150, notice_url: "url") + end.to raise_error(ArgumentError) + end + + it "enqueues a brightness command" do + stub_request(:post, "https://api.example.com/v2/player/real-time-control/brightness") + .with(body: { + playerIds: %w[p1 p2], + brightness: 70, + noticeUrl: "https://callback.example.com" + }.to_json) + .to_return( + status: 200, + body: { "success" => %w[p1], "requestId" => "req-123" }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + result = resource.brightness( + player_ids: %w[p1 p2], + brightness: 70, + notice_url: "https://callback.example.com" + ) + + expect(result).to be_a(NovacloudClient::Objects::QueuedRequest) + expect(result.success_count).to eq(1) + expect(result.request_id).to eq("req-123") + end + end + + describe "#volume" do + it "requires notice URL" do + expect do + resource.volume(player_ids: %w[p1], volume: 50, notice_url: " ") + end.to raise_error(ArgumentError) + end + + it "enqueues a volume command" do + stub_request(:post, "https://api.example.com/v2/player/real-time-control/volume") + .with(body: { + playerIds: %w[p1], + volume: 20, + noticeUrl: "https://callback.example.com" + }.to_json) + .to_return( + status: 200, + body: { "success" => %w[p1], "requestId" => "req-456" }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + result = resource.volume( + player_ids: %w[p1], + volume: 20, + notice_url: "https://callback.example.com" + ) + + expect(result).to be_a(NovacloudClient::Objects::QueuedRequest) + expect(result).to be_all_successful + expect(result.request_id).to eq("req-456") + end + end + + describe "#video_source" do + it "requires source" do + expect do + resource.video_source(player_ids: %w[p1], source: " ", notice_url: "https://callback") + end.to raise_error(ArgumentError) + end + + it "enqueues a video source command" do + stub_request(:post, "https://api.example.com/v2/player/real-time-control/video-source") + .with(body: { + playerIds: %w[p1], + videoSource: "HDMI1", + noticeUrl: "https://callback.example.com" + }.to_json) + .to_return( + status: 200, + body: { "success" => %w[p1], "requestId" => "req-vs" }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + result = resource.video_source( + player_ids: %w[p1], + source: "HDMI1", + notice_url: "https://callback.example.com" + ) + + expect(result).to be_a(NovacloudClient::Objects::QueuedRequest) + expect(result.request_id).to eq("req-vs") + end + end + + describe "#screen_power" do + it "normalizes boolean values" do + stub_request(:post, "https://api.example.com/v2/player/real-time-control/power") + .with(body: { + playerIds: %w[p1], + option: 1, + noticeUrl: "https://callback.example.com" + }.to_json) + .to_return( + status: 200, + body: { "success" => %w[p1], "requestId" => "req-power" }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + result = resource.screen_power( + player_ids: %w[p1], + state: true, + notice_url: "https://callback.example.com" + ) + + expect(result).to be_a(NovacloudClient::Objects::QueuedRequest) + expect(result.request_id).to eq("req-power") + end + + it "raises for unsupported states" do + expect do + resource.screen_power(player_ids: %w[p1], state: :invalid, notice_url: "https://callback") + end.to raise_error(ArgumentError) + end + end + + describe "#screen_status" do + it "enqueues a screen status command" do + stub_request(:post, "https://api.example.com/v2/player/real-time-control/screen-status") + .with(body: { + playerIds: %w[p1], + status: "OPEN", + noticeUrl: "https://callback.example.com" + }.to_json) + .to_return( + status: 200, + body: { "success" => %w[p1], "requestId" => "req-status" }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + result = resource.screen_status( + player_ids: %w[p1], + status: :open, + notice_url: "https://callback.example.com" + ) + + expect(result.request_id).to eq("req-status") + end + + it "validates screen status values" do + expect do + resource.screen_status(player_ids: %w[p1], status: :invalid, notice_url: "https://callback") + end.to raise_error(ArgumentError) + end + end + + describe "#screenshot" do + it "queues screenshot commands" do + stub_request(:post, "https://api.example.com/v2/player/real-time-control/screen-capture") + .with(body: { + playerIds: %w[p1], + noticeUrl: "https://callback.example.com" + }.to_json) + .to_return( + status: 200, + body: { "success" => %w[p1], "requestId" => "req-shot" }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + result = resource.screenshot( + player_ids: %w[p1], + notice_url: "https://callback.example.com" + ) + + expect(result.request_id).to eq("req-shot") + end + end + + describe "#reboot" do + it "enqueues reboot command" do + stub_request(:post, "https://api.example.com/v2/player/real-time-control/reboot") + .with(body: { + playerIds: %w[p1], + noticeUrl: "https://callback.example.com" + }.to_json) + .to_return( + status: 200, + body: { "success" => %w[p1], "requestId" => "req-789" }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + result = resource.reboot( + player_ids: %w[p1], + notice_url: "https://callback.example.com" + ) + + expect(result).to be_a(NovacloudClient::Objects::QueuedRequest) + expect(result).to be_all_successful + expect(result.request_id).to eq("req-789") + end + end + + describe "#request_result" do + it "fetches the request result" do + stub_request(:get, "https://api.example.com/v2/player/control/request-result") + .with(query: { requestId: "123" }) + .to_return( + status: 200, + body: { + "success" => %w[p1], + "fail" => [] + }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + result = resource.request_result(request_id: "123") + + expect(result).to be_a(NovacloudClient::Objects::ControlResult) + expect(result).to be_all_successful + end + + it "requires a request id" do + expect { resource.request_result(request_id: " ") }.to raise_error(ArgumentError) + end + end +end diff --git a/spec/novacloud_client/resources/logs_spec.rb b/spec/novacloud_client/resources/logs_spec.rb new file mode 100644 index 0000000..e6597e2 --- /dev/null +++ b/spec/novacloud_client/resources/logs_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +RSpec.describe NovacloudClient::Resources::Logs do + subject(:resource) { described_class.new(client) } + + let(:client) do + NovacloudClient::Client.new( + app_key: "key", + app_secret: "secret", + service_domain: "api.example.com" + ) + end + + describe "#control_history" do + it "raises when player id is missing" do + expect { resource.control_history(player_id: "") }.to raise_error(ArgumentError) + end + + it "returns log entries" do + stub_request(:get, "https://api.example.com/v2/logs/remote-control") + .with(query: hash_including("playerId" => "p1", "start" => "0", "count" => "20")) + .to_return( + status: 200, + body: { + "rows" => [ + { + "status" => 1, + "executeTime" => "2024-07-02 04:55:48", + "type" => "openScreen" + } + ] + }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + logs = resource.control_history(player_id: "p1") + + expect(logs).to all(be_a(NovacloudClient::Objects::ControlLogEntry)) + expect(logs.first).to be_success + expect(logs.first.execute_time).to be_a(Time) + end + end +end diff --git a/spec/novacloud_client/resources/players_spec.rb b/spec/novacloud_client/resources/players_spec.rb new file mode 100644 index 0000000..517f4ce --- /dev/null +++ b/spec/novacloud_client/resources/players_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +RSpec.describe NovacloudClient::Resources::Players do + subject(:resource) { described_class.new(client) } + + let(:client) do + NovacloudClient::Client.new( + app_key: "key", + app_secret: "secret", + service_domain: "api.example.com" + ) + end + + describe "#list" do + it "returns player objects built from the response" do + stub_request(:get, "https://api.example.com/v2/player/list") + .with(query: { start: 0, count: 20 }) + .to_return( + status: 200, + body: { + "rows" => [ + { + "player_id" => "p1", + "player_type" => 1, + "name" => "Player One", + "online_status" => 1, + "last_online_time" => "2023-01-01T12:00:00Z" + } + ] + }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + players = resource.list + + expect(players).to all(be_a(NovacloudClient::Objects::Player)) + expect(players.first).to have_attributes( + player_id: "p1", + name: "Player One" + ) + expect(players.first).to be_online + end + end + + describe "#statuses" do + it "raises when no ids are provided" do + expect { resource.statuses }.to raise_error(ArgumentError) + end + + it "sends provided player IDs and returns status objects" do + stub_request(:post, "https://api.example.com/v2/player/current/online-status") + .with(body: { playerIds: %w[p1 p2] }.to_json) + .to_return( + status: 200, + body: [ + { "player_id" => "p1", "online_status" => 1 }, + { "player_id" => "p2", "online_status" => 0 } + ].to_json, + headers: { "Content-Type" => "application/json" } + ) + + statuses = resource.statuses(player_ids: %w[p1 p2]) + + expect(statuses).to all(be_a(NovacloudClient::Objects::PlayerStatus)) + expect(statuses.map(&:player_id)).to contain_exactly("p1", "p2") + expect(statuses.count(&:online?)).to eq(1) + end + + it "supports player serial numbers" do + stub_request(:post, "https://api.example.com/v2/player/current/online-status") + .with(body: { playerSns: %w[sn1] }.to_json) + .to_return( + status: 200, + body: [].to_json, + headers: { "Content-Type" => "application/json" } + ) + + expect { resource.statuses(player_sns: %w[sn1]) }.not_to raise_error + end + end + + describe "#running_status" do + let(:payload) do + { + playerIds: %w[p1], + commands: ["screenshot"], + noticeUrl: "https://callback.example.com" + } + end + + it "validates required fields" do + expect do + resource.running_status(player_ids: [], commands: ["cmd"], notice_url: "url") + end.to raise_error(ArgumentError) + + expect do + resource.running_status(player_ids: %w[p1], commands: [], notice_url: "url") + end.to raise_error(ArgumentError) + + expect do + resource.running_status(player_ids: %w[p1], commands: ["cmd"], notice_url: " ") + end.to raise_error(ArgumentError) + end + + it "returns a queued request object" do + stub_request(:post, "https://api.example.com/v2/player/current/running-status") + .with(body: payload.to_json) + .to_return( + status: 200, + body: { "requestId" => "abc" }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + result = resource.running_status( + player_ids: %w[p1], + commands: ["screenshot"], + notice_url: "https://callback.example.com" + ) + + expect(result).to be_a(NovacloudClient::Objects::QueuedRequest) + expect(result.success).to eq([]) + expect(result.request_id).to eq("abc") + end + end +end diff --git a/spec/novacloud_client/resources/screens_spec.rb b/spec/novacloud_client/resources/screens_spec.rb new file mode 100644 index 0000000..243b3e0 --- /dev/null +++ b/spec/novacloud_client/resources/screens_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +RSpec.describe NovacloudClient::Resources::Screens do + subject(:resource) { described_class.new(client) } + + let(:client) do + NovacloudClient::Client.new( + app_key: "key", + app_secret: "secret", + service_domain: "api.example.com" + ) + end + + describe "#list" do + it "returns screen objects built from the response" do + stub_request(:get, "https://api.example.com/v2/device-status-monitor/screen/list") + .with(query: { start: 0, count: 20 }) + .to_return( + status: 200, + body: { + "items" => [ + { + "sid" => 1, + "name" => "Screen A", + "status" => 1, + "camera" => 0, + "brightness" => 80, + "envBrightness" => 1200 + } + ] + }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + screens = resource.list + + expect(screens).to all(be_a(NovacloudClient::Objects::Screen)) + expect(screens.first).to be_online + expect(screens.first).not_to be_camera_enabled + end + + it "supports status filtering" do + stub_request(:get, "https://api.example.com/v2/device-status-monitor/screen/list") + .with(query: { start: 0, count: 20, status: 1 }) + .to_return(status: 200, body: { "items" => [] }.to_json) + + expect { resource.list(status: 1) }.not_to raise_error + end + end + + describe "#monitor" do + it "retrieves single screen monitoring metrics" do + stub_request(:get, "https://api.example.com/v2/device-status-monitor/screen/monitor/SN123") + .to_return( + status: 200, + body: { + "displayDevice" => "LED", + "brightness" => 50, + "envBrightness" => 100, + "height" => 50, + "width" => 100, + "sn" => "SN123" + }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + monitor = resource.monitor(sn: "SN123") + + expect(monitor).to be_a(NovacloudClient::Objects::ScreenMonitor) + expect(monitor.env_brightness).to eq(100) + end + + it "requires an sn" do + expect { resource.monitor(sn: " ") }.to raise_error(ArgumentError) + end + end + + describe "#detail" do + it "returns detailed screen information" do + stub_request(:post, "https://api.example.com/v2/device-status-monitor/all") + .with(body: { snList: %w[SN123] }.to_json) + .to_return( + status: 200, + body: { + "value" => [ + { + "identifier" => "screen-1", + "sn" => "SN123", + "masterControl" => { "status" => true } + } + ] + }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + detail = resource.detail(sn_list: %w[SN123]) + + expect(detail).to all(be_a(NovacloudClient::Objects::ScreenDetail)) + expect(detail.first.master_control).to eq("status" => true) + end + + it "validates sn list size" do + expect do + resource.detail(sn_list: Array.new(11) { |i| "SN#{i}" }) + end.to raise_error(ArgumentError) + end + end +end