dustin/mqttmarionette/pipeline/head This commit looks goodDetails
The MQTT Marionette will now publish Home Assistant discovery
configuration for a button entity to initiate a page reload. Each
managed screen has its own button, so individual windows are refreshed
independently.
dustin/mqttmarionette/pipeline/head This commit looks goodDetails
For now, we're only building aarch64 binaries. The screens I intend to
deploy this on are both Raspberry Pis, and I don't yet have a way to
produce multi-arch container images to provide the build environment.
We now manage a switch entity in Home Assistant that can be used to turn
the display on or off. On Linux, this is handled by the X DPMS
extension; presumably there is similar functionality on other platforms
that we can use if we decide to support those as well.
The Paho automatic reconnect capability is pretty useless. While it
does automatically connect to the broker again after an unexpected
disconnect, it does not subscribe to the topics again. Further, since
the broker will send the will message when the client disconnects
unexpectedly, we need to send our "online" availability message when we
reconnect.
To resolve both of these problems, the `MqttClient::run` method now
takes care reconnecting manually. When it receives a disconnect
notification, it explicitly calls `AsyncClient::reconnect`. Once that
succeeds, it resubscribes to the command topics and publishes an
"online" message.
If the Marionette server closes the connection unexpectedly, it will
manifest as an empty message from the socket. We need to handle this
and return an error, instead of causing a panic by attempting to read
from the empty buffer (by trying to create a slice with a negative
length).
Apparently, the `AsyncReceiver` stream produces nested `Option` objects.
The outer is `None` if "the stream is exhausted," which is somehow
different than the connection being closed; the inner `Option` is `None`
in that case.
We were originally ignoring the inner `None`, but just causes the
async task to go into a busy loop when connection is closed. We need to
break out of the loop there instead.
The MQTT broker does *not* send the client's last will and testament
message when the client disconnects gracefully. I thought it did
because my other Rust/MQTT project, MQTTDPMS, seems to behave that way.
It turns out, though, that in that project, the client never actually
disconnects gracefully. It has no signal handlers, so when it receives
SIGINT or SIGTERM, it just exits immediately. This leaves the OS to
forcefully close the TCP connection, so the broker sends the will
message.
Since the Browser HUD process *does* have signal handlers, when it
receives a signal, it shuts down gracefully. As Rust drops objects
during shut down, the MQTT client eventually disconnects cleanly, so the
broker does not send the will message. In order to notify Home
Assistant that the device is now unavailable, we have to explicitly send
the offline message before disconnecting the MQTT client.
I've added a `Notify` object that lives for the entire life of the
process and is passed in to the session. When a signal is received,
this object wakes up the asynchronous tasks that perform the
pre-shutdown operations. One such task is spawned by the
`MqttClient::run` method; it sends the offline message when notified,
then disconnects the MQTT client. In order to share the MQTT client
object between this new task and the message receive loop, it has to be
wrapped in an `Arc` and a `Mutex`.
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/
The `NewWindow` Marionette command actually takes two arguments. They
are optional, and without them, Firefox will open a new *tab* instead
of a new *window*. Since we obviously want windows rather than tabs, so
as to place them on separate monitors, we need to explicitly specify
this when we execute the command.
When we create Firefox windows for each monitor, we need to move them to
their respective screens. The Marionette protocol provides a
`SetWindowRect` command that *should* move the active window (it doesn't
work for all window managers, notably *i3*).
For heads-up displays with multiple monitors, we're going to want one
Firefox window on each. To support this, we need to get a list of
connected monitors from the operating system and associate each with its
own window. Since Firefox may start with multiple taps open
automatically, we first close all but one and associate it with the
first monitor. Then, for each remaining monitor, we open a new window
to associate with it.
To maintain the monitor-window association, the `Session` structure has
a `HashMap`. When a naigation request arrives, the Firefox window to
control is found by looking up the specified screen name in the map.
Since the Marionette protocol is stateful, we have to "switch to" the
desired window and then send the navigation command.
I have tried to design the monitor information lookup API so that it can
be swapped out at compile time for different operating systems. For
now, only X11 is supported, but we could hypothetically support Wayland
or even Windows by implementing the appropriate `get_monitors` function
for those APIs.
If the URL specified in a navigation command results in a redirect, we
want to publish the final destination, rather than the provided
location. Thus, after navigation completes, we get the browser's
current URL and publish that instead.
The `MarionetteConnection` structure provides the low-level interface to
communicate with the Marionette server via the TCP socket. In contrast,
the `Marionette` structure provides the high-level interface to execute
Marionette commands. Separating these will make the code a bit cleaner,
in my opinion.
Marionette commands can return error responses, e.g. if a command fails
or is invalid. We want to propagate these back to the caller whenever
possible. The error object is specified in the Marionette protocol
documentation, so we can model it appropriately.
The pieces are starting to come together. To control the browser via
MQTT messages, the `MqttClient` dispatches messages via a
`MessageHandler`, which parses them and makes the appropriate Marionette
requests. The `MessageHandler` trait defines callback methods for each
MQTT control operation, which currently is just `navigate`. The
operation type is determined by the MQTT topic on which the message was
received.
Several new types are necessary to make this work. The `MessageHandler`
trait and implementation are of course the core, reacting to incoming
MQTT messages. In order for the handler to be able to *send* MQTT
messages, though, it needs a reference to the Paho MQTT client. The
`MqttPublisher` provides a convenient wrapper around the client, with
specific methods for each type of message to send. Finally, there's the
`MessageType` enumeration, which works in conjunction with the
`TopicMatcher` to match topic names to message types using topic filter
patterns.
Separating the `connect` call out of the `MqttClient::new` function
makes is such that we do not have to create a new object for each
iteration of the initial connection loop. Instead, we just create one
object and repeatedly call its `connect` method until it succeeds
Moving the signal handler setup and wait to a separate function will
allow us to eventually create platform-specific implementations.
Windows doesn't have SIGINT/SIGTERM, so we will need different logic if
we ever want to support that OS.
Naturally, we need a way to configure the MQTT connection parameters
(host, port, username, etc.). For that, we'll use a TOML configuration
file, which is read at startup and deserialized into a structure owned
by the Session.
The Session object now has a `run` method, which establishes the MQTT
connection and then repeatedly waits for messages from the broker. It
will continuously attempt to connect to the broker until it succeeds.
This way, if the broker is unavailable when the application starts, it
will eventually connect when it becomes available. Once the initial
connection is established, the client will automatically reconnect if it
gets disconnected later.
Since the `run` method loops forever and never returns, we need to use a
separate Tokio task to manage it. We keep the task handle so we can
cancel the task when the application shuts down.
The Marionette protocol is designed to facilitate concurrent,
asynchronous messages. Each request message includes a message
ID, and the corresponding response includes the same message ID. This
allows several requests to be in flight at once. In order for this to
be useful, the client needs to maintain a record of each request it has
sent so that it knows how to handle responses, even if they arrive out
of order.
To implement this functionality in *mqttmarionette*, the
`MarionetteConnection` structure spawns a Tokio task to handle all
incoming messages from the server. When a message arrives, its ID is
looked up in a registry that maps message IDs to Tokio "oneshot"
channels. If a channel is found in the map, the response is sent back
to the caller through the channel.
In order to handle incoming messages in a separate task, the TCP stream
has to be split into its read and write parts. The receiver task cannot
be spawned, though, until after the first unsolicited message is read
from the socket, since a) there is no caller to send the message back to
and b) it does not follow the same encoding scheme as the rest of the
Marionette messages. As such, I've refactored the
`MarionetteConnection` structure to handle the initial message in the
`connect` function and dropped the `handshake` method. A new
`start_session` method is responsible for initiating the Marionette
session.