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
Dustin 2023-01-07 17:21:17 -06:00
parent 38e826b454
commit d4f2c73eca
8 changed files with 187 additions and 9 deletions

30
Cargo.lock generated
View File

@ -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",
]

View File

@ -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"] }

View File

@ -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,
}

46
src/hass.rs Normal file
View File

@ -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()
}

View File

@ -1,9 +1,11 @@
mod browser;
mod config;
mod hass;
mod marionette;
mod monitor;
mod mqtt;
mod session;
mod util;
#[cfg(unix)]
mod x11;

View File

@ -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(())
}
}

View File

@ -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,

10
src/util.rs Normal file
View File

@ -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()
}