SmartestHome/hosts/steam-tv-box/agent/steamtv_agent/session_mode.py

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