From 89f7399e0d9717efc09988697d0a671f0569cfd8 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Thu, 19 Jun 2025 18:47:54 +1000 Subject: [PATCH 1/2] feat: add epiphan pearl driver --- drivers/epiphan/pearl.cr | 170 +++++++++++++++++++++++++++++++ drivers/epiphan/pearl_models.cr | 60 +++++++++++ drivers/epiphan/pearl_spec.cr | 173 ++++++++++++++++++++++++++++++++ 3 files changed, 403 insertions(+) create mode 100644 drivers/epiphan/pearl.cr create mode 100644 drivers/epiphan/pearl_models.cr create mode 100644 drivers/epiphan/pearl_spec.cr diff --git a/drivers/epiphan/pearl.cr b/drivers/epiphan/pearl.cr new file mode 100644 index 00000000000..000a5e1c088 --- /dev/null +++ b/drivers/epiphan/pearl.cr @@ -0,0 +1,170 @@ +# Documentation: https://epiphan-video.github.io/pearl_api_swagger_ui/ +# API Reference: Epiphan Pearl REST API for Pearl-2 and Pearl Mini devices +# Device Models: Pearl-2, Pearl Mini +# Protocol: HTTP/HTTPS REST API with Basic Authentication + +require "placeos-driver" +require "./pearl_models" + +class Epiphan::Pearl < PlaceOS::Driver + descriptive_name "Epiphan Pearl Recording Device" + generic_name :Recording + + uri_base "https://pearl-device.local" + + default_settings({ + basic_auth: { + username: "admin", + password: "admin", + }, + poll_every: 30, + }) + + @poll_every : Int32 = 30 + @recorders = [] of Epiphan::PearlModels::Recorder + + def on_load + on_update + end + + def on_update + @poll_every = setting?(Int32, :poll_every) || 30 + + schedule.clear + schedule.every(@poll_every.seconds) { poll_status } + schedule.in(2.seconds) { poll_status } + end + + def connected + schedule.every(@poll_every.seconds) { poll_status } + schedule.in(2.seconds) { poll_status } + end + + def disconnected + schedule.clear + end + + def list_recorders + response = get("/api/v2.0/recorders") + raise "Failed to get recorders: #{response.status_code}" unless response.success? + + recorders_response = Epiphan::PearlModels::RecordersResponse.from_json(response.body.not_nil!) + raise "API returned error: #{recorders_response.status}" unless recorders_response.status == "ok" + + @recorders = recorders_response.result + self[:recorders] = @recorders + @recorders + end + + def get_recorder_status(recorder_id : String) + response = get("/api/v2.0/recorders/#{recorder_id}/status") + raise "Failed to get recorder status: #{response.status_code}" unless response.success? + + status_response = Epiphan::PearlModels::RecorderStatusResponse.from_json(response.body.not_nil!) + raise "API returned error: #{status_response.status}" unless status_response.status == "ok" + + status = status_response.result + self["recorder_#{recorder_id}_status"] = status + status + end + + def start_recording(recorder_id : String) + response = post("/api/recorders/#{recorder_id}/control/start") + raise "Failed to start recording: #{response.status_code}" unless response.success? + + control_response = Epiphan::PearlModels::ControlResponse.from_json(response.body.not_nil!) + schedule.in(2.seconds) { get_recorder_status(recorder_id) } + control_response.status == "ok" + end + + def stop_recording(recorder_id : String) + response = post("/api/recorders/#{recorder_id}/control/stop") + raise "Failed to stop recording: #{response.status_code}" unless response.success? + + control_response = Epiphan::PearlModels::ControlResponse.from_json(response.body.not_nil!) + schedule.in(2.seconds) { get_recorder_status(recorder_id) } + control_response.status == "ok" + end + + def list_channels + response = get("/api/channels") + raise "Failed to get channels: #{response.status_code}" unless response.success? + + channels_response = Epiphan::PearlModels::ChannelsResponse.from_json(response.body.not_nil!) + raise "API returned error: #{channels_response.status}" unless channels_response.status == "ok" + + channels = channels_response.result + self[:channels] = channels + channels + end + + def get_channel_layouts(channel_id : String) + response = get("/api/channels/#{channel_id}/layouts") + raise "Failed to get layouts: #{response.status_code}" unless response.success? + + layouts_response = Epiphan::PearlModels::LayoutsResponse.from_json(response.body.not_nil!) + raise "API returned error: #{layouts_response.status}" unless layouts_response.status == "ok" + + layouts = layouts_response.result + self["channel_#{channel_id}_layouts"] = layouts + layouts + end + + def start_streaming(channel_id : String, publisher_id : String) + response = post("/api/channels/#{channel_id}/publishers/#{publisher_id}/control/start") + raise "Failed to start streaming: #{response.status_code}" unless response.success? + + control_response = Epiphan::PearlModels::ControlResponse.from_json(response.body.not_nil!) + control_response.status == "ok" + end + + def stop_streaming(channel_id : String, publisher_id : String) + response = post("/api/channels/#{channel_id}/publishers/#{publisher_id}/control/stop") + raise "Failed to start streaming: #{response.status_code}" unless response.success? + + control_response = Epiphan::PearlModels::ControlResponse.from_json(response.body.not_nil!) + control_response.status == "ok" + end + + def is_recording?(recorder_id : String) + status = get_recorder_status(recorder_id) + status.state == "recording" + end + + def get_active_recordings + active = [] of String + + @recorders.each do |recorder| + status = get_recorder_status(recorder.id) + if status.state == "recording" + active << recorder.id + end + end + + self[:active_recordings] = active + active + end + + def stop_all_recordings + results = {} of String => Bool + @recorders.each do |recorder| + if is_recording?(recorder.id) + results[recorder.id] = begin + stop_recording(recorder.id) + rescue + false + end + end + end + results + end + + private def poll_status + begin + list_recorders + get_active_recordings + rescue error + logger.warn(exception: error) { "Error polling device status" } + end + end +end diff --git a/drivers/epiphan/pearl_models.cr b/drivers/epiphan/pearl_models.cr new file mode 100644 index 00000000000..792887c4095 --- /dev/null +++ b/drivers/epiphan/pearl_models.cr @@ -0,0 +1,60 @@ +require "json" + +module Epiphan::PearlModels + # API Response wrapper - all Pearl API responses use this format + class ApiResponse(T) + include JSON::Serializable + + getter status : String + getter result : T + end + + # Represents a recording channel/recorder + class Recorder + include JSON::Serializable + + getter id : String + getter name : String + getter multisource : Bool + end + + # Represents the status of a recorder + class RecorderStatus + include JSON::Serializable + + getter state : String + getter duration : Int64? + getter filename : String? + end + + # Represents a streaming channel + class Channel + include JSON::Serializable + + getter id : String + getter name : String + getter type : String + end + + # Channel layout information + class Layout + include JSON::Serializable + + getter id : String + getter name : String + getter active : Bool + end + + # Control operation response - simple status response + class ControlResponse + include JSON::Serializable + + getter status : String + end + + # Response type aliases for specific endpoints + alias RecordersResponse = ApiResponse(Array(Recorder)) + alias ChannelsResponse = ApiResponse(Array(Channel)) + alias RecorderStatusResponse = ApiResponse(RecorderStatus) + alias LayoutsResponse = ApiResponse(Array(Layout)) +end diff --git a/drivers/epiphan/pearl_spec.cr b/drivers/epiphan/pearl_spec.cr new file mode 100644 index 00000000000..522549676dd --- /dev/null +++ b/drivers/epiphan/pearl_spec.cr @@ -0,0 +1,173 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Epiphan::Pearl" do + settings({ + basic_auth: { + username: "admin", + password: "admin", + }, + poll_every: 30, + }) + + # Test list_recorders functionality with actual API response structure + retval = exec(:list_recorders) + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/v2.0/recorders" + response.status_code = 200 + response << %({ + "status": "ok", + "result": [ + { + "id": "1", + "name": "HDMI-A", + "multisource": false + }, + { + "id": "2", + "name": "HDMI-B", + "multisource": false + }, + { + "id": "3", + "name": "USB-A", + "multisource": false + } + ] + }) + else + response.status_code = 401 + end + end + + retval.get + recorders = status["recorders"]? + recorders.should_not be_nil + + # Test list_channels functionality with actual API response + retval = exec(:list_channels) + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/channels" + response.status_code = 200 + response << %({ + "status": "ok", + "result": [ + { + "id": "4", + "name": "CameraTrackingRegie", + "type": "local" + }, + { + "id": "5", + "name": "CAM1", + "type": "local" + }, + { + "id": "6", + "name": "CAM2", + "type": "local" + } + ] + }) + else + response.status_code = 401 + end + end + + retval.get + channels = status["channels"]? + channels.should_not be_nil + + # Test get_recorder_status functionality + retval = exec(:get_recorder_status, "1") + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/v2.0/recorders/1/status" + response.status_code = 200 + response << %({ + "status": "ok", + "result": { + "state": "stopped", + "duration": 0, + "filename": null + } + }) + else + response.status_code = 401 + end + end + + retval.get + recorder_status = status["recorder_1_status"]? + recorder_status.should_not be_nil + + # Test start_recording functionality + retval = exec(:start_recording, "1") + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/recorders/1/control/start" + response.status_code = 200 + response << %({"status": "ok"}) + else + response.status_code = 401 + end + end + + retval.get.should be_true + + # Test stop_recording functionality + retval = exec(:stop_recording, "1") + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/recorders/1/control/stop" + response.status_code = 200 + response << %({"status": "ok"}) + else + response.status_code = 401 + end + end + + retval.get.should be_true + + # Test get_channel_layouts functionality + retval = exec(:get_channel_layouts, "4") + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/channels/4/layouts" + response.status_code = 200 + response << %({ + "status": "ok", + "result": [ + { + "id": "1", + "name": "Web+Barco+Cams", + "active": false + }, + { + "id": "2", + "name": "Barco+Cams", + "active": false + }, + { + "id": "3", + "name": "Cams", + "active": true + } + ] + }) + else + response.status_code = 401 + end + end + + retval.get + layouts = status["channel_4_layouts"]? + layouts.should_not be_nil +end From 3508c2c7ffff1d399eeb6bae95aa13e59a9a235f Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Fri, 20 Jun 2025 10:40:25 +1000 Subject: [PATCH 2/2] fix errors --- drivers/epiphan/pearl.cr | 155 ++++++++++++++++++++++++++++---- drivers/epiphan/pearl_models.cr | 69 ++++++++++++-- drivers/epiphan/pearl_spec.cr | 134 +++++++++++++++++++++++---- 3 files changed, 319 insertions(+), 39 deletions(-) diff --git a/drivers/epiphan/pearl.cr b/drivers/epiphan/pearl.cr index 000a5e1c088..31b0cf052bf 100644 --- a/drivers/epiphan/pearl.cr +++ b/drivers/epiphan/pearl.cr @@ -9,6 +9,23 @@ require "./pearl_models" class Epiphan::Pearl < PlaceOS::Driver descriptive_name "Epiphan Pearl Recording Device" generic_name :Recording + description <<-DESC + Driver for Epiphan Pearl-2 and Pearl Mini recording/streaming devices. + + Requirements: + - Pearl device must be accessible on the network + - Admin credentials required for API access + - REST API v2.0 must be enabled on the device + + Features: + - Recording control (start/stop/pause/resume) + - Streaming control for channels and publishers + - Channel layout switching + - Active recording/streaming monitoring + - Publisher listing and status + + Based on Epiphan Pearl REST API v2.0 + DESC uri_base "https://pearl-device.local" @@ -23,10 +40,6 @@ class Epiphan::Pearl < PlaceOS::Driver @poll_every : Int32 = 30 @recorders = [] of Epiphan::PearlModels::Recorder - def on_load - on_update - end - def on_update @poll_every = setting?(Int32, :poll_every) || 30 @@ -69,25 +82,27 @@ class Epiphan::Pearl < PlaceOS::Driver end def start_recording(recorder_id : String) - response = post("/api/recorders/#{recorder_id}/control/start") + response = post("/api/v2.0/recorders/#{recorder_id}/control/start") raise "Failed to start recording: #{response.status_code}" unless response.success? control_response = Epiphan::PearlModels::ControlResponse.from_json(response.body.not_nil!) + raise "API returned error: #{control_response.status}" unless control_response.status == "ok" schedule.in(2.seconds) { get_recorder_status(recorder_id) } - control_response.status == "ok" + true end def stop_recording(recorder_id : String) - response = post("/api/recorders/#{recorder_id}/control/stop") + response = post("/api/v2.0/recorders/#{recorder_id}/control/stop") raise "Failed to stop recording: #{response.status_code}" unless response.success? control_response = Epiphan::PearlModels::ControlResponse.from_json(response.body.not_nil!) + raise "API returned error: #{control_response.status}" unless control_response.status == "ok" schedule.in(2.seconds) { get_recorder_status(recorder_id) } - control_response.status == "ok" + true end def list_channels - response = get("/api/channels") + response = get("/api/v2.0/channels") raise "Failed to get channels: #{response.status_code}" unless response.success? channels_response = Epiphan::PearlModels::ChannelsResponse.from_json(response.body.not_nil!) @@ -98,8 +113,21 @@ class Epiphan::Pearl < PlaceOS::Driver channels end + # Channel status endpoint not clearly defined in API spec - commenting out for now + # def get_channel_status(channel_id : String) + # response = get("/api/v2.0/channels/#{channel_id}/status") + # raise "Failed to get channel status: #{response.status_code}" unless response.success? + # + # status_response = Epiphan::PearlModels::ChannelStatusResponse.from_json(response.body.not_nil!) + # raise "API returned error: #{status_response.status}" unless status_response.status == "ok" + # + # status = status_response.result + # self["channel_#{channel_id}_status"] = status + # status + # end + def get_channel_layouts(channel_id : String) - response = get("/api/channels/#{channel_id}/layouts") + response = get("/api/v2.0/channels/#{channel_id}/layouts") raise "Failed to get layouts: #{response.status_code}" unless response.success? layouts_response = Epiphan::PearlModels::LayoutsResponse.from_json(response.body.not_nil!) @@ -111,24 +139,26 @@ class Epiphan::Pearl < PlaceOS::Driver end def start_streaming(channel_id : String, publisher_id : String) - response = post("/api/channels/#{channel_id}/publishers/#{publisher_id}/control/start") + response = post("/api/v2.0/channels/#{channel_id}/publishers/#{publisher_id}/control/start") raise "Failed to start streaming: #{response.status_code}" unless response.success? control_response = Epiphan::PearlModels::ControlResponse.from_json(response.body.not_nil!) - control_response.status == "ok" + raise "API returned error: #{control_response.status}" unless control_response.status == "ok" + true end def stop_streaming(channel_id : String, publisher_id : String) - response = post("/api/channels/#{channel_id}/publishers/#{publisher_id}/control/stop") - raise "Failed to start streaming: #{response.status_code}" unless response.success? + response = post("/api/v2.0/channels/#{channel_id}/publishers/#{publisher_id}/control/stop") + raise "Failed to stop streaming: #{response.status_code}" unless response.success? control_response = Epiphan::PearlModels::ControlResponse.from_json(response.body.not_nil!) - control_response.status == "ok" + raise "API returned error: #{control_response.status}" unless control_response.status == "ok" + true end def is_recording?(recorder_id : String) status = get_recorder_status(recorder_id) - status.state == "recording" + status.state == Epiphan::PearlModels::RecorderState::Started end def get_active_recordings @@ -136,7 +166,7 @@ class Epiphan::Pearl < PlaceOS::Driver @recorders.each do |recorder| status = get_recorder_status(recorder.id) - if status.state == "recording" + if status.state == Epiphan::PearlModels::RecorderState::Started active << recorder.id end end @@ -159,10 +189,101 @@ class Epiphan::Pearl < PlaceOS::Driver results end + def pause_recording(recorder_id : String) + response = post("/api/v2.0/recorders/#{recorder_id}/control/pause") + raise "Failed to pause recording: #{response.status_code}" unless response.success? + + control_response = Epiphan::PearlModels::ControlResponse.from_json(response.body.not_nil!) + raise "API returned error: #{control_response.status}" unless control_response.status == "ok" + schedule.in(2.seconds) { get_recorder_status(recorder_id) } + true + end + + def resume_recording(recorder_id : String) + response = post("/api/v2.0/recorders/#{recorder_id}/control/resume") + raise "Failed to resume recording: #{response.status_code}" unless response.success? + + control_response = Epiphan::PearlModels::ControlResponse.from_json(response.body.not_nil!) + raise "API returned error: #{control_response.status}" unless control_response.status == "ok" + schedule.in(2.seconds) { get_recorder_status(recorder_id) } + true + end + + def get_system_status + response = get("/api/v2.0/system/status") + raise "Failed to get system status: #{response.status_code}" unless response.success? + + status_response = Epiphan::PearlModels::SystemStatusResponse.from_json(response.body.not_nil!) + raise "API returned error: #{status_response.status}" unless status_response.status == "ok" + + status = status_response.result + self[:system_status] = status + status + end + + def list_publishers(channel_id : String) + response = get("/api/v2.0/channels/#{channel_id}/publishers") + raise "Failed to get publishers: #{response.status_code}" unless response.success? + + publishers_response = Epiphan::PearlModels::PublishersResponse.from_json(response.body.not_nil!) + raise "API returned error: #{publishers_response.status}" unless publishers_response.status == "ok" + + publishers = publishers_response.result + self["channel_#{channel_id}_publishers"] = publishers + publishers + end + + def set_channel_layout(channel_id : String, layout_id : String) + body = { + layout_id: layout_id, + }.to_json + + response = put("/api/v2.0/channels/#{channel_id}/set_layout", body: body, headers: {"Content-Type" => "application/json"}) + raise "Failed to set layout: #{response.status_code}" unless response.success? + + control_response = Epiphan::PearlModels::ControlResponse.from_json(response.body.not_nil!) + raise "API returned error: #{control_response.status}" unless control_response.status == "ok" + schedule.in(2.seconds) { get_channel_layouts(channel_id) } + true + end + + def get_active_streamings + active = [] of NamedTuple(channel_id: String, publisher_ids: Array(String)) + + channels = list_channels + channels.each do |channel| + active_publishers = [] of String + publishers = list_publishers(channel.id) + + publishers.each do |publisher| + # Check if this publisher is currently streaming + if publisher.status && publisher.status.try &.state == Epiphan::PearlModels::StreamingState::Started + active_publishers << publisher.id + end + end + + if !active_publishers.empty? + active << {channel_id: channel.id, publisher_ids: active_publishers} + end + end + + self[:active_streamings] = active + active + end + + # Check if a channel has any active streaming publishers + def is_streaming?(channel_id : String) + publishers = list_publishers(channel_id) + publishers.any? { |pub| pub.status && pub.status.try &.state == Epiphan::PearlModels::StreamingState::Started } + end + private def poll_status begin + get_system_status list_recorders get_active_recordings + list_channels + get_active_streamings if @recorders.size > 0 rescue error logger.warn(exception: error) { "Error polling device status" } end diff --git a/drivers/epiphan/pearl_models.cr b/drivers/epiphan/pearl_models.cr index 792887c4095..fcb0a178b19 100644 --- a/drivers/epiphan/pearl_models.cr +++ b/drivers/epiphan/pearl_models.cr @@ -1,6 +1,23 @@ require "json" module Epiphan::PearlModels + # Enum for recorder states + enum RecorderState + Started + Stopped + Paused + Starting + Stopping + end + + # Enum for publisher/streaming states + enum StreamingState + Started + Stopped + Starting + Stopping + end + # API Response wrapper - all Pearl API responses use this format class ApiResponse(T) include JSON::Serializable @@ -22,9 +39,10 @@ module Epiphan::PearlModels class RecorderStatus include JSON::Serializable - getter state : String - getter duration : Int64? - getter filename : String? + getter state : RecorderState + getter duration : Int64? # Duration in seconds (optional) + getter active : String? # Number of active recordings as string (optional) + getter total : String? # Total number of recordings as string (optional) end # Represents a streaming channel @@ -33,16 +51,25 @@ module Epiphan::PearlModels getter id : String getter name : String - getter type : String + getter publishers : Array(Publisher)? + getter encoders : Array(JSON::Any)? + getter active_layout : Layout? end # Channel layout information class Layout include JSON::Serializable + class Sources + include JSON::Serializable + + getter video : Array(JSON::Any)? + getter audio : Array(JSON::Any)? + end + getter id : String getter name : String - getter active : Bool + getter sources : Sources? end # Control operation response - simple status response @@ -52,9 +79,41 @@ module Epiphan::PearlModels getter status : String end + # Publisher status within a channel + class PublisherStatus + include JSON::Serializable + + getter state : StreamingState + end + + # Publisher information + class Publisher + include JSON::Serializable + + getter id : String + getter type : String # "rtmp", "rtsp", "srt", etc. + getter name : String + getter status : PublisherStatus? + getter settings : JSON::Any? # PublisherSettings varies by type + end + + # System status information + class SystemStatus + include JSON::Serializable + + getter cpuload : Int32? # CPU load percentage + getter cpuload_high : Bool? # CPU warning + getter cputemp : Int32? # CPU temperature in Celsius + getter cputemp_threshold : Int32? # High CPU temperature threshold + getter date : Time? # Current system time + getter uptime : Int64? # System uptime in seconds + end + # Response type aliases for specific endpoints alias RecordersResponse = ApiResponse(Array(Recorder)) alias ChannelsResponse = ApiResponse(Array(Channel)) alias RecorderStatusResponse = ApiResponse(RecorderStatus) alias LayoutsResponse = ApiResponse(Array(Layout)) + alias PublishersResponse = ApiResponse(Array(Publisher)) + alias SystemStatusResponse = ApiResponse(SystemStatus) end diff --git a/drivers/epiphan/pearl_spec.cr b/drivers/epiphan/pearl_spec.cr index 522549676dd..b6d26f492d7 100644 --- a/drivers/epiphan/pearl_spec.cr +++ b/drivers/epiphan/pearl_spec.cr @@ -50,25 +50,22 @@ DriverSpecs.mock_driver "Epiphan::Pearl" do expect_http_request do |request, response| headers = request.headers - if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/channels" + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/v2.0/channels" response.status_code = 200 response << %({ "status": "ok", "result": [ { "id": "4", - "name": "CameraTrackingRegie", - "type": "local" + "name": "CameraTrackingRegie" }, { "id": "5", - "name": "CAM1", - "type": "local" + "name": "CAM1" }, { "id": "6", - "name": "CAM2", - "type": "local" + "name": "CAM2" } ] }) @@ -93,7 +90,8 @@ DriverSpecs.mock_driver "Epiphan::Pearl" do "result": { "state": "stopped", "duration": 0, - "filename": null + "active": "0", + "total": "0" } }) else @@ -110,7 +108,7 @@ DriverSpecs.mock_driver "Epiphan::Pearl" do expect_http_request do |request, response| headers = request.headers - if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/recorders/1/control/start" + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/v2.0/recorders/1/control/start" && request.method == "POST" response.status_code = 200 response << %({"status": "ok"}) else @@ -125,7 +123,7 @@ DriverSpecs.mock_driver "Epiphan::Pearl" do expect_http_request do |request, response| headers = request.headers - if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/recorders/1/control/stop" + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/v2.0/recorders/1/control/stop" && request.method == "POST" response.status_code = 200 response << %({"status": "ok"}) else @@ -140,25 +138,22 @@ DriverSpecs.mock_driver "Epiphan::Pearl" do expect_http_request do |request, response| headers = request.headers - if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/channels/4/layouts" + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/v2.0/channels/4/layouts" response.status_code = 200 response << %({ "status": "ok", "result": [ { "id": "1", - "name": "Web+Barco+Cams", - "active": false + "name": "Web+Barco+Cams" }, { "id": "2", - "name": "Barco+Cams", - "active": false + "name": "Barco+Cams" }, { "id": "3", - "name": "Cams", - "active": true + "name": "Cams" } ] }) @@ -170,4 +165,109 @@ DriverSpecs.mock_driver "Epiphan::Pearl" do retval.get layouts = status["channel_4_layouts"]? layouts.should_not be_nil + + # Test pause_recording functionality + retval = exec(:pause_recording, "1") + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/v2.0/recorders/1/control/pause" && request.method == "POST" + response.status_code = 200 + response << %({"status": "ok"}) + else + response.status_code = 401 + end + end + + retval.get.should be_true + + # Test resume_recording functionality + retval = exec(:resume_recording, "1") + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/v2.0/recorders/1/control/resume" && request.method == "POST" + response.status_code = 200 + response << %({"status": "ok"}) + else + response.status_code = 401 + end + end + + retval.get.should be_true + + # Test list_publishers functionality + retval = exec(:list_publishers, "4") + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/v2.0/channels/4/publishers" + response.status_code = 200 + response << %({ + "status": "ok", + "result": [ + { + "id": "1", + "type": "rtmp", + "name": "RTMP Stream" + }, + { + "id": "2", + "type": "hls", + "name": "HLS Stream" + } + ] + }) + else + response.status_code = 401 + end + end + + retval.get + publishers = status["channel_4_publishers"]? + publishers.should_not be_nil + + # Test set_channel_layout functionality + retval = exec(:set_channel_layout, "4", "3") + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && + request.path == "/api/v2.0/channels/4/set_layout" && + request.method == "PUT" + response.status_code = 200 + response << %({"status": "ok"}) + else + response.status_code = 401 + end + end + + retval.get.should be_true + + # Test get_system_status functionality + retval = exec(:get_system_status) + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/v2.0/system/status" + response.status_code = 200 + response << %({ + "status": "ok", + "result": { + "date": "2025-02-14T08:41:09-05:00", + "uptime": 5490, + "cpuload": 25, + "cpuload_high": false, + "cputemp": 57, + "cputemp_threshold": 70 + } + }) + else + response.status_code = 401 + end + end + + retval.get + system_status = status[:system_status]? + system_status.should_not be_nil end