use std::net::IpAddr;
use std::sync::Arc;
use std::time::Duration;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use futures_util::{SinkExt, StreamExt};
use tokio::sync::Mutex;
use crate::Error;
use crate::frame_tv_transport::FrameTvTransport;
use crate::protocol::art_request::{ArtRequest, ArtRequestParams, ConnInfo};
use crate::protocol::art_response::{ArtResponse, ConnInfoResponse};
use crate::protocol::event;
use crate::protocol::ws_message::{WsIncoming, WsOutgoing};
use crate::transport::tcp_bulk_transfer;
use crate::transport::tls_config;
const WS_PORT: u16 = 8002;
const REMOTE_ENDPOINT: &str = "samsung.remote.control";
const ART_ENDPOINT: &str = "com.samsung.art-app";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
type WsStream =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
pub(crate) struct WsTransport {
host: IpAddr,
token: Arc<Mutex<Option<String>>>,
}
impl WsTransport {
pub(crate) async fn connect(
host: IpAddr,
token: Option<String>,
timeout: Duration,
) -> Result<Self, Error> {
let token = Arc::new(Mutex::new(token));
let ws_url = build_ws_url(host, REMOTE_ENDPOINT, &*token.lock().await);
let connector = tls_config::make_tls_connector();
let (ws_stream, _) = tokio::time::timeout(timeout, async {
tokio_tungstenite::connect_async_tls_with_config(&ws_url, None, false, Some(connector))
.await
})
.await
.map_err(|_| Error::Timeout(timeout))?
.map_err(|e| Error::WebSocket(Box::new(e)))?;
let (_write, mut read) = ws_stream.split();
if let Some(Ok(tokio_tungstenite::tungstenite::Message::Text(text))) = read.next().await
&& let Ok(incoming) = serde_json::from_str::<WsIncoming>(&text)
{
match incoming.event.as_deref() {
Some(event::CHANNEL_UNAUTHORIZED) => {
return Err(Error::Unauthorized);
}
Some(event::CHANNEL_TIMEOUT) => {
return Err(Error::ConnectionFailed(
"TV rejected the connection (ms.channel.timeOut). \
Troubleshooting steps:\n\
1. Your device must be on the same local subnet as the TV \
(e.g. both on 192.168.1.x). The TV rejects WebSocket \
connections from other subnets entirely.\n\
2. Ensure IP Remote is enabled on the TV: \
Settings → Connection → Network → Expert Settings → IP Remote\n\
3. For first-time pairing, the TV screen must be on (not in \
standby or art mode) so the approval popup can appear."
.into(),
));
}
Some(event::CHANNEL_CONNECT) => {
if let Some(data) = &incoming.data
&& let Some(t) = data.get("token").and_then(|v| v.as_str())
{
*token.lock().await = Some(t.to_string());
}
}
_ => {}
}
}
Ok(Self { host, token })
}
pub(crate) async fn get_token(&self) -> Option<String> {
self.token.lock().await.clone()
}
pub(crate) fn connect_from_token(host: IpAddr, token: Option<String>) -> Self {
Self {
host,
token: Arc::new(Mutex::new(token)),
}
}
async fn open_art_connection(&self) -> Result<WsStream, Error> {
let token = self.token.lock().await.clone();
let ws_url = build_ws_url(self.host, ART_ENDPOINT, &token);
let connector = tls_config::make_tls_connector();
let (ws_stream, _) = tokio::time::timeout(DEFAULT_TIMEOUT, async {
tokio_tungstenite::connect_async_tls_with_config(&ws_url, None, false, Some(connector))
.await
})
.await
.map_err(|_| Error::Timeout(DEFAULT_TIMEOUT))?
.map_err(|e| Error::WebSocket(Box::new(e)))?;
Ok(ws_stream)
}
async fn open_remote_connection(&self) -> Result<WsStream, Error> {
let token = self.token.lock().await.clone();
let ws_url = build_ws_url(self.host, REMOTE_ENDPOINT, &token);
let connector = tls_config::make_tls_connector();
let (ws_stream, _) = tokio::time::timeout(DEFAULT_TIMEOUT, async {
tokio_tungstenite::connect_async_tls_with_config(&ws_url, None, false, Some(connector))
.await
})
.await
.map_err(|_| Error::Timeout(DEFAULT_TIMEOUT))?
.map_err(|e| Error::WebSocket(Box::new(e)))?;
Ok(ws_stream)
}
}
async fn wait_for_art_ready(
read: &mut futures_util::stream::SplitStream<WsStream>,
) -> Result<(), Error> {
loop {
match tokio::time::timeout(Duration::from_secs(5), read.next()).await {
Ok(Some(Ok(tokio_tungstenite::tungstenite::Message::Text(text)))) => {
if let Ok(msg) = serde_json::from_str::<WsIncoming>(&text) {
match msg.event.as_deref() {
Some(event::CHANNEL_READY) => return Ok(()),
Some(event::CHANNEL_CONNECT) => continue,
Some(event::CHANNEL_UNAUTHORIZED) => return Err(Error::Unauthorized),
other => {
tracing::debug!("art ready wait: ignoring event {:?}", other);
continue;
}
}
} else {
tracing::debug!("art ready wait: unparseable text: {text}");
}
}
Ok(Some(Ok(other))) => {
tracing::debug!("art ready wait: non-text message: {other:?}");
continue;
}
Ok(Some(Err(e))) => return Err(Error::WebSocket(Box::new(e))),
Ok(None) => return Err(Error::ConnectionFailed("art connection closed".into())),
Err(_) => {
return Err(Error::ConnectionFailed(
"Timed out waiting for art endpoint to become ready. \
The TV must be in art mode (not on the home screen or \
showing other content) for art commands to work."
.into(),
));
}
}
}
}
async fn read_d2d_response(
read: &mut futures_util::stream::SplitStream<WsStream>,
wait_for_event: Option<&str>,
) -> Result<ArtResponse, Error> {
loop {
match tokio::time::timeout(DEFAULT_TIMEOUT, read.next()).await {
Ok(Some(Ok(tokio_tungstenite::tungstenite::Message::Text(text)))) => {
tracing::debug!("d2d read: raw message: {text}");
if let Ok(msg) = serde_json::from_str::<WsIncoming>(&text)
&& msg.event.as_deref() == Some(event::D2D_SERVICE_MESSAGE)
&& let Some(data) = msg.data
{
let data_str = if data.is_string() {
data.as_str().unwrap().to_string()
} else {
data.to_string()
};
if let Ok(art_resp) = serde_json::from_str::<ArtResponse>(&data_str) {
tracing::debug!(
"d2d parsed: event={:?}, error_code={:?}, content_id={:?}",
art_resp.event,
art_resp.error_code,
art_resp.content_id
);
if let Some(ref error_code) = art_resp.error_code {
return Err(Error::TvError {
request: "art_request".to_string(),
code: error_code.to_string(),
});
}
if let Some(wait_event) = wait_for_event {
if art_resp.event.as_deref() == Some(wait_event) {
return Ok(art_resp);
}
continue;
}
return Ok(art_resp);
}
}
}
Ok(Some(Ok(_))) => continue,
Ok(Some(Err(e))) => return Err(Error::WebSocket(Box::new(e))),
Ok(None) => return Err(Error::ConnectionFailed("connection closed".into())),
Err(_) => return Err(Error::Timeout(DEFAULT_TIMEOUT)),
}
}
}
impl FrameTvTransport for WsTransport {
async fn send_art_request(
&self,
request_json: &str,
wait_for_event: Option<&str>,
) -> Result<ArtResponse, Error> {
let ws = self.open_art_connection().await?;
let (mut write, mut read) = ws.split();
wait_for_art_ready(&mut read).await?;
let outgoing = WsOutgoing::art_request(request_json);
let msg_text = serde_json::to_string(&outgoing)?;
write
.send(tokio_tungstenite::tungstenite::Message::Text(
msg_text.into(),
))
.await
.map_err(|e| Error::WebSocket(Box::new(e)))?;
read_d2d_response(&mut read, wait_for_event).await
}
async fn send_remote_key(&self, key_code: &str) -> Result<(), Error> {
let ws = self.open_remote_connection().await?;
let (mut write, mut read) = ws.split();
if let Some(Ok(tokio_tungstenite::tungstenite::Message::Text(text))) = read.next().await
&& let Ok(msg) = serde_json::from_str::<WsIncoming>(&text)
&& msg.event.as_deref() == Some(event::CHANNEL_UNAUTHORIZED)
{
return Err(Error::Unauthorized);
}
let outgoing = WsOutgoing::remote_key_click(key_code);
let msg_text = serde_json::to_string(&outgoing)?;
write
.send(tokio_tungstenite::tungstenite::Message::Text(
msg_text.into(),
))
.await
.map_err(|e| Error::WebSocket(Box::new(e)))?;
Ok(())
}
async fn upload_image(
&self,
image_data: &[u8],
file_type: &str,
matte_id: Option<&str>,
) -> Result<ArtResponse, Error> {
let ws = self.open_art_connection().await?;
let (mut write, mut read) = ws.split();
wait_for_art_ready(&mut read).await?;
let now = chrono_free_date();
let art_id = uuid::Uuid::new_v4().to_string();
let conn_info = ConnInfo::with_id(art_id.clone());
let req = ArtRequest::new_with_id(
art_id,
"send_image",
ArtRequestParams::SendImage {
file_type: file_type.to_string(),
image_date: now,
matte_id: matte_id.unwrap_or("none").to_string(),
portrait_matte_id: "shadowbox_polar".to_string(),
file_size: image_data.len() as u64,
conn_info,
},
);
let req_json = req.to_json()?;
let outgoing = WsOutgoing::art_request(&req_json);
let msg_text = serde_json::to_string(&outgoing)?;
write
.send(tokio_tungstenite::tungstenite::Message::Text(
msg_text.into(),
))
.await
.map_err(|e| Error::WebSocket(Box::new(e)))?;
let conn_resp: ConnInfoResponse = loop {
let art_resp = read_d2d_response(&mut read, Some("ready_to_use")).await?;
if let Some(ref ci) = art_resp.conn_info {
break serde_json::from_str(ci)?;
}
};
let sec_key = conn_resp.key.as_deref().unwrap_or("");
let secured = conn_resp.secured.unwrap_or(false);
tracing::debug!(
"upload conn_info: ip={}, port={}, secured={}, key={:?}",
conn_resp.ip,
conn_resp.port,
secured,
conn_resp.key
);
let _tcp_stream = tcp_bulk_transfer::upload_image(
&conn_resp.ip,
conn_resp.port,
sec_key,
file_type,
image_data,
secured,
)
.await?;
let resp = read_d2d_response(&mut read, Some("image_added")).await?;
drop(_tcp_stream);
Ok(resp)
}
async fn download_thumbnail(&self, content_id: &str) -> Result<Vec<u8>, Error> {
let ws = self.open_art_connection().await?;
let (mut write, mut read) = ws.split();
wait_for_art_ready(&mut read).await?;
let art_id = uuid::Uuid::new_v4().to_string();
let conn_info = ConnInfo::with_id(art_id.clone());
let req = ArtRequest::new_with_id(
art_id,
"get_thumbnail",
ArtRequestParams::GetThumbnail {
content_id: content_id.to_string(),
conn_info,
},
);
let req_json = req.to_json()?;
let outgoing = WsOutgoing::art_request(&req_json);
let msg_text = serde_json::to_string(&outgoing)?;
write
.send(tokio_tungstenite::tungstenite::Message::Text(
msg_text.into(),
))
.await
.map_err(|e| Error::WebSocket(Box::new(e)))?;
let conn_resp: ConnInfoResponse = loop {
let art_resp = read_d2d_response(&mut read, None).await?;
if let Some(ref ci) = art_resp.conn_info {
break serde_json::from_str(ci)?;
}
};
let secured = conn_resp.secured.unwrap_or(false);
tcp_bulk_transfer::download_thumbnail(&conn_resp.ip, conn_resp.port, secured).await
}
async fn tv_display_size(&self) -> Result<(u32, u32), Error> {
Ok((3840, 2160))
}
}
fn build_ws_url(host: IpAddr, endpoint: &str, token: &Option<String>) -> String {
let name = BASE64.encode("framesmith");
let mut url = format!("wss://{host}:{WS_PORT}/api/v2/channels/{endpoint}?name={name}");
if let Some(token) = token {
url.push_str(&format!("&token={token}"));
}
url
}
fn chrono_free_date() -> String {
use std::time::SystemTime;
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
let secs = now.as_secs();
let days = secs / 86400;
let time_of_day = secs % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
let mut year = 1970i64;
let mut remaining_days = days as i64;
loop {
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
if remaining_days < days_in_year {
break;
}
remaining_days -= days_in_year;
year += 1;
}
let month_days = if is_leap_year(year) {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month = 1u32;
for &md in &month_days {
if remaining_days < md {
break;
}
remaining_days -= md;
month += 1;
}
let day = remaining_days + 1;
format!("{year:04}:{month:02}:{day:02} {hours:02}:{minutes:02}:{seconds:02}")
}
fn is_leap_year(year: i64) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}