Add Home Assistant integration
Home Assistant integration is done via [MQTT Discovery][0]. The application publishes configuration to a special topic, which Home Assistant receives and uses to create entities and optionally assign them to devices. To start with, we're exposing two entites to Home Assistant for each attached monitor: one for the current URL and one for the current title of the window. The URL is exposed as a "text" sensor, which allows the state to be changed directly; when the state changes, the new value is puoblished to the "command" topic and thus triggering a navigation. Since the client can only have a single "will" message, every entity will be marked as available/unavailable together. This is probably not an issue, but it does make it impossible to indicate a monitor is no longer attached. Note: for some reason, the will message doesn't seem to get sent when the client disconnects. I am not sure why... [0]: https://www.home-assistant.io/docs/mqtt/discovery/dev/ci
parent
38e826b454
commit
d4f2c73eca
|
@ -2,6 +2,15 @@
|
|||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "0.7.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "1.8.0"
|
||||
|
@ -244,6 +253,17 @@ version = "0.12.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hostname"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"match_cfg",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.2"
|
||||
|
@ -321,6 +341,12 @@ dependencies = [
|
|||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "match_cfg"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.1.0"
|
||||
|
@ -375,10 +401,12 @@ name = "mqttmarionette"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"hostname",
|
||||
"inotify",
|
||||
"mozprofile",
|
||||
"mozrunner",
|
||||
"paho-mqtt",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
|
@ -535,6 +563,8 @@ version = "1.7.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e076559ef8e241f2ae3479e36f97bd5741c0330689e217ad51ce2c76808b868a"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
|
|
|
@ -5,10 +5,12 @@ edition = "2021"
|
|||
|
||||
[dependencies]
|
||||
async-trait = "0.1.60"
|
||||
hostname = "0.3.1"
|
||||
inotify = "0.10.0"
|
||||
mozprofile = "0.9.0"
|
||||
mozrunner = "0.15.0"
|
||||
paho-mqtt = "0.11.1"
|
||||
regex = "1.7.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1.23.0", features = ["io-util", "macros", "net", "rt", "signal", "sync", "time"] }
|
||||
|
|
|
@ -3,6 +3,8 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::util;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigError {
|
||||
Io(std::io::Error),
|
||||
|
@ -73,6 +75,8 @@ impl Default for MqttConfig {
|
|||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct Configuration {
|
||||
#[serde(default = "util::hostname")]
|
||||
pub unique_id: String,
|
||||
pub mqtt: MqttConfig,
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::util;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct HassDevice {
|
||||
pub identifiers: Vec<String>,
|
||||
pub manufacturer: String,
|
||||
pub model: String,
|
||||
pub name: String,
|
||||
pub sw_version: String,
|
||||
}
|
||||
|
||||
impl Default for HassDevice {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
identifiers: vec![util::hostname()],
|
||||
manufacturer: "Dustin C. Hatch".into(),
|
||||
model: env!("CARGO_PKG_VERSION").into(),
|
||||
name: "Browser HUD".into(),
|
||||
sw_version: env!("CARGO_PKG_VERSION").into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct HassConfig {
|
||||
pub availability_topic: String,
|
||||
pub command_topic: Option<String>,
|
||||
pub device: HassDevice,
|
||||
pub name: String,
|
||||
pub state_topic: String,
|
||||
pub unique_id: String,
|
||||
pub object_id: String,
|
||||
pub icon: String,
|
||||
pub retain: Option<bool>,
|
||||
}
|
||||
|
||||
pub fn slugify(s: impl AsRef<str>) -> String {
|
||||
let re = Regex::new("[^a-z0-9_]").unwrap();
|
||||
let s = s.as_ref().to_ascii_lowercase();
|
||||
let s = re.replace_all(&s, "_");
|
||||
let re = Regex::new("_+").unwrap();
|
||||
re.replace_all(s.trim_matches('_'), "_").into()
|
||||
}
|
|
@ -1,9 +1,11 @@
|
|||
mod browser;
|
||||
mod config;
|
||||
mod hass;
|
||||
mod marionette;
|
||||
mod monitor;
|
||||
mod mqtt;
|
||||
mod session;
|
||||
mod util;
|
||||
#[cfg(unix)]
|
||||
mod x11;
|
||||
|
||||
|
|
92
src/mqtt.rs
92
src/mqtt.rs
|
@ -8,17 +8,14 @@ use paho_mqtt::{
|
|||
CreateOptionsBuilder, ServerResponse, SslOptionsBuilder,
|
||||
};
|
||||
use tokio_stream::StreamExt;
|
||||
use tracing::{info, trace};
|
||||
use tracing::{debug, info, trace};
|
||||
|
||||
use crate::config::Configuration;
|
||||
use crate::hass::{self, HassConfig};
|
||||
|
||||
#[async_trait]
|
||||
pub trait MessageHandler {
|
||||
async fn navigate(
|
||||
&mut self,
|
||||
publisher: &MqttPublisher,
|
||||
msg: &Message,
|
||||
);
|
||||
async fn navigate(&mut self, publisher: &MqttPublisher, msg: &Message);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
@ -42,8 +39,10 @@ impl<'a> MqttClient<'a> {
|
|||
config.mqtt.port
|
||||
);
|
||||
info!("Connecting to MQTT server {}", uri);
|
||||
let client_opts =
|
||||
CreateOptionsBuilder::new().server_uri(uri).finalize();
|
||||
let client_opts = CreateOptionsBuilder::new()
|
||||
.client_id(&config.unique_id)
|
||||
.server_uri(uri)
|
||||
.finalize();
|
||||
let mut client = AsyncClient::new(client_opts)?;
|
||||
let stream = client.get_stream(10);
|
||||
let topics = TopicMatcher::new();
|
||||
|
@ -56,7 +55,9 @@ impl<'a> MqttClient<'a> {
|
|||
}
|
||||
|
||||
pub async fn connect(&mut self) -> Result<ServerResponse, Error> {
|
||||
let res = self.client.connect(self.conn_opts()?).await?;
|
||||
let opts = self.conn_opts()?;
|
||||
trace!("Connect options: {:?}", opts);
|
||||
let res = self.client.connect(opts).await?;
|
||||
info!("Successfully connected to MQTT broker");
|
||||
Ok(res)
|
||||
}
|
||||
|
@ -69,6 +70,13 @@ impl<'a> MqttClient<'a> {
|
|||
Ok(res)
|
||||
}
|
||||
|
||||
pub fn publisher(&mut self) -> MqttPublisher {
|
||||
MqttPublisher {
|
||||
config: self.config,
|
||||
client: &mut self.client,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run<H>(mut self, mut handler: H)
|
||||
where
|
||||
H: MessageHandler,
|
||||
|
@ -92,6 +100,11 @@ impl<'a> MqttClient<'a> {
|
|||
|
||||
fn conn_opts(&self) -> Result<ConnectOptions, Error> {
|
||||
let mut conn_opts = ConnectOptionsBuilder::new();
|
||||
conn_opts.will_message(Message::new_retained(
|
||||
format!("{}/available", self.config.mqtt.topic_prefix),
|
||||
"offline",
|
||||
0,
|
||||
));
|
||||
conn_opts.automatic_reconnect(
|
||||
Duration::from_millis(500),
|
||||
Duration::from_secs(30),
|
||||
|
@ -140,4 +153,65 @@ impl<'a> MqttPublisher<'a> {
|
|||
self.client.publish(msg).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn publish_config(&self, screen: &str) -> Result<(), Error> {
|
||||
debug!("Publishing Home Assistant configuration");
|
||||
let prefix = &self.config.mqtt.topic_prefix;
|
||||
|
||||
let availability_topic = format!("{}/available", prefix);
|
||||
let command_topic = Some(format!("{}/{}/navigate", prefix, screen));
|
||||
let name = format!("{} URL", screen);
|
||||
let state_topic = format!("{}/{}/url", prefix, screen);
|
||||
let key = format!(
|
||||
"browserhud_{}_{}",
|
||||
self.config.unique_id,
|
||||
hass::slugify(screen)
|
||||
);
|
||||
let unique_id = format!("text.{}_url", key);
|
||||
let object_id = unique_id.clone();
|
||||
let msg = Message::new_retained(&availability_topic, "online", 0);
|
||||
trace!("Publishing message: {:?}", msg);
|
||||
self.client.publish(msg).await?;
|
||||
let config = HassConfig {
|
||||
availability_topic,
|
||||
command_topic,
|
||||
name,
|
||||
state_topic,
|
||||
unique_id,
|
||||
object_id,
|
||||
device: Default::default(),
|
||||
icon: "mdi:monitor".into(),
|
||||
retain: Some(true),
|
||||
};
|
||||
let msg = Message::new_retained(
|
||||
format!("homeassistant/text/{}_url/config", key),
|
||||
serde_json::to_string(&config).unwrap(),
|
||||
0,
|
||||
);
|
||||
trace!("Publishing message: {:?}", msg);
|
||||
self.client.publish(msg).await?;
|
||||
|
||||
let unique_id = format!("sensor.{}_title", key);
|
||||
let object_id = unique_id.clone();
|
||||
let config = HassConfig {
|
||||
command_topic: None,
|
||||
state_topic: format!("{}/{}/title", prefix, screen),
|
||||
name: format!("{} Title", screen),
|
||||
unique_id,
|
||||
object_id,
|
||||
retain: None,
|
||||
..config
|
||||
};
|
||||
let msg = Message::new_retained(
|
||||
format!("homeassistant/sensor/{}_title/config", key),
|
||||
serde_json::to_string(&config).unwrap(),
|
||||
0,
|
||||
);
|
||||
|
||||
trace!("Publishing message: {:?}", msg);
|
||||
self.client.publish(msg).await?;
|
||||
|
||||
info!("Succesfully published Home Assistant config");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -122,6 +122,16 @@ impl Session {
|
|||
break;
|
||||
}
|
||||
|
||||
let publisher = client.publisher();
|
||||
for w in windows.keys() {
|
||||
if let Err(e) = publisher.publish_config(w).await {
|
||||
error!(
|
||||
"Failed to publish Home Assistant config for screen {}: {}",
|
||||
w, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let handler = MessageHandler {
|
||||
marionette: &mut self.marionette,
|
||||
windows,
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
pub fn hostname() -> String {
|
||||
if let Ok(h) = hostname::get() {
|
||||
if let Some(h) = h.to_str() {
|
||||
if let Some((h, _)) = h.split_once('.') {
|
||||
return h.into();
|
||||
};
|
||||
};
|
||||
};
|
||||
"localhost".into()
|
||||
}
|
Loading…
Reference in New Issue