147 lines
5.6 KiB
Python
147 lines
5.6 KiB
Python
"""What is this box doing right now — gaming, media, or idle — and switching between.
|
|
|
|
WHY THIS EXISTS
|
|
---------------
|
|
Home Assistant needs to be able to answer "is somebody playing on the TV box?" without
|
|
guessing from power draw. That one sensor is worth a lot in automations: don't dim the
|
|
living room during a game, don't announce the doorbell over a raid, count the room as
|
|
occupied even though nobody has moved for forty minutes.
|
|
|
|
WHAT IT READS
|
|
-------------
|
|
Two cheap facts, both local:
|
|
- is the Steam client running (pgrep);
|
|
- which sway workspace is focused (swaymsg -t get_workspaces).
|
|
|
|
Deliberately NOT the window title. Steam's window titles and the existence of a
|
|
separate Big Picture window have both changed across client rewrites, and a sensor that
|
|
silently goes wrong after a Steam update is worse than one that is slightly coarse. The
|
|
workspace name is a contract this repo owns — see the header in
|
|
configs/session/session-watcher, which makes the same call for the same reason.
|
|
|
|
MODES
|
|
-----
|
|
gaming — Steam is running and 1:steam is focused. The screen is showing a game or
|
|
Big Picture.
|
|
steam — Steam is running but the user has moved elsewhere (this is "exited Big
|
|
Picture but left Steam up", the case session-watcher reacts to).
|
|
media — Steam is not running; the media session is what is on screen.
|
|
idle — nothing is up. Only really seen in the seconds after boot, or after
|
|
somebody quit Steam and the media session has not been started yet.
|
|
|
|
SECURITY POSTURE, UNCHANGED
|
|
---------------------------
|
|
The two commands below take no arguments derived from any MQTT payload. The mode
|
|
*switch* is an enumerated action — see mqtt_discovery.py's module docstring.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
|
|
from .sway_control import WS_STEAM
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
MODE_GAMING = "gaming"
|
|
MODE_STEAM = "steam"
|
|
MODE_MEDIA = "media"
|
|
MODE_IDLE = "idle"
|
|
|
|
MODES = (MODE_GAMING, MODE_MEDIA)
|
|
|
|
STEAM_PROCESS = "steam"
|
|
MEDIA_SESSION = "/usr/local/bin/media-session"
|
|
STEAM_BIG_PICTURE = "/usr/local/bin/steam-big-picture"
|
|
|
|
# What media-session itself guards on. Kept in sync with that script by hand; there is
|
|
# no shared file to read, and inventing one to hold three strings would be worse.
|
|
MEDIA_PATTERNS = (
|
|
"com.spotify.Client",
|
|
"media-player-idle",
|
|
"firefox.*--profile.*/firefox/web",
|
|
)
|
|
|
|
|
|
class SessionMode:
|
|
def __init__(self, sway):
|
|
self.sway = sway
|
|
# What current() last computed, versus what was last put on the broker. Two
|
|
# fields because current() updates the first every time it is called, so it
|
|
# cannot also serve as "have we told HA about this yet".
|
|
self.last_mode = MODE_IDLE
|
|
self.last_published = ""
|
|
|
|
# --- reading ------------------------------------------------------------
|
|
def focused_workspace(self) -> str:
|
|
output = self.sway.swaymsg("-t", "get_workspaces")
|
|
if not output:
|
|
return ""
|
|
try:
|
|
workspaces = json.loads(output)
|
|
except ValueError:
|
|
log.warning("could not parse get_workspaces output")
|
|
return ""
|
|
for workspace in workspaces:
|
|
if isinstance(workspace, dict) and workspace.get("focused"):
|
|
return str(workspace.get("name") or "")
|
|
return ""
|
|
|
|
def steam_running(self) -> bool:
|
|
# is_process (pgrep -x), not is_running (pgrep -f) — see that method's comment
|
|
# for why Steam specifically needs the exact-name match.
|
|
return self.sway.is_process(STEAM_PROCESS)
|
|
|
|
def media_running(self) -> bool:
|
|
return any(self.sway.is_running(pattern) for pattern in MEDIA_PATTERNS)
|
|
|
|
def current(self) -> str:
|
|
steam = self.steam_running()
|
|
if steam and self.focused_workspace() == WS_STEAM:
|
|
mode = MODE_GAMING
|
|
elif steam:
|
|
mode = MODE_STEAM
|
|
elif self.media_running():
|
|
mode = MODE_MEDIA
|
|
else:
|
|
mode = MODE_IDLE
|
|
self.last_mode = mode
|
|
return mode
|
|
|
|
def attributes(self) -> dict:
|
|
return {
|
|
"steam_running": self.steam_running(),
|
|
"media_running": self.media_running(),
|
|
"workspace": self.focused_workspace(),
|
|
}
|
|
|
|
# --- switching ----------------------------------------------------------
|
|
def select(self, mode: str) -> str:
|
|
"""Handle the HA select. Enumerated: anything else is ignored, not guessed at.
|
|
|
|
Note the asymmetry, which is intentional. Choosing "gaming" launches or focuses
|
|
Steam and leaves the media apps alone — someone may well want music over a
|
|
game, and killing a running Spotify because a game started would lose whatever
|
|
was playing. Choosing "media" starts the media session and likewise does not
|
|
quit Steam, because quitting Steam from a phone while somebody is mid-match is
|
|
not a thing this should be able to do by accident. Both directions are additive;
|
|
the only thing that shuts anything down is the explicit "Stop media apps"
|
|
button.
|
|
"""
|
|
mode = (mode or "").strip().lower()
|
|
if mode == MODE_GAMING:
|
|
log.info("session mode -> gaming")
|
|
self.sway.launch_app([STEAM_BIG_PICTURE])
|
|
elif mode == MODE_MEDIA:
|
|
log.info("session mode -> media")
|
|
self.sway.launch_app([MEDIA_SESSION, "start", "--focus"])
|
|
else:
|
|
log.warning("ignoring unknown session mode %r", mode)
|
|
return self.current()
|
|
|
|
def stop_media(self) -> str:
|
|
log.info("stopping the media session")
|
|
self.sway.launch_app([MEDIA_SESSION, "stop"])
|
|
return self.current()
|