diff --git a/drivers/epiphan/pearl.cr b/drivers/epiphan/pearl.cr new file mode 100644 index 00000000000..31b0cf052bf --- /dev/null +++ b/drivers/epiphan/pearl.cr @@ -0,0 +1,291 @@ +# 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 + 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" + + default_settings({ + basic_auth: { + username: "admin", + password: "admin", + }, + poll_every: 30, + }) + + @poll_every : Int32 = 30 + @recorders = [] of Epiphan::PearlModels::Recorder + + 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/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) } + true + end + + def stop_recording(recorder_id : String) + 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) } + true + end + + def list_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!) + raise "API returned error: #{channels_response.status}" unless channels_response.status == "ok" + + channels = channels_response.result + self[:channels] = channels + 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/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!) + 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/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!) + 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/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!) + 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 == Epiphan::PearlModels::RecorderState::Started + end + + def get_active_recordings + active = [] of String + + @recorders.each do |recorder| + status = get_recorder_status(recorder.id) + if status.state == Epiphan::PearlModels::RecorderState::Started + 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 + + 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 + end +end diff --git a/drivers/epiphan/pearl_models.cr b/drivers/epiphan/pearl_models.cr new file mode 100644 index 00000000000..fcb0a178b19 --- /dev/null +++ b/drivers/epiphan/pearl_models.cr @@ -0,0 +1,119 @@ +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 + + 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 : 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 + class Channel + include JSON::Serializable + + getter id : String + getter name : 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 sources : Sources? + end + + # Control operation response - simple status response + class ControlResponse + include JSON::Serializable + + 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 new file mode 100644 index 00000000000..b6d26f492d7 --- /dev/null +++ b/drivers/epiphan/pearl_spec.cr @@ -0,0 +1,273 @@ +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/v2.0/channels" + response.status_code = 200 + response << %({ + "status": "ok", + "result": [ + { + "id": "4", + "name": "CameraTrackingRegie" + }, + { + "id": "5", + "name": "CAM1" + }, + { + "id": "6", + "name": "CAM2" + } + ] + }) + 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, + "active": "0", + "total": "0" + } + }) + 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/v2.0/recorders/1/control/start" && request.method == "POST" + 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/v2.0/recorders/1/control/stop" && request.method == "POST" + 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/v2.0/channels/4/layouts" + response.status_code = 200 + response << %({ + "status": "ok", + "result": [ + { + "id": "1", + "name": "Web+Barco+Cams" + }, + { + "id": "2", + "name": "Barco+Cams" + }, + { + "id": "3", + "name": "Cams" + } + ] + }) + else + response.status_code = 401 + end + end + + 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