mt4ctl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mt4ctl/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """mt4ctl — an MCP server for managing headless MetaTrader terminals.
2
+
3
+ Manage MetaTrader 4 terminals that run under Wine + systemd on remote hosts
4
+ (native Linux or WSL2) entirely over SSH: status, logs, screenshots, lifecycle
5
+ control, and headless first-login — exposed as Model Context Protocol tools.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ __all__ = ["__version__"]
mt4ctl/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Enable ``python -m mt4ctl`` to launch the MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .server import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
mt4ctl/auth.py ADDED
@@ -0,0 +1,57 @@
1
+ """Credential resolution for terminal logins.
2
+
3
+ Resolution order (first hit wins), mirroring common CLI conventions:
4
+
5
+ 1. an explicit ``password`` passed by the caller
6
+ 2. ``MT4CTL_PASSWORD_<account>`` environment variable
7
+ 3. the ``<account>`` key in a JSON secrets file
8
+ (``$MT4CTL_CREDENTIALS`` or ``$XDG_CONFIG_HOME/mt4ctl/credentials.json``)
9
+
10
+ Passwords are never logged, echoed, or written anywhere except the transient
11
+ remote login config, which the login flow shreds after use.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ from pathlib import Path
19
+
20
+ from .errors import CredentialError
21
+
22
+
23
+ def secrets_file() -> Path:
24
+ """Return the path of the JSON secrets file (may not exist)."""
25
+ if env := os.environ.get("MT4CTL_CREDENTIALS"):
26
+ return Path(env).expanduser()
27
+ xdg = os.environ.get("XDG_CONFIG_HOME")
28
+ base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
29
+ return base / "mt4ctl" / "credentials.json"
30
+
31
+
32
+ def resolve_password(account: str, explicit: str | None = None) -> str:
33
+ """Return the password for *account*, raising :class:`CredentialError` if absent."""
34
+ if explicit:
35
+ return explicit
36
+
37
+ if env := os.environ.get(f"MT4CTL_PASSWORD_{account}"):
38
+ return env
39
+
40
+ path = secrets_file()
41
+ if path.is_file():
42
+ if os.name == "posix" and (path.stat().st_mode & 0o077):
43
+ raise CredentialError(
44
+ f"secrets file {path} is readable by group/other; run: chmod 600 {path}"
45
+ )
46
+ try:
47
+ data = json.loads(path.read_text(encoding="utf-8"))
48
+ except (json.JSONDecodeError, OSError) as exc:
49
+ raise CredentialError(f"could not read secrets file {path}: {exc}") from None
50
+ if isinstance(data, dict) and account in data:
51
+ return str(data[account])
52
+
53
+ raise CredentialError(
54
+ f"no password for account {account!r}. Provide it via the password "
55
+ f"argument, the MT4CTL_PASSWORD_{account} env var, or the "
56
+ f"{account!r} key in {path}."
57
+ )
mt4ctl/config.py ADDED
@@ -0,0 +1,203 @@
1
+ """Load and validate the terminal registry from YAML.
2
+
3
+ The registry path is resolved as:
4
+
5
+ 1. an explicit ``path`` argument — must exist
6
+ 2. ``MT4CTL_CONFIG`` — if set, must exist (it does *not* fall back to the search
7
+ paths, so a typo fails loudly instead of using a stale file)
8
+ 3. otherwise the first existing of ``$XDG_CONFIG_HOME/mt4ctl/terminals.yaml``
9
+ (or ``~/.config/...``) then ``./terminals.yaml``
10
+
11
+ This keeps real infrastructure details out of the source tree: ship the
12
+ ``examples/terminals.example.yaml`` template, keep the populated file private.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import yaml
23
+
24
+ from .errors import ConfigError
25
+ from .models import Env, Host, HostKind, Registry, Terminal
26
+
27
+ # Ids become systemd targets, dict keys, and (sanitized) filenames, so keep them
28
+ # to a conservative, shell- and protocol-safe character set.
29
+ _ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
30
+ _DISTRO_RE = re.compile(r"^[A-Za-z0-9._-]+$")
31
+
32
+
33
+ def candidate_paths() -> list[Path]:
34
+ """Default registry search paths (XDG config dir, then CWD); env excluded."""
35
+ xdg = os.environ.get("XDG_CONFIG_HOME")
36
+ base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
37
+ return [base / "mt4ctl" / "terminals.yaml", Path.cwd() / "terminals.yaml"]
38
+
39
+
40
+ def resolve_path(path: str | os.PathLike[str] | None = None) -> Path:
41
+ """Find the registry file, raising :class:`ConfigError` if none exists.
42
+
43
+ An explicit ``path`` or a set ``MT4CTL_CONFIG`` must point to an existing
44
+ file — neither falls back to the search paths, so a typo'd path fails loudly
45
+ instead of silently using a stale registry.
46
+ """
47
+ if path is not None:
48
+ resolved = Path(path).expanduser()
49
+ if not resolved.is_file():
50
+ raise ConfigError(f"registry file not found: {resolved}")
51
+ return resolved
52
+
53
+ env = os.environ.get("MT4CTL_CONFIG")
54
+ if env:
55
+ resolved = Path(env).expanduser()
56
+ if not resolved.is_file():
57
+ raise ConfigError(
58
+ f"MT4CTL_CONFIG is set to {resolved}, but that file does not exist. "
59
+ "Create it, fix the path, or unset MT4CTL_CONFIG to use the default "
60
+ "search paths."
61
+ )
62
+ return resolved
63
+
64
+ for candidate in candidate_paths():
65
+ if candidate.is_file():
66
+ return candidate
67
+
68
+ searched = "\n ".join(str(p) for p in candidate_paths())
69
+ raise ConfigError(
70
+ "mt4ctl could not find a terminal registry.\n\n"
71
+ "Create one:\n"
72
+ " mkdir -p ~/.config/mt4ctl\n"
73
+ " cp examples/terminals.example.yaml ~/.config/mt4ctl/terminals.yaml\n\n"
74
+ "Or set MT4CTL_CONFIG=/absolute/path/to/terminals.yaml.\nSearched:\n " + searched
75
+ )
76
+
77
+
78
+ def _require(mapping: dict[str, Any], key: str, where: str) -> Any:
79
+ if key not in mapping:
80
+ raise ConfigError(f"{where}: missing required field {key!r}")
81
+ return mapping[key]
82
+
83
+
84
+ def _require_mapping(value: Any, where: str) -> dict[str, Any]:
85
+ if not isinstance(value, dict):
86
+ raise ConfigError(f"{where}: expected a mapping, got {type(value).__name__}")
87
+ return value
88
+
89
+
90
+ def _validate_id(kind: str, value: str) -> None:
91
+ if not _ID_RE.match(value):
92
+ raise ConfigError(
93
+ f"{kind} id {value!r} is invalid; use letters, digits, '.', '_', '-' only"
94
+ )
95
+ if kind == "terminal" and value == "all":
96
+ raise ConfigError(
97
+ "terminal id 'all' is reserved (mt4_status uses it for every terminal)"
98
+ )
99
+
100
+
101
+ def _clean_field(value: str, field: str, where: str) -> str:
102
+ # These values are embedded in the '|'-delimited status protocol and in
103
+ # shell scripts; forbid separators and control characters outright.
104
+ if any(c in value for c in ("|", "\n", "\r")):
105
+ raise ConfigError(f"{where}: {field} must not contain '|' or newlines")
106
+ return value
107
+
108
+
109
+ def _build_host(host_id: str, raw: dict[str, Any]) -> Host:
110
+ where = f"host {host_id!r}"
111
+ try:
112
+ kind = HostKind(raw.get("kind", "native"))
113
+ except ValueError:
114
+ raise ConfigError(
115
+ f"{where}: invalid kind {raw.get('kind')!r} (use native or wsl)"
116
+ ) from None
117
+ ssh = str(_require(raw, "ssh", where))
118
+ if ssh.startswith("-"):
119
+ raise ConfigError(f"{where}: ssh destination must not start with '-'")
120
+ distro = raw.get("wsl_distro")
121
+ if distro is not None and not _DISTRO_RE.match(str(distro)):
122
+ raise ConfigError(f"{where}: wsl_distro {distro!r} has invalid characters")
123
+ broker = raw.get("broker_host")
124
+ try:
125
+ return Host(
126
+ id=host_id,
127
+ ssh=ssh,
128
+ kind=kind,
129
+ wsl_distro=str(distro) if distro is not None else None,
130
+ broker_host=_clean_field(str(broker), "broker_host", where)
131
+ if broker is not None
132
+ else None,
133
+ )
134
+ except ValueError as exc: # invariant from Host.__post_init__
135
+ raise ConfigError(str(exc)) from None
136
+
137
+
138
+ def _build_terminal(term_id: str, raw: dict[str, Any], hosts: dict[str, Host]) -> Terminal:
139
+ where = f"terminal {term_id!r}"
140
+ host_id = str(_require(raw, "host", where))
141
+ if host_id not in hosts:
142
+ raise ConfigError(f"{where}: references unknown host {host_id!r}")
143
+ try:
144
+ env = Env(raw.get("env", "demo"))
145
+ except ValueError:
146
+ raise ConfigError(
147
+ f"{where}: invalid env {raw.get('env')!r} (use demo or live)"
148
+ ) from None
149
+ account = raw.get("account")
150
+ window_match = raw.get("window_match")
151
+ return Terminal(
152
+ id=term_id,
153
+ host=host_id,
154
+ service=_clean_field(str(_require(raw, "service", where)), "service", where),
155
+ data_dir=_clean_field(str(_require(raw, "data_dir", where)), "data_dir", where),
156
+ display=_clean_field(str(raw.get("display", ":0")), "display", where),
157
+ account=_clean_field(str(account), "account", where) if account is not None else None,
158
+ env=env,
159
+ window_match=(
160
+ _clean_field(str(window_match), "window_match", where)
161
+ if window_match is not None
162
+ else None
163
+ ),
164
+ )
165
+
166
+
167
+ def parse_registry(data: dict[str, Any]) -> Registry:
168
+ """Build a :class:`Registry` from already-parsed YAML data."""
169
+ if not isinstance(data, dict):
170
+ raise ConfigError("registry root must be a mapping")
171
+ raw_hosts = data.get("hosts") or {}
172
+ raw_terminals = data.get("terminals") or {}
173
+ _require_mapping(raw_hosts, "hosts")
174
+ _require_mapping(raw_terminals, "terminals")
175
+ if not raw_hosts:
176
+ raise ConfigError("registry defines no hosts")
177
+ if not raw_terminals:
178
+ raise ConfigError("registry defines no terminals")
179
+
180
+ hosts: dict[str, Host] = {}
181
+ for hid, raw in raw_hosts.items():
182
+ _validate_id("host", str(hid))
183
+ hosts[str(hid)] = _build_host(str(hid), _require_mapping(raw, f"host {hid!r}"))
184
+
185
+ terminals: dict[str, Terminal] = {}
186
+ for tid, raw in raw_terminals.items():
187
+ _validate_id("terminal", str(tid))
188
+ terminals[str(tid)] = _build_terminal(
189
+ str(tid), _require_mapping(raw, f"terminal {tid!r}"), hosts
190
+ )
191
+ return Registry(hosts=hosts, terminals=terminals)
192
+
193
+
194
+ def load_registry(path: str | os.PathLike[str] | None = None) -> Registry:
195
+ """Resolve, read, and validate the registry file."""
196
+ resolved = resolve_path(path)
197
+ try:
198
+ data = yaml.safe_load(resolved.read_text(encoding="utf-8"))
199
+ except yaml.YAMLError as exc:
200
+ raise ConfigError(f"{resolved}: invalid YAML: {exc}") from None
201
+ if data is None:
202
+ raise ConfigError(f"{resolved}: file is empty")
203
+ return parse_registry(data)
mt4ctl/errors.py ADDED
@@ -0,0 +1,47 @@
1
+ """Typed errors with actionable messages.
2
+
3
+ Errors raised here are caught at the MCP boundary and surfaced to the calling
4
+ agent verbatim, so each message should tell the agent *how to recover* rather
5
+ than just *what went wrong*.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class Mt4ctlError(Exception):
12
+ """Base class for all expected, user-facing errors."""
13
+
14
+
15
+ class ConfigError(Mt4ctlError):
16
+ """The registry file is missing, malformed, or internally inconsistent."""
17
+
18
+
19
+ class UnknownTargetError(Mt4ctlError):
20
+ """A referenced terminal or host does not exist in the registry."""
21
+
22
+
23
+ class RemoteCommandError(Mt4ctlError):
24
+ """A command executed over SSH returned a non-zero exit status."""
25
+
26
+ def __init__(self, host: str, returncode: int, stderr: str) -> None:
27
+ self.host = host
28
+ self.returncode = returncode
29
+ self.stderr = stderr.strip()
30
+ detail = self.stderr or "(no stderr)"
31
+ super().__init__(f"remote command on {host!r} failed (exit {returncode}): {detail}")
32
+
33
+
34
+ class ConfirmationRequiredError(Mt4ctlError):
35
+ """A mutating operation targeted a live terminal without ``confirm=true``."""
36
+
37
+ def __init__(self, terminal_id: str, action: str) -> None:
38
+ self.terminal_id = terminal_id
39
+ self.action = action
40
+ super().__init__(
41
+ f"refusing to {action} live terminal {terminal_id!r} without "
42
+ f"confirmation; pass confirm=true to proceed"
43
+ )
44
+
45
+
46
+ class CredentialError(Mt4ctlError):
47
+ """A credential could not be resolved for a login operation."""
mt4ctl/login.py ADDED
@@ -0,0 +1,171 @@
1
+ """Headless first-login for a terminal.
2
+
3
+ MetaTrader stores its password encrypted with a machine-bound key, so a terminal
4
+ copied to a new host cannot auto-login until it authenticates once on that host.
5
+ This module automates that bootstrap:
6
+
7
+ 1. stop the systemd unit (frees the terminal slot)
8
+ 2. write a transient startup config (login/password/server), created fresh by
9
+ ``mktemp`` with mode 600, inside the unit's working directory
10
+ 3. launch the terminal once, in its own process group, reusing the unit's
11
+ ``WorkingDirectory`` / ``WINEPREFIX`` / ``DISPLAY``
12
+ 4. wait for ``config/accounts.ini`` to become newer than a marker stamped right
13
+ before launch — MetaTrader's signal that authentication succeeded and
14
+ credentials were re-encrypted for this host
15
+ 5. an ``EXIT``/signal trap *always* kills that process group (siblings share the
16
+ Wine prefix, so a blanket ``wineserver -k`` is wrong) and shreds the config,
17
+ even if an earlier step fails
18
+ 6. restart the unit, which now auto-logins from the saved file
19
+
20
+ After this runs once, the terminal reconnects on its own across restarts.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from . import auth, scripts, ssh
26
+ from .errors import ConfirmationRequiredError, Mt4ctlError, RemoteCommandError
27
+ from .models import Env, Registry
28
+
29
+
30
+ def build_login_script(
31
+ service: str,
32
+ data_dir: str,
33
+ account: str,
34
+ server: str,
35
+ password: str,
36
+ *,
37
+ wait_seconds: int = 120,
38
+ ) -> str:
39
+ """Build the self-contained user-side bootstrap script (see module docstring)."""
40
+ # Credentials are written into a quoted heredoc below (no shell expansion),
41
+ # but a newline could still smuggle in a heredoc terminator or corrupt the
42
+ # ini. MT4 login/server/password are single-line tokens, so reject newlines.
43
+ for field, value in (("account", account), ("server", server), ("password", password)):
44
+ if "\n" in value or "\r" in value:
45
+ raise Mt4ctlError(f"{field} must not contain newlines")
46
+ svc = scripts.sh_quote(service)
47
+ ddir = scripts.sh_quote(data_dir)
48
+ return f"""\
49
+ set +e
50
+ SVC={svc}
51
+ DDIR={ddir}
52
+ WORKDIR=$(systemctl show -p WorkingDirectory --value "$SVC" 2>/dev/null)
53
+ ENV=$(systemctl show -p Environment --value "$SVC" 2>/dev/null)
54
+ WP=$(echo "$ENV" | tr ' ' '\\n' | sed -n 's/^WINEPREFIX=//p' | head -1)
55
+ DISP=$(echo "$ENV" | tr ' ' '\\n' | sed -n 's/^DISPLAY=//p' | head -1)
56
+ [ -z "$WORKDIR" ] && WORKDIR="$DDIR"
57
+ [ -z "$DISP" ] && DISP=:0
58
+
59
+ INI=""
60
+ MARKER=""
61
+ PGID=""
62
+ cleanup() {{
63
+ [ -n "$PGID" ] && kill -TERM -"$PGID" 2>/dev/null
64
+ sleep 1
65
+ [ -n "$PGID" ] && kill -KILL -"$PGID" 2>/dev/null
66
+ [ -n "$INI" ] && {{ shred -u "$INI" 2>/dev/null || rm -f "$INI"; }}
67
+ [ -n "$MARKER" ] && rm -f "$MARKER" 2>/dev/null
68
+ }}
69
+ trap cleanup EXIT HUP INT TERM
70
+
71
+ INI=$(mktemp "$WORKDIR/mt4ctl-login.XXXXXX.ini" 2>/dev/null) || {{ echo "LOGIN|error=mktemp"; exit 1; }}
72
+ chmod 600 "$INI"
73
+ INIBASE=$(basename "$INI")
74
+ ACCFILE="$DDIR/config/accounts.ini"
75
+ MARKER=$(mktemp 2>/dev/null) || MARKER="/tmp/mt4ctl-marker.$$"
76
+
77
+ cat > "$INI" <<'INICFG'
78
+ [Common]
79
+ Login={account}
80
+ Password={password}
81
+ Server={server}
82
+ KeepPrivate=1
83
+ NewsEnable=false
84
+ CertInstall=false
85
+ [Experts]
86
+ AllowLiveTrading=false
87
+ Enabled=false
88
+ INICFG
89
+
90
+ cd "$WORKDIR" || {{ echo "LOGIN|error=workdir"; exit 1; }}
91
+ : > "$MARKER"
92
+ sleep 1
93
+ WINEPREFIX="$WP" DISPLAY="$DISP" setsid wine terminal.exe /portable "$INIBASE" >/dev/null 2>&1 &
94
+ PGID=$!
95
+
96
+ ok=0
97
+ for _ in $(seq 1 {wait_seconds}); do
98
+ if [ "$ACCFILE" -nt "$MARKER" ]; then ok=1; break; fi
99
+ sleep 1
100
+ done
101
+ echo "LOGIN|ok=$ok"
102
+ """
103
+
104
+
105
+ async def login(
106
+ registry: Registry,
107
+ terminal_id: str,
108
+ *,
109
+ account: str | None = None,
110
+ server: str,
111
+ password: str | None = None,
112
+ confirm: bool = False,
113
+ wait_seconds: int = 120,
114
+ ) -> str:
115
+ """Perform a one-time headless login, then restart the unit for auto-reconnect.
116
+
117
+ Args:
118
+ terminal_id: terminal to log in.
119
+ account: login number; defaults to the terminal's configured account.
120
+ server: broker server name, e.g. ``ExampleBroker-Demo``.
121
+ password: explicit password; otherwise resolved via :mod:`mt4ctl.auth`.
122
+ confirm: required (``True``) for live terminals.
123
+ wait_seconds: how long to wait for authentication to land.
124
+
125
+ Returns:
126
+ A short human-readable summary of the outcome.
127
+ """
128
+ term = registry.terminal(terminal_id)
129
+ host = registry.host_of(term)
130
+ if term.env is Env.LIVE and not confirm:
131
+ raise ConfirmationRequiredError(terminal_id, "login")
132
+
133
+ login_account = account or term.account
134
+ if not login_account:
135
+ raise Mt4ctlError(f"terminal {terminal_id!r} has no account configured; pass account=")
136
+ secret = auth.resolve_password(login_account, password)
137
+
138
+ # Stop the unit so the one-shot owns the terminal slot; abort if it fails.
139
+ await ssh.run(
140
+ host, scripts.build_control_script(term.service, "stop"), root=True, check=True
141
+ )
142
+ script = build_login_script(
143
+ term.service, term.data_dir, login_account, server, secret, wait_seconds=wait_seconds
144
+ )
145
+ result = await ssh.run(host, script, timeout=wait_seconds + 30, check=False)
146
+
147
+ # Bring the unit back; it auto-logins from the now-saved accounts.ini.
148
+ try:
149
+ await ssh.run(
150
+ host, scripts.build_control_script(term.service, "start"), root=True, check=True
151
+ )
152
+ restarted = True
153
+ except RemoteCommandError:
154
+ restarted = False
155
+
156
+ if "LOGIN|ok=1" in result.stdout:
157
+ msg = (
158
+ f"{terminal_id}: logged in to account {login_account} on {server}; "
159
+ f"credentials saved."
160
+ )
161
+ else:
162
+ msg = (
163
+ f"{terminal_id}: login did NOT confirm within {wait_seconds}s "
164
+ f"(account {login_account} on {server}); check `mt4_logs` and verify the "
165
+ f"server name and password."
166
+ )
167
+ # The restart outcome is reported on every path so a stopped unit is never
168
+ # hidden behind a login message.
169
+ if restarted:
170
+ return msg + " Unit restarted for auto-reconnect."
171
+ return msg + " WARNING: restarting the unit failed — run `mt4_control` start."
mt4ctl/models.py ADDED
@@ -0,0 +1,139 @@
1
+ """Domain models for the terminal registry.
2
+
3
+ The registry is intentionally plain data: a set of *hosts* (machines reachable
4
+ over SSH) and a set of *terminals* (MetaTrader instances managed by ``systemd``
5
+ on those hosts). Everything else in :mod:`mt4ctl` operates on these models.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from enum import StrEnum
12
+
13
+ from .errors import UnknownTargetError
14
+
15
+
16
+ class HostKind(StrEnum):
17
+ """How commands are dispatched on a host.
18
+
19
+ ``NATIVE``
20
+ A plain Linux box. Commands run directly; root is obtained via ``sudo``.
21
+ ``WSL``
22
+ A Windows host running WSL2. Commands are wrapped in
23
+ ``wsl -d <distro> -- ...`` and root is obtained via ``wsl -u root``.
24
+ """
25
+
26
+ NATIVE = "native"
27
+ WSL = "wsl"
28
+
29
+
30
+ class Env(StrEnum):
31
+ """Risk tier of a terminal.
32
+
33
+ ``LIVE`` terminals trade real money, so mutating operations on them require
34
+ an explicit ``confirm=true`` from the caller.
35
+ """
36
+
37
+ DEMO = "demo"
38
+ LIVE = "live"
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class Host:
43
+ """A machine reachable over SSH that runs one or more terminals."""
44
+
45
+ id: str
46
+ ssh: str
47
+ """SSH destination — an alias from ``~/.ssh/config`` or ``user@host``."""
48
+ kind: HostKind = HostKind.NATIVE
49
+ wsl_distro: str | None = None
50
+ """Required when :attr:`kind` is :data:`HostKind.WSL`."""
51
+ broker_host: str | None = None
52
+ """Optional broker hostname used to detect live connections via ``ss``."""
53
+
54
+ def __post_init__(self) -> None:
55
+ if self.kind is HostKind.WSL and not self.wsl_distro:
56
+ raise ValueError(f"host {self.id!r} is kind=wsl but has no wsl_distro")
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class Terminal:
61
+ """A single MetaTrader terminal managed by ``systemd``."""
62
+
63
+ id: str
64
+ host: str
65
+ """Id of the owning :class:`Host`."""
66
+ service: str
67
+ """The ``systemd`` unit name, e.g. ``mt4-demo3``."""
68
+ data_dir: str
69
+ """Absolute path to the terminal data folder (parent of ``logs/``)."""
70
+ display: str = ":0"
71
+ """X11 display used for screenshots, e.g. ``:99``."""
72
+ account: str | None = None
73
+ """Login number — labels output and locates the window for screenshots."""
74
+ env: Env = Env.DEMO
75
+ window_match: str | None = None
76
+ """Override window-search string; defaults to :attr:`account`."""
77
+
78
+ @property
79
+ def window_query(self) -> str | None:
80
+ """The string used to find this terminal's window with ``xdotool``."""
81
+ return self.window_match or self.account
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class TerminalStatus:
86
+ """Resolved runtime status of a terminal."""
87
+
88
+ id: str
89
+ host: str
90
+ env: Env
91
+ account: str | None
92
+ service_state: str
93
+ """Raw ``systemctl is-active`` value: ``active`` / ``inactive`` / ``failed``…"""
94
+ connected: bool | None
95
+ """True/False if a broker connection could be determined, else ``None``."""
96
+ log_age_seconds: int | None
97
+ """Seconds since the newest log file was written, or ``None`` if no logs."""
98
+ last_event: str | None = None
99
+ """Most recent connection-related log line, if any."""
100
+
101
+ @property
102
+ def healthy(self) -> bool:
103
+ """A terminal is healthy when its service is active and it is connected."""
104
+ return self.service_state == "active" and self.connected is True
105
+
106
+
107
+ @dataclass(frozen=True, slots=True)
108
+ class Registry:
109
+ """The full set of configured hosts and terminals."""
110
+
111
+ hosts: dict[str, Host] = field(default_factory=dict)
112
+ terminals: dict[str, Terminal] = field(default_factory=dict)
113
+
114
+ def terminal(self, terminal_id: str) -> Terminal:
115
+ try:
116
+ return self.terminals[terminal_id]
117
+ except KeyError:
118
+ raise UnknownTargetError(
119
+ f"unknown terminal {terminal_id!r}; known: "
120
+ f"{', '.join(sorted(self.terminals)) or '(none)'}"
121
+ ) from None
122
+
123
+ def host(self, host_id: str) -> Host:
124
+ try:
125
+ return self.hosts[host_id]
126
+ except KeyError:
127
+ raise UnknownTargetError(
128
+ f"unknown host {host_id!r}; known: {', '.join(sorted(self.hosts)) or '(none)'}"
129
+ ) from None
130
+
131
+ def host_of(self, terminal: Terminal) -> Host:
132
+ return self.host(terminal.host)
133
+
134
+ def by_host(self) -> dict[str, list[Terminal]]:
135
+ """Group terminals by their host id (stable order)."""
136
+ grouped: dict[str, list[Terminal]] = {}
137
+ for term in self.terminals.values():
138
+ grouped.setdefault(term.host, []).append(term)
139
+ return grouped