framesmith-cli 0.1.0

CLI tool for controlling Samsung Frame TVs over the local network
use std::net::IpAddr;
use std::path::Path;
use std::time::Duration;

use anyhow::{Context, Result};
use framesmith::{FileAuthTokenStore, FrameTv};

/// Wraps a persistent `FrameTv` connection with reconnection + backoff state.
///
/// The TV connection may be `None` if the initial connection failed or the TV
/// became unreachable. The server keeps running and retries with exponential
/// backoff in either case.
pub struct TvConnection {
    pub tv: Option<FrameTv>,
    host: IpAddr,
    token_file: std::path::PathBuf,
    timeout: Duration,
    pub connected: bool,
    backoff_secs: u64,
}

const MIN_BACKOFF: u64 = 4;
const MAX_BACKOFF: u64 = 32;

impl TvConnection {
    /// Create a new connection, attempting to connect immediately.
    /// Returns a connected `TvConnection` on success.
    pub async fn connect(host: IpAddr, token_file: &Path, timeout_secs: u64) -> Result<Self> {
        let tv = connect_persistent(host, token_file, timeout_secs).await?;
        Ok(Self {
            tv: Some(tv),
            host,
            token_file: token_file.to_owned(),
            timeout: Duration::from_secs(timeout_secs),
            connected: true,
            backoff_secs: MIN_BACKOFF,
        })
    }

    /// Create a `TvConnection` in a disconnected state. The server will
    /// retry connecting via the backoff loop.
    pub fn new_disconnected(host: IpAddr, token_file: &Path, timeout_secs: u64) -> Self {
        Self {
            tv: None,
            host,
            token_file: token_file.to_owned(),
            timeout: Duration::from_secs(timeout_secs),
            connected: false,
            backoff_secs: MIN_BACKOFF,
        }
    }

    pub fn mark_disconnected(&mut self) {
        self.connected = false;
        tracing::warn!("TV connection marked as disconnected, will retry with backoff");
    }

    pub fn backoff_duration(&self) -> Duration {
        Duration::from_secs(self.backoff_secs)
    }

    /// Attempt to reconnect to the TV. If we have an existing `FrameTv`
    /// instance, try `reconnect()` on it. Otherwise, do a full fresh
    /// connection.
    pub async fn attempt_reconnect(&mut self) -> bool {
        tracing::info!(
            "attempting reconnect to {} (backoff {}s)",
            self.host,
            self.backoff_secs
        );

        // If we have an existing FrameTv, try reconnecting it
        if let Some(ref tv) = self.tv {
            match tv.reconnect().await {
                Ok(()) => {
                    tracing::info!("reconnected to {}", self.host);
                    self.connected = true;
                    self.backoff_secs = MIN_BACKOFF;
                    return true;
                }
                Err(e) => {
                    tracing::warn!("reconnect failed: {e:#}");
                }
            }
        }

        // Either no existing FrameTv, or reconnect failed — try a full fresh connection
        match connect_persistent(self.host, &self.token_file, self.timeout.as_secs()).await {
            Ok(tv) => {
                tracing::info!("fresh connection established to {}", self.host);
                self.tv = Some(tv);
                self.connected = true;
                self.backoff_secs = MIN_BACKOFF;
                true
            }
            Err(e) => {
                tracing::warn!("fresh connection failed: {e:#}");
                self.backoff_secs = (self.backoff_secs * 2).min(MAX_BACKOFF);
                false
            }
        }
    }

    /// Send keepalive pings on open WebSocket connections.
    pub async fn send_keepalive(&self) {
        if let Some(ref tv) = self.tv {
            tv.send_keepalive().await;
        }
    }

    pub fn host(&self) -> IpAddr {
        self.host
    }
}

async fn connect_persistent(host: IpAddr, token_file: &Path, timeout_secs: u64) -> Result<FrameTv> {
    if let Some(parent) = token_file.parent() {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("failed to create directory: {}", parent.display()))?;
    }

    let tv = FrameTv::connection_builder(host)
        .auth_token_store(FileAuthTokenStore::new(token_file))
        .connection_timeout(Duration::from_secs(timeout_secs))
        .persistent()
        .connect()
        .await
        .context("failed to connect to TV")?;

    Ok(tv)
}