Asynchronous control manager for LG TVs.
LgTvManager
manages an asynchronous interface to LG TVs supporting the webOS SSAP WebSocket
protocol.
- UPnP discovery of LG TVs.
- Connect to TV using discovered UPnP device details or host IP.
- Connection pairing.
- Sending LG commands to the connected TV.
- Details on the connected TV (software versions, inputs, etc).
- Automatic reconnects.
- Wake-on-LAN support.
- Persists last-connected TV details between sessions.
LgTvManager
has only been tested against a small number of LG TVs, and different TVs sometimes
behave differently (e.g. the SSAP payload shapes sometimes differ). As a result, LgTvManager
is
not guaranteed to work with all TVs.
An LgTvManager
instance:
- Handles the WebSocket connection to an LG TV, including pairing.
- Accepts
ManagerMessage
messages from the caller to:- Perform UPnP discovery of LG TVs.
- Connect, disconnect, shut down, etc.
- Send
TvCommand
messages (e.g. increase volume) to the connected TV. - Perform WebSocket connection or network (ping) tests.
- Submit a Wake-on-LAN request to the last-seen TV.
- Sends
ManagerOutputMessage
updates back to the caller:- After a successful connection:
- The
LastSeenTv
. - The
TvInfo
(e.g. model name). - The
TvInput
list (e.g. HDMI). - The
TvSoftwareInfo
(e.g. webOS version). - The
TvState
(e.g. current volume level). - Whether Wake-on-LAN available.
- The
- As required during the lifetime of a connection:
- Updates to the manager's
ManagerStatus
. - Updates to the
TvState
. TestStatus
updates for regular connection tests and network ping tests.
- Updates to the manager's
- As required in response to other manager actions:
- Discovered UPnP devices (
LgTvDevice
) TestStatus
updates for on-demand connection tests and network ping tests.
- Discovered UPnP devices (
- Any manager or TV errors.
- After a successful connection:
To view the full documentation, clone the repository and run cargo doc --open
.
Run the examples with:
cargo run --example control
cargo run --example discover
Communication with LgTvManager
is asynchronous. Commands are invoked on the TV by sending a
ManagerMessage
to the manager. There is no guarantee that the manager will send an associated
ManagerOutputMessage
back to the caller. However, any changes to the TV's state (like a new
volume setting) will be passed back to the caller via ManagerOutputMessage::TvState
.
LgTvManager
also subscribes to volume, mute, and power state updates from the TV. This means
changes to these states made by other sources -- such as the TV's remote control -- will be
reflected in TvState
updates. TvState
contains the entire state of the TV at the time the
message was sent.
Most use cases will rely on sending ManagerMessage::SendTvCommand
messages to control the TV;
and processing any received ManagerOutputMessage::TvState
messages. This asynchronous pattern
may not always be desirable. It would be possible to extend LgTvManager
to support associating
commands with responses via a message ID.
- Discover LG TVs on the network using UPnP discovery.
- Choose one of the UPnP TV devices to connect to.
- Connect to the TV.
- Wait for the manager to enter the
Communicating
state. - Loop for as long as desired:
- Send
TvCommand
messages to the manager. - Process
TvState
updates from the manager.
- Send
- Disconnect from the TV.
Instantiate an LgTvManager
with LgTvManager::new()
, providing a channel that will be used to
send ManagerMessage
messages to the manager. LgTvManager::new()
will return a tuple of the
manager instance itself, and another channel over which the manager will send
ManagerOutputMessage
messages back to the caller.
use lgtv_manager::LgTvManager;
use tokio::sync::mpsc;
let (to_manager, to_manager_rx) = mpsc::channel(32);
let (mut manager, mut from_manager) = LgTvManager::new(to_manager_rx);
// Send messages with to_manager.send()
// Receive messages with from_manager.recv()
Optionally, manager settings can be configured using the LgTvManagerBuilder
.
Connect to a TV by sending a ManagerMessage::Connect
message to the manager. Connections can be
made to a TV by host, or by discovered UPnP device.
Connection settings must be specified. Use ConnectionSettings::default()
or the
ConnectionSettingsBuilder
.
use lgtv_manager::{
Connection, ConnectionSettingsBuilder, LgTvManager, ManagerMessage::{Connect}
};
use tokio::sync::mpsc;
let (to_manager, to_manager_rx) = mpsc::channel(32);
// <Instantiate and run the manager first>
to_manager
.send(Connect(Connection::Host(
"10.0.0.101".into(),
ConnectionSettingsBuilder::new()
.with_no_tls()
.with_forced_pairing()
.with_initial_connect_retries()
.with_auto_reconnect()
.build(),
)))
.await;
When using Connection::Host
, a best effort is made to generate a valid LG TV WebSocket
server URL from the provided host string. For example, tv.local
will be converted to
wss://tv.local:3001/
. This behavior can be overridden by passing a fully-qualified URL
as the host, such as ws://10.0.1.101:3000/
, in which case the host will be used unaltered.
When using Connection::Device
, the WebSocket server URL is generated using the UPnP
device url. This assumes the wss://
scheme on port 3001
.
After a successful connection, the manager will emit LastSeenTv
, TvInfo
, TvInput
,
TvSoftwareInfo
, and TvState
details.
Successfully connecting to a TV requires accepting a pair request, during which the TV will
prompt for input and the manager will be in the ManagerStatus::Pairing
state. If the pair
request is accepted then a unique client key is generated by the TV. LgTvManager
will persist
this client key to local storage and will use it automatically for future sessions. The persisted
client key can be manually overridden with ConnectionSettingsBuilder::with_client_key()
.
Pairing can be forced with ConnectionSettingsBuilder::with_forced_pairing()
.
UPnP discovery can be used to find LG TVs on the local network by sending a
ManagerMessage::Discover
message to the manager. The manager will then send messages back to
the caller, providing discovery status information with ManagerOutputMessage::IsDiscovering
and
a vector of discovered devices with ManagerOutputMessage::DiscoveredDevices
.
LgTvManager
automatically transitions through a number of states while establishing a connection
to a TV. The manager will return a ManagerOutputMessage::Status
message for each updated
state. The flow is as follows:
Disconnected
Connecting
Connected
Pairing
Initializing
Communicating
Disconnecting
Most states can be safely ignored. It is enough to instantiate LgTvManager
, send a
ManagerMessage::Connect
message, and then wait for the manager to enter the Communicating
state before sending commands.
The WebSocket URL is provided as String data with most states (where applicable).
See LgTvManager
for a diagram of the state flow.
When the TV closes the connection (e.g. after the TV enters standby), the manager reverts to its
default Disconnected
state. This behavior can be overridden with
ConnectionSettingsBuilder::with_auto_reconnect()
.
When auto reconnect is enabled, the manager will attempt to reestablish a lost connection. The wait
time between reconnects will increase exponentially from 250ms to a maximum of 10 seconds. The
manager can be instructed to stop attempting reconnects by sending
ManagerMessage::CancelReconnect
.
The manager will emit ManagerOutputMessage::ReconnectFlowStatus
messages to indicate the
current state of the reconnect flow. This is distinct from the manager state, which will continue
to cycle through the normal connect phases when attempting to reconnect. The reconnect flow will
end either upon successful connection, or when cancelled with ManagerMessage::CancelReconnect
.
If the TV goes offline then the manager will attempt to ping the TV over the network at regular intervals until it responds, at which time standard reconnects will be attempted.
When connecting to a TV for the first time during a single manager session, the initial connection
might fail. This is likely to happen when the TV is turned off or in standby mode. This initial
connection can be optionally retried with
ConnectionSettingsBuilder::with_initial_connect_retries()
. The retry behavior is the same as
for closed-connection reconnects.
Once a successful connection has been established and the manager has returned
ManagerOutputMessage::Status(Communicating)
, arbitrary commands can be sent to the TV.
Commands are sent using ManagerMessage::SendTvCommand
. Supported commands can be seen in
TvCommand
.
It is expected that the most common commands to send to the TV will be those that change the TV's
state (such as SetMute
, VolumeUp
, etc). Any changes to the TV's state will be received via
TvState
updates from the manager, so invoking the "get" commands is usually not necessary.
The current validity of the WebSocket connection to the TV can be tested using
ManagerMessage::TestConnection
. The manager will respond with a
ManagerOutputMessage::ConnectionTestStatus
. For a connection test to pass, the TV must respond
to a WebSocket ping over the existing WebSocket connection.
Whether the TV is currently visible on the network can be tested using
ManagerMessage::TestTvOnNetwork
. The manager will respond with a
ManagerOutputMessage::TvOnNetworkTestStatus
. For a network test to pass, the TV must respond to
a network ICMP ping.
Use ManagerMessage::Disconnect
to disconnect from a TV. The manager will continue to run after
a disconnect, and can still accept future ManagerMessage::Connect
messages. Send
ManagerMessage::ShutDown
to instruct the manager to disconnect and exit.
The lgtv_manager
crate is currently limited in scope. It only supports the TvCommand
list
found in src/tv_commands.rs
, and does not provide much in the way of configuration (such as
timeout durations, reconnect intervals, etc).
Extending LgTvManager
to support additional TV commands should be fairly trivial, although
LG's SSAP protocol does not appear to be documented. A good place to start is the
LGWebOSRemote project, which was a source of
inspiration for lgtv_manager
.
(Note: These examples rely on the third-party crates env_logger
and tokio
).
To run the examples:
cargo run --example control
cargo run --example discover
The control
example creates an LgTvManager
instance, sends commands to the manager via the
console, and prints all messages received from the manager. This example likely won't work
without updating the TV IP address.
use env_logger;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::mpsc;
use lgtv_manager::{
Connection, ConnectionSettings, LgTvManager, ManagerOutputMessage,
TvCommand::{VolumeDown, VolumeUp},
};
use lgtv_manager::ManagerMessage::{
Connect, Disconnect, SendTvCommand, ShutDown, TestConnection,
};
use lgtv_manager::ManagerStatus::Disconnected;
#[tokio::main]
async fn main() -> Result<(), ()> {
// Initialize manager and associated send/receive channels
let (to_manager, to_manager_rx) = mpsc::channel(32);
let (mut manager, mut from_manager) = LgTvManager::new(to_manager_rx);
// Print all logs to stdout
// TODO: Set LevelFilter::Debug to see debug logging
env_logger::Builder::new()
.filter(None, log::LevelFilter::Info)
.init();
// Task to print all messages received from the manager
tokio::spawn(async move {
loop {
if let Some(manager_output_msg) = from_manager.recv().await {
println!(
"<<< Received message from LgTvManager: {:?}",
manager_output_msg
);
if ManagerOutputMessage::Status(Disconnected) == manager_output_msg {
println!("\n>>> Manager is disconnected and ready to receive messages");
println!(">>> SEND CONNECT ('c') COMMAND FIRST; TV IP MUST BE VALID");
println!(concat!(
">>> Enter command:\n",
">>> c (connect), u (volume up), d (volume down)\n",
">>> t (test connection), i (disconnect), s (shut down)\n"
));
}
}
}
});
let to_manager_clone = to_manager.clone();
// Task to accept commands from the console to send to the manager
let stdin_handle = tokio::spawn(async move {
let stdin = tokio::io::stdin();
let mut reader = BufReader::new(stdin);
loop {
let mut buf = String::new();
if reader
.read_line(&mut buf)
.await
.expect("Failed to read line")
== 0
{
break;
}
match buf.trim() {
"c" => {
// TODO: Set the IP address to a valid TV on the local network
to_manager_clone
.send(Connect(Connection::Host(
"10.0.0.101".into(),
ConnectionSettings::default(),
)))
.await
.map_err(|_| ())?;
}
"u" => {
to_manager_clone
.send(SendTvCommand(VolumeUp))
.await
.map_err(|_| ())?;
}
"d" => {
to_manager_clone
.send(SendTvCommand(VolumeDown))
.await
.map_err(|_| ())?;
}
"t" => {
to_manager_clone
.send(TestConnection)
.await
.map_err(|_| ())?;
}
"i" => {
to_manager_clone.send(Disconnect).await.map_err(|_| ())?;
}
"s" => {
to_manager_clone.send(ShutDown).await.map_err(|_| ())?;
break;
}
_ => {}
}
}
Ok(())
});
// Run the manager until instructed to shut down (ManagerMessage::ShutDown)
manager.run().await;
stdin_handle.await.map_err(|_| ())?
}
The discover
example performs LG TV UPnP device discovery. Discovery returns a vector of
LgTvDevice
instances, which can be passed to the manager using ManagerMessage::Connect
.
use env_logger;
use tokio::sync::mpsc;
use lgtv_manager::{
LgTvManager,
ManagerMessage::{Discover, ShutDown},
ManagerOutputMessage,
};
#[tokio::main]
async fn main() -> Result<(), ()> {
// Initialize manager and associated send/receive channels
let (to_manager, to_manager_rx) = mpsc::channel(32);
let (mut manager, mut from_manager) = LgTvManager::new(to_manager_rx);
let to_manager_clone = to_manager.clone();
// Print all logs to stdout
env_logger::Builder::new()
.filter(None, log::LevelFilter::Debug)
.init();
// Wait for a DiscoveredDevices message from the manager and display the devices
tokio::spawn(async move {
loop {
if let Some(manager_output_msg) = from_manager.recv().await {
match manager_output_msg {
ManagerOutputMessage::DiscoveredDevices(devices) => {
match devices.len() {
0 => println!("\nNo LG TV devices found.\n"),
_ => println!("\nDiscovered devices:\n\n{:?}\n", devices),
}
let _ = to_manager_clone.send(ShutDown).await;
break;
}
_ => {}
}
}
}
});
// Instruct the manager to start discovering devices
println!("Discovering LG TV devices...");
to_manager.send(Discover).await.map_err(|_| ())?;
manager.run().await;
Ok(())
}