From 7b660b7e7464e9566633665c69d5041139b32779 Mon Sep 17 00:00:00 2001 From: Chayut Orapinpatipat Date: Sun, 26 Oct 2025 13:45:16 +1100 Subject: [PATCH 1/6] pass1 --- lib/novacloud_client/client.rb | 10 ++ lib/novacloud_client/objects/base.rb | 32 +++++ .../objects/control_result.rb | 46 +++++++ lib/novacloud_client/objects/player.rb | 37 ++++++ lib/novacloud_client/objects/player_status.rb | 29 ++++ .../objects/queued_request.rb | 11 ++ lib/novacloud_client/resources/base.rb | 29 ++++ lib/novacloud_client/resources/control.rb | 76 +++++++++++ lib/novacloud_client/resources/players.rb | 63 +++++++++ .../resources/control_spec.rb | 122 +++++++++++++++++ .../resources/players_spec.rb | 124 ++++++++++++++++++ 11 files changed, 579 insertions(+) create mode 100644 lib/novacloud_client/objects/base.rb create mode 100644 lib/novacloud_client/objects/control_result.rb create mode 100644 lib/novacloud_client/objects/player.rb create mode 100644 lib/novacloud_client/objects/player_status.rb create mode 100644 lib/novacloud_client/objects/queued_request.rb create mode 100644 lib/novacloud_client/resources/base.rb create mode 100644 lib/novacloud_client/resources/control.rb create mode 100644 lib/novacloud_client/resources/players.rb create mode 100644 spec/novacloud_client/resources/control_spec.rb create mode 100644 spec/novacloud_client/resources/players_spec.rb diff --git a/lib/novacloud_client/client.rb b/lib/novacloud_client/client.rb index 2fa6933..20c458d 100644 --- a/lib/novacloud_client/client.rb +++ b/lib/novacloud_client/client.rb @@ -7,6 +7,8 @@ require_relative "configuration" require_relative "middleware/authentication" require_relative "middleware/error_handler" +require_relative "resources/players" +require_relative "resources/control" module NovacloudClient # Central entry point for interacting with the NovaCloud API. @@ -37,6 +39,14 @@ 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 + 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..30c8f00 --- /dev/null +++ b/lib/novacloud_client/objects/base.rb @@ -0,0 +1,32 @@ +# 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| + setter = "#{key}=" + public_send(setter, value) if respond_to?(setter) + 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 + 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..2393f44 --- /dev/null +++ b/lib/novacloud_client/objects/control_result.rb @@ -0,0 +1,46 @@ +# 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 :success, :fail + + def initialize(attributes = {}) + super + @success ||= [] + @fail ||= [] + end + + def success=(value) + @success = Array(value) + end + + def fail=(value) + @fail = Array(value) + end + + def all_successful? + raise.empty? + end + + def partial_success? + !success.empty? && !raise.empty? + end + + def all_failed? + success.empty? + end + + def success_count + success.size + end + + def failure_count + raise.size + 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..4893c2c --- /dev/null +++ b/lib/novacloud_client/objects/queued_request.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require_relative "control_result" + +module NovacloudClient + module Objects + # Represents the enqueue result for asynchronous player commands. + class QueuedRequest < ControlResult + 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..dd20b43 --- /dev/null +++ b/lib/novacloud_client/resources/control.rb @@ -0,0 +1,76 @@ +# 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. + class Control < Base + MAX_BATCH = 100 + + def brightness(player_ids:, brightness:, notice_url:) + validate_player_ids!(player_ids, max: MAX_BATCH) + validate_percentage!(brightness, "brightness") + validate_notice_url!(notice_url) + + payload = { + playerIds: player_ids, + brightness: brightness, + noticeUrl: notice_url + } + + response = post("/v2/player/control/brightness", params: payload) + Objects::QueuedRequest.new(response) + end + + def volume(player_ids:, volume:, notice_url:) + validate_player_ids!(player_ids, max: MAX_BATCH) + validate_percentage!(volume, "volume") + validate_notice_url!(notice_url) + + payload = { + playerIds: player_ids, + volume: volume, + noticeUrl: notice_url + } + + response = post("/v2/player/control/volume", params: payload) + Objects::QueuedRequest.new(response) + end + + def reboot(player_ids:, notice_url:) + validate_player_ids!(player_ids, max: MAX_BATCH) + validate_notice_url!(notice_url) + + payload = { + playerIds: player_ids, + noticeUrl: notice_url + } + + response = post("/v2/player/control/reboot", params: payload) + Objects::QueuedRequest.new(response) + end + + 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 + 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..ba807a7 --- /dev/null +++ b/lib/novacloud_client/resources/players.rb @@ -0,0 +1,63 @@ +# 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. + class Players < Base + MAX_BATCH = 100 + + 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 + + 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 + + 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/spec/novacloud_client/resources/control_spec.rb b/spec/novacloud_client/resources/control_spec.rb new file mode 100644 index 0000000..30f9c16 --- /dev/null +++ b/spec/novacloud_client/resources/control_spec.rb @@ -0,0 +1,122 @@ +# 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/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] }.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) + 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/control/volume") + .with(body: { + playerIds: %w[p1], + volume: 20, + noticeUrl: "https://callback.example.com" + }.to_json) + .to_return( + status: 200, + body: { "success" => %w[p1] }.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 + end + end + + describe "#reboot" do + it "enqueues reboot command" do + stub_request(:post, "https://api.example.com/v2/player/control/reboot") + .with(body: { + playerIds: %w[p1], + noticeUrl: "https://callback.example.com" + }.to_json) + .to_return( + status: 200, + body: { "success" => %w[p1] }.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 + 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/players_spec.rb b/spec/novacloud_client/resources/players_spec.rb new file mode 100644 index 0000000..401a87e --- /dev/null +++ b/spec/novacloud_client/resources/players_spec.rb @@ -0,0 +1,124 @@ +# 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: { "request_id" => "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([]) + end + end +end From 5eb0d5b02ee347e66787289c2a6ca302364b9bb5 Mon Sep 17 00:00:00 2001 From: Chayut Orapinpatipat Date: Sun, 26 Oct 2025 13:51:24 +1100 Subject: [PATCH 2/6] docs2 --- .rubocop.yml | 3 ++ .rubocop_todo.yml | 2 ++ README.md | 28 ++++++++++++++---- lib/novacloud_client/resources/control.rb | 36 +++++++++++++++++++++++ lib/novacloud_client/resources/players.rb | 32 ++++++++++++++++++++ 5 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 .rubocop_todo.yml 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..bda688b --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,2 @@ +Metrics/MethodLength: + Max: 20 diff --git a/README.md b/README.md index f2dcbcd..e103fa0 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ Sprint 01 delivered the core HTTP client for the NovaCloud Open Platform. The ge - 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`). + ## Quick Start ```ruby @@ -18,11 +20,19 @@ 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_ID_FROM_NOTICE") +puts result.all_successful? ``` ### Development @@ -33,4 +43,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/resources/control.rb b/lib/novacloud_client/resources/control.rb index dd20b43..72ee0cf 100644 --- a/lib/novacloud_client/resources/control.rb +++ b/lib/novacloud_client/resources/control.rb @@ -7,9 +7,27 @@ 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_player_ids!(player_ids, max: MAX_BATCH) validate_percentage!(brightness, "brightness") @@ -25,6 +43,13 @@ def brightness(player_ids:, brightness:, notice_url:) Objects::QueuedRequest.new(response) 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_player_ids!(player_ids, max: MAX_BATCH) validate_percentage!(volume, "volume") @@ -40,6 +65,12 @@ def volume(player_ids:, volume:, notice_url:) Objects::QueuedRequest.new(response) 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:) validate_player_ids!(player_ids, max: MAX_BATCH) validate_notice_url!(notice_url) @@ -53,6 +84,11 @@ def reboot(player_ids:, notice_url:) Objects::QueuedRequest.new(response) 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? diff --git a/lib/novacloud_client/resources/players.rb b/lib/novacloud_client/resources/players.rb index ba807a7..b2f161b 100644 --- a/lib/novacloud_client/resources/players.rb +++ b/lib/novacloud_client/resources/players.rb @@ -8,9 +8,28 @@ 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 @@ -20,12 +39,25 @@ def list(start: 0, count: 20, name: nil) 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? From 974bbee235fdc26ef3924f6220c0834d00d31d7a Mon Sep 17 00:00:00 2001 From: Chayut Orapinpatipat Date: Sun, 26 Oct 2025 14:01:00 +1100 Subject: [PATCH 3/6] passs 3 --- README.md | 2 +- .../objects/control_result.rb | 29 ++++++++++++------- .../objects/queued_request.rb | 11 +++++++ .../resources/control_spec.rb | 15 ++++++---- .../resources/players_spec.rb | 5 ++-- 5 files changed, 43 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index e103fa0..1a35386 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ request = client.control.brightness( notice_url: "https://example.com/callback" ) -result = client.control.request_result(request_id: "REQUEST_ID_FROM_NOTICE") +result = client.control.request_result(request_id: request.request_id) puts result.all_successful? ``` diff --git a/lib/novacloud_client/objects/control_result.rb b/lib/novacloud_client/objects/control_result.rb index 2393f44..e4a8ac4 100644 --- a/lib/novacloud_client/objects/control_result.rb +++ b/lib/novacloud_client/objects/control_result.rb @@ -6,40 +6,49 @@ module NovacloudClient module Objects # Represents the result of a control or queued request returning success/fail lists. class ControlResult < Base - attr_reader :success, :fail + attr_reader :successes, :failures def initialize(attributes = {}) + @successes = [] + @failures = [] super - @success ||= [] - @fail ||= [] end def success=(value) - @success = Array(value) + @successes = Array(value) end def fail=(value) - @fail = Array(value) + @failures = Array(value) end def all_successful? - raise.empty? + failures.empty? end def partial_success? - !success.empty? && !raise.empty? + successes.any? && failures.any? end def all_failed? - success.empty? + successes.empty? end def success_count - success.size + successes.size end def failure_count - raise.size + failures.size + end + + # Maintain API compatibility with camelCase keys. + def success + successes + end + + def fail + failures end end end diff --git a/lib/novacloud_client/objects/queued_request.rb b/lib/novacloud_client/objects/queued_request.rb index 4893c2c..020784b 100644 --- a/lib/novacloud_client/objects/queued_request.rb +++ b/lib/novacloud_client/objects/queued_request.rb @@ -6,6 +6,17 @@ 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 + + # Handles camelCase keys returned by the API without additional mapping. + def requestId=(value) + self.request_id = value + end end end end diff --git a/spec/novacloud_client/resources/control_spec.rb b/spec/novacloud_client/resources/control_spec.rb index 30f9c16..234d377 100644 --- a/spec/novacloud_client/resources/control_spec.rb +++ b/spec/novacloud_client/resources/control_spec.rb @@ -27,7 +27,7 @@ }.to_json) .to_return( status: 200, - body: { "success" => %w[p1] }.to_json, + body: { "success" => %w[p1], "requestId" => "req-123" }.to_json, headers: { "Content-Type" => "application/json" } ) @@ -38,7 +38,8 @@ ) expect(result).to be_a(NovacloudClient::Objects::QueuedRequest) - expect(result.success_count).to eq(1) + expect(result.success_count).to eq(1) + expect(result.request_id).to eq("req-123") end end @@ -58,7 +59,7 @@ }.to_json) .to_return( status: 200, - body: { "success" => %w[p1] }.to_json, + body: { "success" => %w[p1], "requestId" => "req-456" }.to_json, headers: { "Content-Type" => "application/json" } ) @@ -69,7 +70,8 @@ ) expect(result).to be_a(NovacloudClient::Objects::QueuedRequest) - expect(result).to be_all_successful + expect(result).to be_all_successful + expect(result.request_id).to eq("req-456") end end @@ -82,7 +84,7 @@ }.to_json) .to_return( status: 200, - body: { "success" => %w[p1] }.to_json, + body: { "success" => %w[p1], "requestId" => "req-789" }.to_json, headers: { "Content-Type" => "application/json" } ) @@ -92,7 +94,8 @@ ) expect(result).to be_a(NovacloudClient::Objects::QueuedRequest) - expect(result).to be_all_successful + expect(result).to be_all_successful + expect(result.request_id).to eq("req-789") end end diff --git a/spec/novacloud_client/resources/players_spec.rb b/spec/novacloud_client/resources/players_spec.rb index 401a87e..5fa6c89 100644 --- a/spec/novacloud_client/resources/players_spec.rb +++ b/spec/novacloud_client/resources/players_spec.rb @@ -107,7 +107,7 @@ .with(body: payload.to_json) .to_return( status: 200, - body: { "request_id" => "abc" }.to_json, + body: { "requestId" => "abc" }.to_json, headers: { "Content-Type" => "application/json" } ) @@ -118,7 +118,8 @@ ) expect(result).to be_a(NovacloudClient::Objects::QueuedRequest) - expect(result.success).to eq([]) + expect(result.success).to eq([]) + expect(result.request_id).to eq("abc") end end end From a2d74a503c80189e68256e77e7b3896560cc57ca Mon Sep 17 00:00:00 2001 From: Chayut Orapinpatipat Date: Sun, 26 Oct 2025 14:49:07 +1100 Subject: [PATCH 4/6] pass 2 --- README.md | 12 +- lib/novacloud_client/client.rb | 10 ++ .../objects/control_log_entry.rb | 25 ++++ lib/novacloud_client/objects/screen.rb | 29 ++++ lib/novacloud_client/objects/screen_detail.rb | 44 ++++++ .../objects/screen_monitor.rb | 16 +++ lib/novacloud_client/resources/control.rb | 114 ++++++++++++++- lib/novacloud_client/resources/logs.rb | 39 ++++++ lib/novacloud_client/resources/screens.rb | 65 +++++++++ .../resources/control_spec.rb | 132 ++++++++++++++++-- spec/novacloud_client/resources/logs_spec.rb | 43 ++++++ .../resources/players_spec.rb | 4 +- .../resources/screens_spec.rb | 108 ++++++++++++++ 13 files changed, 626 insertions(+), 15 deletions(-) create mode 100644 lib/novacloud_client/objects/control_log_entry.rb create mode 100644 lib/novacloud_client/objects/screen.rb create mode 100644 lib/novacloud_client/objects/screen_detail.rb create mode 100644 lib/novacloud_client/objects/screen_monitor.rb create mode 100644 lib/novacloud_client/resources/logs.rb create mode 100644 lib/novacloud_client/resources/screens.rb create mode 100644 spec/novacloud_client/resources/logs_spec.rb create mode 100644 spec/novacloud_client/resources/screens_spec.rb diff --git a/README.md b/README.md index 1a35386..1450c5d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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. @@ -9,6 +9,13 @@ Sprint 01 delivered the core HTTP client for the NovaCloud Open Platform. The ge 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 @@ -33,6 +40,9 @@ request = client.control.brightness( 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 diff --git a/lib/novacloud_client/client.rb b/lib/novacloud_client/client.rb index 20c458d..d6587f8 100644 --- a/lib/novacloud_client/client.rb +++ b/lib/novacloud_client/client.rb @@ -9,6 +9,8 @@ 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. @@ -47,6 +49,14 @@ 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/control_log_entry.rb b/lib/novacloud_client/objects/control_log_entry.rb new file mode 100644 index 0000000..0082d24 --- /dev/null +++ b/lib/novacloud_client/objects/control_log_entry.rb @@ -0,0 +1,25 @@ +# 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 + + def executeTime=(value) + self.execute_time = value + 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..d4d7c5a --- /dev/null +++ b/lib/novacloud_client/objects/screen.rb @@ -0,0 +1,29 @@ +# 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 + + def envBrightness=(value) + self.env_brightness = value + end + + def screenStatus=(value) + self.status = value + 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..928e5ff --- /dev/null +++ b/lib/novacloud_client/objects/screen_detail.rb @@ -0,0 +1,44 @@ +# 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 + + def initialize(attributes = {}) + super + @input_source ||= {} + @master_control ||= {} + @module ||= {} + @monitor_card ||= {} + @receiving_card ||= {} + @screen ||= {} + @smart_module ||= {} + end + + def inputSource=(value) + self.input_source = value + end + + def masterControl=(value) + self.master_control = value + end + + def monitorCard=(value) + self.monitor_card = value + end + + def receivingCard=(value) + self.receiving_card = value + end + + def smartModule=(value) + self.smart_module = value + 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..af71b14 --- /dev/null +++ b/lib/novacloud_client/objects/screen_monitor.rb @@ -0,0 +1,16 @@ +# 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 + + def envBrightness=(value) + self.env_brightness = value + end + end + end +end diff --git a/lib/novacloud_client/resources/control.rb b/lib/novacloud_client/resources/control.rb index 72ee0cf..1de3a08 100644 --- a/lib/novacloud_client/resources/control.rb +++ b/lib/novacloud_client/resources/control.rb @@ -39,7 +39,7 @@ def brightness(player_ids:, brightness:, notice_url:) noticeUrl: notice_url } - response = post("/v2/player/control/brightness", params: payload) + response = post("/v2/player/real-time-control/brightness", params: payload) Objects::QueuedRequest.new(response) end @@ -61,7 +61,93 @@ def volume(player_ids:, volume:, notice_url:) noticeUrl: notice_url } - response = post("/v2/player/control/volume", params: payload) + response = post("/v2/player/real-time-control/volume", params: payload) + Objects::QueuedRequest.new(response) + 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_player_ids!(player_ids, max: MAX_BATCH) + validate_presence!(source, "source") + validate_notice_url!(notice_url) + + payload = { + playerIds: player_ids, + videoSource: source, + noticeUrl: notice_url + } + + response = post("/v2/player/real-time-control/video-source", params: payload) + Objects::QueuedRequest.new(response) + 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:) + validate_player_ids!(player_ids, max: MAX_BATCH) + normalized_state = normalize_power_state(state) + validate_notice_url!(notice_url) + + payload = { + playerIds: player_ids, + option: normalized_state, + noticeUrl: notice_url + } + + response = post("/v2/player/real-time-control/power", params: payload) + Objects::QueuedRequest.new(response) + 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:) + validate_player_ids!(player_ids, max: MAX_BATCH) + normalized_status = normalize_screen_status(status) + validate_notice_url!(notice_url) + + payload = { + playerIds: player_ids, + status: normalized_status, + noticeUrl: notice_url + } + + response = post("/v2/player/real-time-control/screen-status", params: payload) + Objects::QueuedRequest.new(response) + 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:) + validate_player_ids!(player_ids, max: MAX_BATCH) + validate_notice_url!(notice_url) + + payload = { + playerIds: player_ids, + noticeUrl: notice_url + } + + response = post("/v2/player/real-time-control/screen-capture", params: payload) Objects::QueuedRequest.new(response) end @@ -80,7 +166,7 @@ def reboot(player_ids:, notice_url:) noticeUrl: notice_url } - response = post("/v2/player/control/reboot", params: payload) + response = post("/v2/player/real-time-control/reboot", params: payload) Objects::QueuedRequest.new(response) end @@ -107,6 +193,28 @@ def validate_percentage!(value, field) 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 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/screens.rb b/lib/novacloud_client/resources/screens.rb new file mode 100644 index 0000000..f5b4370 --- /dev/null +++ b/lib/novacloud_client/resources/screens.rb @@ -0,0 +1,65 @@ +# 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(sn:) + validate_presence!(sn, "sn") + + response = get("/v2/device-status-monitor/screen/monitor/#{sn}") + 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 index 234d377..38b40ab 100644 --- a/spec/novacloud_client/resources/control_spec.rb +++ b/spec/novacloud_client/resources/control_spec.rb @@ -19,7 +19,7 @@ end it "enqueues a brightness command" do - stub_request(:post, "https://api.example.com/v2/player/control/brightness") + stub_request(:post, "https://api.example.com/v2/player/real-time-control/brightness") .with(body: { playerIds: %w[p1 p2], brightness: 70, @@ -38,8 +38,8 @@ ) expect(result).to be_a(NovacloudClient::Objects::QueuedRequest) - expect(result.success_count).to eq(1) - expect(result.request_id).to eq("req-123") + expect(result.success_count).to eq(1) + expect(result.request_id).to eq("req-123") end end @@ -51,7 +51,7 @@ end it "enqueues a volume command" do - stub_request(:post, "https://api.example.com/v2/player/control/volume") + stub_request(:post, "https://api.example.com/v2/player/real-time-control/volume") .with(body: { playerIds: %w[p1], volume: 20, @@ -70,14 +70,128 @@ ) expect(result).to be_a(NovacloudClient::Objects::QueuedRequest) - expect(result).to be_all_successful - expect(result.request_id).to eq("req-456") + 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/control/reboot") + stub_request(:post, "https://api.example.com/v2/player/real-time-control/reboot") .with(body: { playerIds: %w[p1], noticeUrl: "https://callback.example.com" @@ -94,8 +208,8 @@ ) expect(result).to be_a(NovacloudClient::Objects::QueuedRequest) - expect(result).to be_all_successful - expect(result.request_id).to eq("req-789") + expect(result).to be_all_successful + expect(result.request_id).to eq("req-789") 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 index 5fa6c89..517f4ce 100644 --- a/spec/novacloud_client/resources/players_spec.rb +++ b/spec/novacloud_client/resources/players_spec.rb @@ -118,8 +118,8 @@ ) expect(result).to be_a(NovacloudClient::Objects::QueuedRequest) - expect(result.success).to eq([]) - expect(result.request_id).to eq("abc") + 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 From ae2d11966fe744ee97345ef45182ea69edf3586f Mon Sep 17 00:00:00 2001 From: Chayut Orapinpatipat Date: Sun, 26 Oct 2025 15:05:31 +1100 Subject: [PATCH 5/6] spec --- lib/novacloud_client/objects/base.rb | 21 ++++++++++-- .../objects/control_log_entry.rb | 4 --- .../objects/queued_request.rb | 5 --- lib/novacloud_client/objects/screen.rb | 8 ----- lib/novacloud_client/objects/screen_detail.rb | 33 +++++-------------- .../objects/screen_monitor.rb | 4 --- 6 files changed, 28 insertions(+), 47 deletions(-) diff --git a/lib/novacloud_client/objects/base.rb b/lib/novacloud_client/objects/base.rb index 30c8f00..8dae91d 100644 --- a/lib/novacloud_client/objects/base.rb +++ b/lib/novacloud_client/objects/base.rb @@ -14,8 +14,16 @@ def initialize(attributes = {}) def assign_attributes(attributes) attributes.each do |key, value| - setter = "#{key}=" - public_send(setter, value) if respond_to?(setter) + 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 @@ -27,6 +35,15 @@ def parse_timestamp(value) 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 index 0082d24..8ca994e 100644 --- a/lib/novacloud_client/objects/control_log_entry.rb +++ b/lib/novacloud_client/objects/control_log_entry.rb @@ -16,10 +16,6 @@ def execute_time=(value) def success? status.to_i.zero? ? false : status.to_i == 1 end - - def executeTime=(value) - self.execute_time = value - end end end end diff --git a/lib/novacloud_client/objects/queued_request.rb b/lib/novacloud_client/objects/queued_request.rb index 020784b..d718fd5 100644 --- a/lib/novacloud_client/objects/queued_request.rb +++ b/lib/novacloud_client/objects/queued_request.rb @@ -12,11 +12,6 @@ class QueuedRequest < ControlResult def request_id=(value) @request_id = value&.to_s end - - # Handles camelCase keys returned by the API without additional mapping. - def requestId=(value) - self.request_id = value - end end end end diff --git a/lib/novacloud_client/objects/screen.rb b/lib/novacloud_client/objects/screen.rb index d4d7c5a..8180687 100644 --- a/lib/novacloud_client/objects/screen.rb +++ b/lib/novacloud_client/objects/screen.rb @@ -16,14 +16,6 @@ def online? def camera_enabled? camera.to_i == 1 end - - def envBrightness=(value) - self.env_brightness = value - end - - def screenStatus=(value) - self.status = value - end end end end diff --git a/lib/novacloud_client/objects/screen_detail.rb b/lib/novacloud_client/objects/screen_detail.rb index 928e5ff..7626f1c 100644 --- a/lib/novacloud_client/objects/screen_detail.rb +++ b/lib/novacloud_client/objects/screen_detail.rb @@ -9,35 +9,20 @@ 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 - @input_source ||= {} - @master_control ||= {} - @module ||= {} - @monitor_card ||= {} - @receiving_card ||= {} - @screen ||= {} - @smart_module ||= {} - end - - def inputSource=(value) - self.input_source = value - end - - def masterControl=(value) - self.master_control = value - end - - def monitorCard=(value) - self.monitor_card = value + ensure_nested_hashes! end - def receivingCard=(value) - self.receiving_card = value - end + private - def smartModule=(value) - self.smart_module = value + def ensure_nested_hashes! + NESTED_HASH_FIELDS.each do |field| + value = public_send(field) + public_send("#{field}=", value || {}) + end end end end diff --git a/lib/novacloud_client/objects/screen_monitor.rb b/lib/novacloud_client/objects/screen_monitor.rb index af71b14..9f76dee 100644 --- a/lib/novacloud_client/objects/screen_monitor.rb +++ b/lib/novacloud_client/objects/screen_monitor.rb @@ -7,10 +7,6 @@ module Objects # Represents high-level monitoring metrics for a screen device. class ScreenMonitor < Base attr_accessor :display_device, :brightness, :env_brightness, :height, :width, :sn - - def envBrightness=(value) - self.env_brightness = value - end end end end From 56e64a5fba7d6a8a595bf2ebf062133a9a612c96 Mon Sep 17 00:00:00 2001 From: Chayut Orapinpatipat Date: Sun, 26 Oct 2025 15:22:34 +1100 Subject: [PATCH 6/6] spec --- .rubocop_todo.yml | 3 + lib/novacloud_client/resources/control.rb | 131 +++++++++------------- lib/novacloud_client/resources/screens.rb | 7 +- 3 files changed, 61 insertions(+), 80 deletions(-) diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index bda688b..8980c02 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,2 +1,5 @@ Metrics/MethodLength: Max: 20 + +Metrics/ClassLength: + Max: 200 \ No newline at end of file diff --git a/lib/novacloud_client/resources/control.rb b/lib/novacloud_client/resources/control.rb index 1de3a08..deeb10b 100644 --- a/lib/novacloud_client/resources/control.rb +++ b/lib/novacloud_client/resources/control.rb @@ -29,18 +29,13 @@ class Control < Base # @return [NovacloudClient::Objects::QueuedRequest] # @raise [ArgumentError] when validation fails def brightness(player_ids:, brightness:, notice_url:) - validate_player_ids!(player_ids, max: MAX_BATCH) validate_percentage!(brightness, "brightness") - validate_notice_url!(notice_url) - - payload = { - playerIds: player_ids, - brightness: brightness, - noticeUrl: notice_url - } - - response = post("/v2/player/real-time-control/brightness", params: payload) - Objects::QueuedRequest.new(response) + 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. @@ -51,18 +46,13 @@ def brightness(player_ids:, brightness:, notice_url:) # @return [NovacloudClient::Objects::QueuedRequest] # @raise [ArgumentError] when validation fails def volume(player_ids:, volume:, notice_url:) - validate_player_ids!(player_ids, max: MAX_BATCH) validate_percentage!(volume, "volume") - validate_notice_url!(notice_url) - - payload = { - playerIds: player_ids, - volume: volume, - noticeUrl: notice_url - } - - response = post("/v2/player/real-time-control/volume", params: payload) - Objects::QueuedRequest.new(response) + 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. @@ -73,18 +63,13 @@ def volume(player_ids:, volume:, notice_url:) # @return [NovacloudClient::Objects::QueuedRequest] # @raise [ArgumentError] when validation fails def video_source(player_ids:, source:, notice_url:) - validate_player_ids!(player_ids, max: MAX_BATCH) validate_presence!(source, "source") - validate_notice_url!(notice_url) - - payload = { - playerIds: player_ids, - videoSource: source, - noticeUrl: notice_url - } - - response = post("/v2/player/real-time-control/video-source", params: payload) - Objects::QueuedRequest.new(response) + 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. @@ -96,18 +81,13 @@ def video_source(player_ids:, source:, notice_url:) # @return [NovacloudClient::Objects::QueuedRequest] # @raise [ArgumentError] when validation fails def screen_power(player_ids:, state:, notice_url:) - validate_player_ids!(player_ids, max: MAX_BATCH) - normalized_state = normalize_power_state(state) - validate_notice_url!(notice_url) - - payload = { - playerIds: player_ids, - option: normalized_state, - noticeUrl: notice_url - } - - response = post("/v2/player/real-time-control/power", params: payload) - Objects::QueuedRequest.new(response) + 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. @@ -118,18 +98,13 @@ def screen_power(player_ids:, state:, notice_url:) # @return [NovacloudClient::Objects::QueuedRequest] # @raise [ArgumentError] when validation fails def screen_status(player_ids:, status:, notice_url:) - validate_player_ids!(player_ids, max: MAX_BATCH) - normalized_status = normalize_screen_status(status) - validate_notice_url!(notice_url) - - payload = { - playerIds: player_ids, - status: normalized_status, - noticeUrl: notice_url - } - - response = post("/v2/player/real-time-control/screen-status", params: payload) - Objects::QueuedRequest.new(response) + 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. @@ -139,16 +114,11 @@ def screen_status(player_ids:, status:, notice_url:) # @return [NovacloudClient::Objects::QueuedRequest] # @raise [ArgumentError] when validation fails def screenshot(player_ids:, notice_url:) - validate_player_ids!(player_ids, max: MAX_BATCH) - validate_notice_url!(notice_url) - - payload = { - playerIds: player_ids, - noticeUrl: notice_url - } - - response = post("/v2/player/real-time-control/screen-capture", params: payload) - Objects::QueuedRequest.new(response) + 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. @@ -158,16 +128,11 @@ def screenshot(player_ids:, notice_url:) # @return [NovacloudClient::Objects::QueuedRequest] # @raise [ArgumentError] when validation fails def reboot(player_ids:, notice_url:) - validate_player_ids!(player_ids, max: MAX_BATCH) - validate_notice_url!(notice_url) - - payload = { - playerIds: player_ids, - noticeUrl: notice_url - } - - response = post("/v2/player/real-time-control/reboot", params: payload) - Objects::QueuedRequest.new(response) + 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. @@ -198,6 +163,18 @@ 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 diff --git a/lib/novacloud_client/resources/screens.rb b/lib/novacloud_client/resources/screens.rb index f5b4370..7fdeb6e 100644 --- a/lib/novacloud_client/resources/screens.rb +++ b/lib/novacloud_client/resources/screens.rb @@ -29,10 +29,11 @@ def list(start: 0, count: 20, status: nil) # @param sn [String] screen serial number registered in VNNOXCare # @return [NovacloudClient::Objects::ScreenMonitor] # @raise [ArgumentError] when serial number is blank - def monitor(sn:) - validate_presence!(sn, "sn") + def monitor(serial_number: nil, **kwargs) + serial = serial_number || kwargs[:sn] + validate_presence!(serial, "serial_number") - response = get("/v2/device-status-monitor/screen/monitor/#{sn}") + response = get("/v2/device-status-monitor/screen/monitor/#{serial}") Objects::ScreenMonitor.new(response) end