feat(jmri): headless JMRI server with Leviton layout power monitor and X11 GUI mode

- Stable udev device symlinks (/dev/jmri/nce, /dev/jmri/loconet, /dev/jmri/lcc)
- jmri-monitor: polls Leviton Decora Smart switch to start/stop JMRI automatically
  - Quiet hours 1-10 AM (no polling)
  - 30s off-delay before shutdown
- LCRR config cloned from Gitea (ssh://gitea.mk-labs.cloud:2221/rblundon/LCRR.git)
- ~/.jmri symlinked to LCRR repo for GitOps config management
- jmri-gui: X11 remote GUI access (PanelPro/DecoderPro) via ssh -X as jmri user
  - Stops daemon, launches GUI, restarts daemon on exit if layout still on
- jmri user gets login shell + SSH key for GUI sessions
- Full JRE installed (openjdk-21-jre) for AWT/X11 support
This commit is contained in:
Hermes Agent service account
2026-07-29 00:43:23 -05:00
parent a5433dcb5b
commit d974c75d7c
26 changed files with 2460 additions and 459 deletions

View File

@@ -0,0 +1,25 @@
# /etc/udev/rules.d/99-jmri-devices.rules
# Managed by Ansible — do not edit manually.
#
# Creates stable /dev/jmri/* symlinks for all three JMRI serial interfaces.
# Uses ID_SERIAL (full serial string) to match by-id symlink identity,
# so device numbering (ttyACM0, ttyACM1, etc.) doesn't matter after reboots.
# Ownership: root:dialout mode 0660 — jmri user is a member of dialout.
# NCE command station (Microchip CDC)
# Appears when layout is powered on; disappears when layout powers off.
SUBSYSTEM=="tty", ENV{ID_SERIAL}=="{{ jmri_dev_nce_serial }}", \
SYMLINK+="jmri/nce", \
GROUP="dialout", MODE="0660"
# RR-CirKits LocoBuffer-NG (LocoNet bridge)
# USB-powered — always present when server is running.
SUBSYSTEM=="tty", ENV{ID_SERIAL}=="{{ jmri_dev_loconet_serial }}", \
SYMLINK+="jmri/loconet", \
GROUP="dialout", MODE="0660"
# FTDI USB serial (LCC interface)
# USB-powered — always present when server is running.
SUBSYSTEM=="tty", ENV{ID_SERIAL}=="{{ jmri_dev_lcc_serial }}", \
SYMLINK+="jmri/lcc", \
GROUP="dialout", MODE="0660"

View File

@@ -0,0 +1,111 @@
#!/bin/bash
# jmri-gui — launch JMRI GUI over SSH -X (X11 forwarding)
# Managed by Ansible — do not edit manually.
#
# Usage (connect as jmri user):
# ssh -X jmri@main-street-station jmri-gui panelpro
# ssh -X jmri@main-street-station jmri-gui decoderpro
# ssh -X jmri@main-street-station jmri-gui status
#
# ~/.ssh/config recommended:
# Host main-street-station
# User jmri
# ForwardX11 yes
set -euo pipefail
JMRI_DIR="{{ jmri_install_dir }}"
JMRI_PROFILE="{{ jmri_profile_id }}"
JMRI_SERVICE="jmri.service"
MONITOR_SERVICE="jmri-monitor.service"
usage() {
echo "Usage: jmri-gui <panelpro|decoderpro|status>"
exit 1
}
check_display() {
if [ -z "${DISPLAY:-}" ]; then
echo "ERROR: No DISPLAY set. Connect with: ssh -X jmri@main-street-station"
exit 1
fi
}
status() {
echo "=== JMRI daemon ==="
systemctl status "$JMRI_SERVICE" --no-pager -l 2>&1 | head -8
echo ""
echo "=== Layout monitor ==="
systemctl status "$MONITOR_SERVICE" --no-pager -l 2>&1 | head -5
}
launch() {
local app="$1"
local binary
case "$app" in
panelpro) binary="PanelPro" ;;
decoderpro) binary="DecoderPro" ;;
*) usage ;;
esac
check_display
# Stop the daemon if running
if systemctl is-active --quiet "$JMRI_SERVICE" 2>/dev/null; then
echo "Stopping JMRI daemon..."
sudo systemctl stop "$JMRI_SERVICE"
fi
echo "Launching $binary (profile: $JMRI_PROFILE)..."
echo "Close the window to return to daemon mode."
echo ""
# SSH sets DISPLAY to localhost:N (TCP) — translate to unix socket form
# so Java's AWT xauth lookup matches the forwarded cookie.
export DISPLAY=$(echo "$DISPLAY" | sed 's/localhost:/:/')
# Force AWT out of headless mode
export JMRI_OPTIONS="-Djava.awt.headless=false"
# Run directly — we are already the jmri user, DISPLAY is set by SSH
"$JMRI_DIR/$binary" --profile="$JMRI_PROFILE"
echo ""
echo "$binary closed."
# Restart daemon if layout switch is still on
if systemctl is-active --quiet "$MONITOR_SERVICE" 2>/dev/null; then
if sudo /opt/jmri-monitor/bin/python3 - <<'EOF'
from decora_wifi import DecoraWiFiSession
from decora_wifi.models.residential_account import ResidentialAccount
import sys
session = DecoraWiFiSession()
person = session.login("{{ jmri_leviton_email }}", "{{ jmri_leviton_password }}")
perms = person.get_residential_permissions()
for perm in perms:
acct_id = perm.data.get('residentialAccountId')
if not acct_id:
continue
acct = ResidentialAccount(session, acct_id)
acct.refresh()
for r in acct.get_residences():
for s in r.get_iot_switches():
if s.data.get('name') == '{{ jmri_leviton_switch_name }}':
sys.exit(0 if s.data.get('power') == 'ON' else 1)
sys.exit(1)
EOF
then
echo "Layout is ON — restarting JMRI daemon."
sudo systemctl start "$JMRI_SERVICE"
else
echo "Layout is OFF — JMRI daemon will not restart."
fi
fi
}
case "${1:-}" in
panelpro|decoderpro) launch "$1" ;;
status) status ;;
*) usage ;;
esac

View File

@@ -0,0 +1,156 @@
#!/opt/jmri-monitor/bin/python3
"""
jmri-monitor — Leviton Decora Smart Wi-Fi layout power monitor
Managed by Ansible — do not edit manually.
Polls the Leviton cloud API for the "{{ jmri_leviton_switch_name }}" switch state.
- Switch ON after being OFF → systemctl start jmri.service
- Switch OFF for {{ jmri_monitor_stop_delay }}s → systemctl stop jmri.service
Quiet hours {{ jmri_monitor_quiet_start }}:00{{ jmri_monitor_quiet_end }}:00: no polling (layout assumed off).
"""
import subprocess
import time
import logging
import sys
from datetime import datetime
LEVITON_EMAIL = "{{ jmri_leviton_email }}"
LEVITON_PASSWORD = "{{ jmri_leviton_password }}"
SWITCH_NAME = "{{ jmri_leviton_switch_name }}"
POLL_INTERVAL = {{ jmri_monitor_poll_interval }}
QUIET_START = {{ jmri_monitor_quiet_start }}
QUIET_END = {{ jmri_monitor_quiet_end }}
STOP_DELAY = {{ jmri_monitor_stop_delay }}
JMRI_SERVICE = "jmri.service"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [jmri-monitor] %(levelname)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
stream=sys.stdout,
)
log = logging.getLogger(__name__)
def systemctl(action):
try:
result = subprocess.run(
["systemctl", action, JMRI_SERVICE],
capture_output=True, text=True, timeout=30,
)
if result.returncode == 0:
log.info("systemctl %s %s: OK", action, JMRI_SERVICE)
else:
log.warning("systemctl %s %s: %s", action, JMRI_SERVICE, result.stderr.strip())
except Exception as e:
log.error("systemctl %s failed: %s", action, e)
def is_quiet_hours():
hour = datetime.now().hour
if QUIET_START < QUIET_END:
return QUIET_START <= hour < QUIET_END
else:
# wraps midnight e.g. 236
return hour >= QUIET_START or hour < QUIET_END
def get_switch_state():
"""Returns True if switch is ON, False if OFF, None on error."""
try:
from decora_wifi import DecoraWiFiSession
from decora_wifi.models.residential_account import ResidentialAccount
session = DecoraWiFiSession()
person = session.login(LEVITON_EMAIL, LEVITON_PASSWORD)
if not person:
log.error("Leviton login failed")
return None
perms = person.get_residential_permissions()
for perm in perms:
acct_id = perm.data.get('residentialAccountId')
if not acct_id:
continue
acct = ResidentialAccount(session, acct_id)
acct.refresh()
for residence in acct.get_residences():
for switch in residence.get_iot_switches():
if switch.data.get('name') == SWITCH_NAME:
state = switch.data.get('power', 'OFF')
session.call_api('/Person/logout', {}, 'post')
return state == 'ON'
all_names = []
for perm in perms:
acct_id = perm.data.get('residentialAccountId')
if acct_id:
acct = ResidentialAccount(session, acct_id)
acct.refresh()
for r in acct.get_residences():
all_names += [s.data.get('name') for s in r.get_iot_switches()]
log.warning("Switch '%s' not found — available: %s", SWITCH_NAME, all_names)
session.call_api('/Person/logout', {}, 'post')
return None
except ImportError:
log.error("decora_wifi not installed")
return None
except Exception as e:
log.error("Error querying Leviton API: %s", e)
return None
def main():
log.info("Layout power monitor starting")
log.info("Switch: '%s' Poll: %ds Quiet: %02d:00%02d:00 Stop delay: %ds",
SWITCH_NAME, POLL_INTERVAL, QUIET_START, QUIET_END, STOP_DELAY)
layout_on = False
off_since = None
while True:
if is_quiet_hours():
log.debug("Quiet hours — sleeping 60s")
# If layout was on when quiet hours started, stop JMRI
if layout_on:
log.info("Quiet hours began — stopping JMRI")
layout_on = False
off_since = None
systemctl("stop")
time.sleep(60)
continue
state = get_switch_state()
if state is True:
off_since = None
if not layout_on:
log.info("Layout switch ON — starting JMRI")
layout_on = True
systemctl("start")
elif state is False:
if layout_on:
if off_since is None:
off_since = time.monotonic()
log.info("Layout switch OFF — waiting %ds before stopping JMRI", STOP_DELAY)
elif time.monotonic() - off_since >= STOP_DELAY:
log.info("Layout switch OFF for %ds — stopping JMRI", STOP_DELAY)
layout_on = False
off_since = None
systemctl("stop")
else:
off_since = None
else:
# API error — don't change state, try again next poll
log.warning("Could not determine switch state — retrying in %ds", POLL_INTERVAL)
time.sleep(POLL_INTERVAL)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,20 @@
[Unit]
Description=JMRI LocoNet Traffic Monitor
Documentation=https://www.jmri.org/
# Must start after udev has processed devices
After=systemd-udev-settle.service
[Service]
Type=simple
# Runs as root — needs to call systemctl start/stop jmri.service
User=root
ExecStart=/usr/local/bin/jmri-monitor
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=jmri-monitor
[Install]
WantedBy=multi-user.target

View File

@@ -1,26 +1,21 @@
[Unit]
Description=JMRI Server (JmriFaceless)
Documentation=https://www.jmri.org/
After=network.target
# Give USB devices time to enumerate after boot
After=dev-{{ jmri_serial_device | basename }}.device
Wants=dev-{{ jmri_serial_device | basename }}.device
# jmri-monitor starts and stops this service based on LocoNet traffic.
# Do NOT enable this unit directly — it is managed by jmri-monitor.service.
After=network.target systemd-udev-settle.service
[Service]
Type=simple
User={{ jmri_user }}
Group={{ jmri_group }}
WorkingDirectory={{ jmri_install_dir }}
# JmriFaceless requires a profile ID
# Find yours: ls ~/.jmri/profiles/
ExecStart={{ jmri_install_dir }}/JmriFaceless --profile={{ jmri_profile_id }}
# Restart on failure, not on clean exit (e.g. intentional shutdown from Jython)
Restart=on-failure
RestartSec=10
# Monitor owns lifecycle — do not auto-restart
Restart=no
# Give hardware time to settle before retry
# Give hardware time to settle on start
TimeoutStartSec=30
# Logging — view with: journalctl -u jmri -f
@@ -32,4 +27,5 @@ SyslogIdentifier=jmri
SupplementaryGroups=dialout
[Install]
WantedBy=multi-user.target
# Intentionally no WantedBy — started only by jmri-monitor
WantedBy=