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:
@@ -9,17 +9,45 @@ jmri_user: jmri
|
||||
jmri_group: jmri
|
||||
jmri_home: /home/jmri
|
||||
|
||||
# Profile — override per-host in inventory
|
||||
# Find on the old box: ls ~/.jmri/profiles/
|
||||
# Profile — set per-host in host_vars
|
||||
jmri_profile_id: ""
|
||||
|
||||
# Config restore source (path to the backed-up .jmri directory)
|
||||
# LCRR git repo (set per-host in host_vars; leave blank to skip clone)
|
||||
jmri_lcrr_repo: ""
|
||||
|
||||
# SSH key for jmri user (for jmri-gui X11 access — set per-host in host_vars)
|
||||
jmri_ssh_authorized_key: ""
|
||||
|
||||
# SSH key for jmri user → Gitea
|
||||
jmri_gitea_key: /home/jmri/.ssh/id_ed25519_gitea
|
||||
|
||||
# Config restore source (legacy tar-based restore — leave blank to skip)
|
||||
jmri_config_src: ""
|
||||
|
||||
# Ports (for documentation / firewall rules)
|
||||
jmri_json_port: 12080
|
||||
jmri_withrottle_port: 12090
|
||||
|
||||
# USB serial device for NCE (set per-host in inventory)
|
||||
# e.g. /dev/ttyUSB0 or /dev/ttyACM0
|
||||
jmri_serial_device: /dev/ttyUSB0
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 — Layout power monitor (Leviton Decora Smart Wi-Fi)
|
||||
# ---------------------------------------------------------------------------
|
||||
jmri_leviton_email: "" # set via group_vars (vault-backed)
|
||||
jmri_leviton_password: "" # set via group_vars (vault-backed)
|
||||
jmri_leviton_switch_name: "Layout"
|
||||
jmri_monitor_poll_interval: 30 # seconds between polls (active hours)
|
||||
jmri_monitor_quiet_start: 1 # hour (24h) to stop polling
|
||||
jmri_monitor_quiet_end: 10 # hour (24h) to resume polling
|
||||
jmri_monitor_stop_delay: 30 # seconds of OFF state before stopping JMRI
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# USB device identification (ENV{ID_SERIAL} from udev — the segment of the
|
||||
# by-id symlink name before the -ifNN suffix)
|
||||
# ---------------------------------------------------------------------------
|
||||
# LocoBuffer-NG (LocoNet bridge) → /dev/jmri/loconet
|
||||
jmri_dev_loconet_serial: "RR-CirKits_LocoBuffer-NG_CDC_ACM_SERIAL_DEVICE_AA5700218A"
|
||||
|
||||
# NCE command station → /dev/jmri/nce ← triggers service start/stop
|
||||
jmri_dev_nce_serial: "Microchip_Technology_Inc._Simple_CDC_Device_Demo"
|
||||
|
||||
# FTDI LCC interface → /dev/jmri/lcc
|
||||
jmri_dev_lcc_serial: "ftdi_usb_serial_converter_ftDYQHZX"
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
---
|
||||
- name: Reload udev
|
||||
ansible.builtin.command: udevadm control --reload-rules
|
||||
changed_when: false
|
||||
|
||||
- name: Reload systemd
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
|
||||
- name: Restart jmri-monitor
|
||||
ansible.builtin.systemd:
|
||||
name: jmri-monitor
|
||||
state: restarted
|
||||
|
||||
- name: Restart jmri
|
||||
ansible.builtin.systemd:
|
||||
name: jmri
|
||||
state: restarted
|
||||
|
||||
- name: Restart sshd
|
||||
ansible.builtin.systemd:
|
||||
name: ssh
|
||||
state: restarted
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
---
|
||||
- name: Install Java runtime (headless)
|
||||
- name: Install Java runtime (full — required for GUI mode)
|
||||
ansible.builtin.apt:
|
||||
name: openjdk-21-jre-headless
|
||||
name:
|
||||
- openjdk-21-jre
|
||||
- openjdk-21-jdk
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
@@ -16,17 +18,147 @@
|
||||
name: "{{ jmri_user }}"
|
||||
group: "{{ jmri_group }}"
|
||||
home: "{{ jmri_home }}"
|
||||
shell: /usr/sbin/nologin
|
||||
shell: /bin/bash
|
||||
system: true
|
||||
create_home: true
|
||||
state: present
|
||||
|
||||
- name: Deploy SSH authorized key for jmri user
|
||||
ansible.posix.authorized_key:
|
||||
user: "{{ jmri_user }}"
|
||||
key: "{{ jmri_ssh_authorized_key }}"
|
||||
state: present
|
||||
when: jmri_ssh_authorized_key | length > 0
|
||||
|
||||
- name: Add JMRI user to dialout group (serial device access)
|
||||
ansible.builtin.user:
|
||||
name: "{{ jmri_user }}"
|
||||
groups: dialout
|
||||
append: true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — Stable udev device symlinks
|
||||
# Creates /dev/jmri/nce, /dev/jmri/loconet, /dev/jmri/lcc
|
||||
# ---------------------------------------------------------------------------
|
||||
- name: Create /dev/jmri symlink directory (static — survives reboots)
|
||||
ansible.builtin.file:
|
||||
path: /etc/jmri
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0755'
|
||||
|
||||
- name: Deploy udev rules for stable JMRI device symlinks
|
||||
ansible.builtin.template:
|
||||
src: 99-jmri-devices.rules.j2
|
||||
dest: /etc/udev/rules.d/99-jmri-devices.rules
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0644'
|
||||
notify: Reload udev
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 — LocoNet traffic monitor
|
||||
# Installs jmri-monitor: watches /dev/jmri/loconet, starts/stops jmri.service
|
||||
# ---------------------------------------------------------------------------
|
||||
- name: Install python3-venv (monitor virtualenv support)
|
||||
ansible.builtin.apt:
|
||||
name: python3-venv
|
||||
state: present
|
||||
|
||||
- name: Create virtualenv for jmri-monitor
|
||||
ansible.builtin.command:
|
||||
cmd: python3 -m venv /opt/jmri-monitor
|
||||
creates: /opt/jmri-monitor/bin/python3
|
||||
|
||||
- name: Install decora_wifi into jmri-monitor virtualenv
|
||||
ansible.builtin.pip:
|
||||
name: decora_wifi
|
||||
state: present
|
||||
virtualenv: /opt/jmri-monitor
|
||||
|
||||
- name: Deploy jmri-monitor script
|
||||
ansible.builtin.template:
|
||||
src: jmri-monitor.py.j2
|
||||
dest: /usr/local/bin/jmri-monitor
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0755'
|
||||
notify: Restart jmri-monitor
|
||||
|
||||
- name: Deploy jmri-monitor systemd unit
|
||||
ansible.builtin.template:
|
||||
src: jmri-monitor.service.j2
|
||||
dest: /etc/systemd/system/jmri-monitor.service
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0644'
|
||||
notify:
|
||||
- Reload systemd
|
||||
- Restart jmri-monitor
|
||||
|
||||
- name: Enable jmri-monitor service
|
||||
ansible.builtin.systemd:
|
||||
name: jmri-monitor
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2b — LCRR config repo (clone/pull .jmri from Gitea)
|
||||
# ---------------------------------------------------------------------------
|
||||
- name: Ensure jmri .ssh directory exists
|
||||
ansible.builtin.file:
|
||||
path: /home/jmri/.ssh
|
||||
state: directory
|
||||
owner: "{{ jmri_user }}"
|
||||
group: "{{ jmri_group }}"
|
||||
mode: '0700'
|
||||
|
||||
- name: Deploy jmri SSH config for Gitea
|
||||
ansible.builtin.copy:
|
||||
dest: /home/jmri/.ssh/config
|
||||
owner: "{{ jmri_user }}"
|
||||
group: "{{ jmri_group }}"
|
||||
mode: '0600'
|
||||
content: |
|
||||
Host gitea.mk-labs.cloud
|
||||
HostName gitea.mk-labs.cloud
|
||||
User git
|
||||
Port 2221
|
||||
IdentityFile {{ jmri_gitea_key }}
|
||||
StrictHostKeyChecking accept-new
|
||||
|
||||
- name: Clone LCRR config repo if not present
|
||||
ansible.builtin.git:
|
||||
repo: "{{ jmri_lcrr_repo }}"
|
||||
dest: "{{ jmri_home }}/LCRR"
|
||||
version: main
|
||||
accept_hostkey: true
|
||||
key_file: "{{ jmri_gitea_key }}"
|
||||
update: false
|
||||
become_user: "{{ jmri_user }}"
|
||||
when: jmri_lcrr_repo | length > 0
|
||||
notify: Restart jmri
|
||||
|
||||
- name: Remove auto-generated .jmri dir if it exists (will be replaced by symlink)
|
||||
ansible.builtin.file:
|
||||
path: "{{ jmri_home }}/.jmri"
|
||||
state: absent
|
||||
when:
|
||||
- jmri_lcrr_repo | length > 0
|
||||
|
||||
- name: Link .jmri config from LCRR repo
|
||||
ansible.builtin.file:
|
||||
src: "{{ jmri_home }}/LCRR/.jmri"
|
||||
dest: "{{ jmri_home }}/.jmri"
|
||||
state: link
|
||||
owner: "{{ jmri_user }}"
|
||||
group: "{{ jmri_group }}"
|
||||
force: true
|
||||
when: jmri_lcrr_repo | length > 0
|
||||
notify: Restart jmri
|
||||
|
||||
- name: Check if JMRI is already installed at correct version
|
||||
ansible.builtin.stat:
|
||||
path: "{{ jmri_install_dir }}/JmriFaceless"
|
||||
@@ -79,9 +211,60 @@
|
||||
- Reload systemd
|
||||
- Restart jmri
|
||||
|
||||
- name: Enable and start JMRI service
|
||||
- name: Enable jmri service (monitor manages start/stop — do not start directly)
|
||||
ansible.builtin.systemd:
|
||||
name: jmri
|
||||
enabled: true
|
||||
state: started
|
||||
enabled: false
|
||||
daemon_reload: true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3 — X11 remote GUI access
|
||||
# ---------------------------------------------------------------------------
|
||||
- name: Install xauth (required for SSH X11 forwarding)
|
||||
ansible.builtin.apt:
|
||||
name: xauth
|
||||
state: present
|
||||
|
||||
- name: Remove old jmri X11 sshd drop-in if present (renamed)
|
||||
ansible.builtin.file:
|
||||
path: /etc/ssh/sshd_config.d/20-jmri-x11.conf
|
||||
state: absent
|
||||
notify: Restart sshd
|
||||
|
||||
- name: Deploy sshd drop-in to enable X11 forwarding (must load before hardening)
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/ssh/sshd_config.d/09-jmri-x11.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0644'
|
||||
content: |
|
||||
# Allow X11 forwarding for JMRI GUI sessions (jmri role)
|
||||
# Must be numbered below 10-mk-labs-hardening.conf — first match wins.
|
||||
X11Forwarding yes
|
||||
X11UseLocalhost yes
|
||||
notify: Restart sshd
|
||||
|
||||
- name: Deploy jmri-gui script
|
||||
ansible.builtin.template:
|
||||
src: jmri-gui.j2
|
||||
dest: /usr/local/bin/jmri-gui
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0755'
|
||||
|
||||
- name: Deploy sudoers drop-in for jmri-gui (wed can manage jmri service)
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/sudoers.d/jmri-gui
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0440'
|
||||
validate: 'visudo -cf %s'
|
||||
content: |
|
||||
# jmri user can manage its own service (for jmri-gui interactive sessions)
|
||||
jmri ALL=(root) NOPASSWD: /usr/bin/systemctl start jmri.service
|
||||
jmri ALL=(root) NOPASSWD: /usr/bin/systemctl stop jmri.service
|
||||
jmri ALL=(root) NOPASSWD: /opt/jmri-monitor/bin/python3
|
||||
# wed retains service control for automation/admin use
|
||||
wed ALL=(root) NOPASSWD: /usr/bin/systemctl start jmri.service
|
||||
wed ALL=(root) NOPASSWD: /usr/bin/systemctl stop jmri.service
|
||||
wed ALL=(root) NOPASSWD: /opt/jmri-monitor/bin/python3
|
||||
|
||||
25
ansible/roles/jmri/templates/99-jmri-devices.rules.j2
Normal file
25
ansible/roles/jmri/templates/99-jmri-devices.rules.j2
Normal 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"
|
||||
111
ansible/roles/jmri/templates/jmri-gui.j2
Normal file
111
ansible/roles/jmri/templates/jmri-gui.j2
Normal 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
|
||||
156
ansible/roles/jmri/templates/jmri-monitor.py.j2
Normal file
156
ansible/roles/jmri/templates/jmri-monitor.py.j2
Normal 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. 23–6
|
||||
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()
|
||||
20
ansible/roles/jmri/templates/jmri-monitor.service.j2
Normal file
20
ansible/roles/jmri/templates/jmri-monitor.service.j2
Normal 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
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user