evmqtt 2.0.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.
evmqtt/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """
2
+ evmqtt - Linux input event to MQTT gateway.
3
+
4
+ This package captures Linux input device events (keyboards, IR remotes)
5
+ and publishes them to an MQTT broker for home automation integration.
6
+ evmqtt.core has no MQTT dependency; the daemon names below load lazily
7
+ so `import evmqtt.core` works without paho-mqtt.
8
+
9
+ https://github.com/odtgit/evmqtt
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib
15
+ from importlib.metadata import PackageNotFoundError, version
16
+ from typing import Any
17
+
18
+ try:
19
+ __version__ = version("evmqtt")
20
+ except PackageNotFoundError:
21
+ __version__ = "0.0.0+unknown"
22
+
23
+ __author__ = "odtgit"
24
+
25
+ _LAZY = {
26
+ "Config": "evmqtt.config",
27
+ "ConfigError": "evmqtt.config",
28
+ "Gateway": "evmqtt.gateway",
29
+ "MQTTClientWrapper": "evmqtt.mqtt_client",
30
+ }
31
+
32
+ __all__ = sorted(_LAZY)
33
+
34
+
35
+ def __getattr__(name: str) -> Any:
36
+ module = _LAZY.get(name)
37
+ if module is None:
38
+ raise AttributeError(f"module 'evmqtt' has no attribute {name!r}")
39
+ return getattr(importlib.import_module(module), name)
evmqtt/__main__.py ADDED
@@ -0,0 +1,163 @@
1
+ """evmqtt command line: python -m evmqtt, or the evmqtt script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import importlib
8
+ import json
9
+ import logging
10
+ import signal
11
+ import sys
12
+ from dataclasses import replace
13
+ from typing import TYPE_CHECKING
14
+
15
+ from evmqtt.config import LOG_LEVELS, Config, ConfigError
16
+ from evmqtt.core import list_devices
17
+
18
+ if TYPE_CHECKING:
19
+ from evmqtt.gateway import Gateway
20
+
21
+ logger = logging.getLogger("evmqtt")
22
+
23
+
24
+ def setup_logging(level: str = "info") -> None:
25
+ logging.basicConfig(
26
+ level=getattr(logging, level.upper()),
27
+ format="[%(asctime)s] %(levelname)s %(name)s: %(message)s",
28
+ datefmt="%Y-%m-%d %H:%M:%S",
29
+ stream=sys.stderr,
30
+ force=True,
31
+ )
32
+
33
+
34
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
35
+ parser = argparse.ArgumentParser(
36
+ prog="evmqtt", description="Linux input event to MQTT gateway"
37
+ )
38
+ parser.add_argument("-c", "--config", help="configuration file", default=None)
39
+ parser.add_argument(
40
+ "--log-level", choices=LOG_LEVELS, help="override log_level from the config"
41
+ )
42
+ parser.add_argument(
43
+ "-v", "--verbose", action="store_true", help="same as --log-level info"
44
+ )
45
+ parser.add_argument(
46
+ "-d", "--debug", action="store_true", help="same as --log-level debug"
47
+ )
48
+ parser.add_argument(
49
+ "--list-devices",
50
+ action="store_true",
51
+ help="list input devices with their stable ids and exit",
52
+ )
53
+ parser.add_argument(
54
+ "--auto-discover", action="store_true", help="force auto_discover on"
55
+ )
56
+ return parser.parse_args(argv)
57
+
58
+
59
+ def cli_log_level(args: argparse.Namespace) -> str | None:
60
+ if args.debug:
61
+ return "debug"
62
+ if args.verbose:
63
+ return "info"
64
+ return str(args.log_level) if args.log_level else None
65
+
66
+
67
+ def mqtt_import_error() -> ImportError | None:
68
+ try:
69
+ importlib.import_module("paho.mqtt.client")
70
+ except ImportError as e:
71
+ return e
72
+ return None
73
+
74
+
75
+ def list_devices_cmd() -> int:
76
+ from evmqtt.sysinfo import is_virtual
77
+
78
+ devices = list_devices()
79
+ if not devices:
80
+ print("No input devices found.")
81
+ return 1
82
+ print(f"Found {len(devices)} input device(s):")
83
+ for info in devices:
84
+ flags = []
85
+ if info.is_keyboard_like:
86
+ flags.append("keyboard")
87
+ if is_virtual(info):
88
+ flags.append("virtual")
89
+ default = info.is_keyboard_like and "virtual" not in flags
90
+ print(
91
+ f" {info.path:<20} {info.id:<44} {json.dumps(info.name)}"
92
+ f" [{', '.join(flags) or '-'}]{' (default)' if default else ''}"
93
+ )
94
+ return 0
95
+
96
+
97
+ async def run_gateway(gateway: Gateway) -> int:
98
+ loop = asyncio.get_running_loop()
99
+ signals = (signal.SIGINT, signal.SIGTERM)
100
+
101
+ def on_signal(sig: signal.Signals) -> None:
102
+ logger.info("Received %s, shutting down", sig.name)
103
+ gateway.request_stop()
104
+
105
+ for sig in signals:
106
+ loop.add_signal_handler(sig, on_signal, sig)
107
+ try:
108
+ await gateway.start()
109
+ await gateway.wait()
110
+ finally:
111
+ await gateway.stop()
112
+ for sig in signals:
113
+ loop.remove_signal_handler(sig)
114
+ err = gateway.fatal_error
115
+ if err is None:
116
+ return 0
117
+ if isinstance(err, (ConfigError, FileNotFoundError, PermissionError)):
118
+ logger.error("Configuration error: %s", err)
119
+ else:
120
+ logger.error("Fatal error: %r", err)
121
+ return 1
122
+
123
+
124
+ def main(argv: list[str] | None = None) -> int:
125
+ args = parse_args(argv)
126
+ override = cli_log_level(args)
127
+ setup_logging(override or "info")
128
+
129
+ if args.list_devices:
130
+ return list_devices_cmd()
131
+
132
+ missing = mqtt_import_error()
133
+ if missing is not None:
134
+ logger.error(
135
+ "The evmqtt daemon needs paho-mqtt: pip install 'evmqtt[mqtt]' (%s)",
136
+ missing,
137
+ )
138
+ return 1
139
+
140
+ try:
141
+ config = Config.load(args.config)
142
+ except (OSError, ValueError) as e:
143
+ logger.error("Configuration error: %s", e)
144
+ return 1
145
+ if args.auto_discover:
146
+ config = replace(config, auto_discover=True)
147
+ setup_logging(override or config.log_level)
148
+ for warning in config.warnings:
149
+ logger.warning("Config: %s", warning)
150
+ logger.info(
151
+ "Base topic '%s', discovery prefix '%s', state file '%s'",
152
+ config.base_topic,
153
+ config.discovery_prefix,
154
+ config.state_path,
155
+ )
156
+
157
+ from evmqtt.gateway import Gateway
158
+
159
+ return asyncio.run(run_gateway(Gateway(config)))
160
+
161
+
162
+ if __name__ == "__main__":
163
+ sys.exit(main())
evmqtt/config.py ADDED
@@ -0,0 +1,304 @@
1
+ """Configuration for the evmqtt MQTT daemon."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import re
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from platform import node as hostname
13
+ from typing import Any
14
+
15
+ from evmqtt.core import KeyState, slugify
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ HA_OPTIONS_PATH = Path("/data/options.json")
20
+ HA_STATE_FILE = Path("/data/evmqtt-state.json")
21
+
22
+ DEFAULT_KEYSTATES: frozenset[KeyState] = frozenset({KeyState.PRESS})
23
+ DEFAULT_DISCOVERY_PREFIX = "homeassistant"
24
+ DEFAULT_RESCAN_INTERVAL = 5.0
25
+ LEGACY_TOPIC = "homeassistant/sensor/evmqtt"
26
+ LOG_LEVELS = ("debug", "info", "warning", "error")
27
+
28
+ _KEYSTATE_BY_NAME: dict[str, KeyState] = {s.name: s for s in KeyState}
29
+ _KEYSTATE_BY_VALUE: dict[int, KeyState] = {s.value: s for s in KeyState}
30
+
31
+ # 1.x key -> 2.0 key
32
+ _ALIASES = {
33
+ "serverip": "mqtt_host",
34
+ "port": "mqtt_port",
35
+ "username": "mqtt_username",
36
+ "password": "mqtt_password",
37
+ "tls": "mqtt_tls",
38
+ "tls_ca": "mqtt_tls_ca",
39
+ }
40
+ _KNOWN = {
41
+ "mqtt_host",
42
+ "mqtt_port",
43
+ "mqtt_username",
44
+ "mqtt_password",
45
+ "mqtt_tls",
46
+ "mqtt_tls_ca",
47
+ "name",
48
+ "discovery_prefix",
49
+ "base_topic",
50
+ "topic",
51
+ "auto_discover",
52
+ "filter_keys_only",
53
+ "keystates",
54
+ "devices",
55
+ "enabled_devices",
56
+ "state_file",
57
+ "rescan_interval",
58
+ "cleanup_legacy",
59
+ "log_level",
60
+ }
61
+ _TOPIC_SEGMENT = re.compile(r"^[^#+\0]+$")
62
+
63
+
64
+ class ConfigError(ValueError):
65
+ pass
66
+
67
+
68
+ def parse_keystates(values: Any) -> frozenset[KeyState]:
69
+ """KeyState members, case-insensitive names or evdev values. None: PRESS."""
70
+ if values is None:
71
+ return DEFAULT_KEYSTATES
72
+ if isinstance(values, (str, bytes)) or not hasattr(values, "__iter__"):
73
+ raise ConfigError(f"keystates must be a list, got {values!r}")
74
+ normalized: set[KeyState] = set()
75
+ for v in values:
76
+ if isinstance(v, KeyState):
77
+ normalized.add(v)
78
+ elif isinstance(v, str) and v.upper() in _KEYSTATE_BY_NAME:
79
+ normalized.add(_KEYSTATE_BY_NAME[v.upper()])
80
+ elif isinstance(v, int) and not isinstance(v, bool) and v in _KEYSTATE_BY_VALUE:
81
+ normalized.add(_KEYSTATE_BY_VALUE[v])
82
+ else:
83
+ raise ConfigError(f"Invalid keystate: {v!r}")
84
+ if not normalized:
85
+ raise ConfigError("keystates cannot be empty")
86
+ return frozenset(normalized)
87
+
88
+
89
+ def host_slug() -> str:
90
+ return slugify(hostname() or "host")
91
+
92
+
93
+ def default_base_topic() -> str:
94
+ return f"evmqtt/{host_slug()}"
95
+
96
+
97
+ def node_id(base_topic: str) -> str:
98
+ """Gateway id from base_topic: 'evmqtt/my-host' -> 'my-host'."""
99
+ slug = slugify(base_topic.replace("/", "-"))
100
+ if slug.startswith("evmqtt-") and len(slug) > len("evmqtt-"):
101
+ slug = slug[len("evmqtt-") :]
102
+ return slug
103
+
104
+
105
+ def _check_topic(name: str, value: str) -> str:
106
+ value = value.strip().strip("/")
107
+ if not value or not all(_TOPIC_SEGMENT.match(p) for p in value.split("/")):
108
+ raise ConfigError(f"{name} is not a valid MQTT topic: {value!r}")
109
+ return value
110
+
111
+
112
+ def _str_list(name: str, value: Any) -> tuple[str, ...]:
113
+ if value is None:
114
+ return ()
115
+ if isinstance(value, str) or not isinstance(value, list):
116
+ raise ConfigError(f"{name} must be a list of strings")
117
+ return tuple(str(v) for v in value if v not in (None, ""))
118
+
119
+
120
+ def _bool(name: str, value: Any) -> bool:
121
+ if not isinstance(value, bool):
122
+ raise ConfigError(f"{name} must be true or false, got {value!r}")
123
+ return value
124
+
125
+
126
+ def _opt_str(value: Any) -> str | None:
127
+ if value is None or value == "":
128
+ return None
129
+ return str(value)
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class Config:
134
+ """Daemon configuration. mqtt_* None means unset (Supervisor may fill it)."""
135
+
136
+ mqtt_host: str | None = None
137
+ mqtt_port: int | None = None
138
+ mqtt_username: str | None = None
139
+ mqtt_password: str | None = None
140
+ mqtt_tls: bool | None = None
141
+ mqtt_tls_ca: str | None = None
142
+ name: str = ""
143
+ discovery_prefix: str = DEFAULT_DISCOVERY_PREFIX
144
+ base_topic: str = field(default_factory=default_base_topic)
145
+ legacy_topic: str = LEGACY_TOPIC
146
+ auto_discover: bool = True
147
+ keystates: frozenset[KeyState] = DEFAULT_KEYSTATES
148
+ devices: tuple[str, ...] = ()
149
+ enabled_devices: tuple[str, ...] = ()
150
+ state_file: Path | None = None
151
+ rescan_interval: float = DEFAULT_RESCAN_INTERVAL
152
+ cleanup_legacy: bool = True
153
+ log_level: str = "info"
154
+ addon: bool = False
155
+ warnings: tuple[str, ...] = ()
156
+
157
+ @property
158
+ def node_id(self) -> str:
159
+ return node_id(self.base_topic)
160
+
161
+ @property
162
+ def gateway_name(self) -> str:
163
+ return self.name or f"evmqtt {hostname() or 'gateway'}"
164
+
165
+ @property
166
+ def state_path(self) -> Path:
167
+ if self.state_file is not None:
168
+ return self.state_file
169
+ if self.addon:
170
+ return HA_STATE_FILE
171
+ state_dir = os.environ.get("STATE_DIRECTORY")
172
+ if state_dir:
173
+ return Path(state_dir.split(":")[0]) / "state.json"
174
+ xdg = os.environ.get("XDG_STATE_HOME") or str(Path.home() / ".local" / "state")
175
+ return Path(xdg) / "evmqtt" / "state.json"
176
+
177
+ @classmethod
178
+ def from_dict(cls, data: Mapping[str, Any], *, addon: bool = False) -> Config:
179
+ """Build from config.json or add-on options. Raises ConfigError."""
180
+ if not isinstance(data, Mapping):
181
+ raise ConfigError("configuration must be a JSON object")
182
+ warnings: list[str] = []
183
+ values: dict[str, Any] = {}
184
+ for key, value in data.items():
185
+ if key in _ALIASES:
186
+ new = _ALIASES[key]
187
+ if new in data:
188
+ warnings.append(f"'{key}' is ignored, '{new}' is set")
189
+ continue
190
+ warnings.append(f"'{key}' is deprecated, use '{new}'")
191
+ values[new] = value
192
+ elif key in _KNOWN:
193
+ values[key] = value
194
+ else:
195
+ warnings.append(f"unknown option '{key}' ignored")
196
+
197
+ tls = _bool("mqtt_tls", values["mqtt_tls"]) if "mqtt_tls" in values else None
198
+ tls_ca = _opt_str(values.get("mqtt_tls_ca"))
199
+ port = values.get("mqtt_port")
200
+ if port is not None:
201
+ if isinstance(port, bool) or not isinstance(port, int):
202
+ raise ConfigError(f"mqtt_port must be an integer, got {port!r}")
203
+ if not 1 <= port <= 65535:
204
+ raise ConfigError(f"mqtt_port must be between 1 and 65535, got {port}")
205
+
206
+ prefix = _check_topic(
207
+ "discovery_prefix",
208
+ str(values.get("discovery_prefix") or DEFAULT_DISCOVERY_PREFIX),
209
+ )
210
+ legacy = _opt_str(values.get("topic"))
211
+ base = _opt_str(values.get("base_topic"))
212
+ if legacy is not None:
213
+ legacy = _check_topic("topic", legacy)
214
+ if base is None and not (legacy + "/").startswith(prefix + "/"):
215
+ base = legacy
216
+ warnings.append(
217
+ f"'topic' is deprecated, use 'base_topic'; using '{legacy}'"
218
+ )
219
+ else:
220
+ warnings.append(
221
+ "'topic' is deprecated and only used to clean up 1.x "
222
+ f"discovery under '{legacy}'"
223
+ )
224
+ base = _check_topic("base_topic", base) if base else default_base_topic()
225
+ if (base + "/").startswith(prefix + "/"):
226
+ raise ConfigError(
227
+ f"base_topic '{base}' must not be under discovery_prefix '{prefix}'"
228
+ )
229
+
230
+ if "filter_keys_only" in values:
231
+ _bool("filter_keys_only", values["filter_keys_only"])
232
+ warnings.append(
233
+ "'filter_keys_only' is deprecated and ignored; keyboard-like "
234
+ "devices are selected by default, list others in 'devices'"
235
+ )
236
+
237
+ auto = values.get("auto_discover", True)
238
+ devices = _str_list("devices", values.get("devices"))
239
+ if not _bool("auto_discover", auto) and not devices:
240
+ raise ConfigError(
241
+ "'devices' must list at least one device when auto_discover is false"
242
+ )
243
+
244
+ rescan = values.get("rescan_interval", DEFAULT_RESCAN_INTERVAL)
245
+ if (
246
+ isinstance(rescan, bool)
247
+ or not isinstance(rescan, (int, float))
248
+ or rescan < 0
249
+ ):
250
+ raise ConfigError(f"rescan_interval must be >= 0, got {rescan!r}")
251
+
252
+ level = str(values.get("log_level") or "info").lower()
253
+ if level not in LOG_LEVELS:
254
+ raise ConfigError(f"log_level must be one of {', '.join(LOG_LEVELS)}")
255
+
256
+ state_file = _opt_str(values.get("state_file"))
257
+ return cls(
258
+ mqtt_host=_opt_str(values.get("mqtt_host")),
259
+ mqtt_port=port,
260
+ mqtt_username=_opt_str(values.get("mqtt_username")),
261
+ mqtt_password=_opt_str(values.get("mqtt_password")),
262
+ mqtt_tls=tls,
263
+ mqtt_tls_ca=tls_ca,
264
+ name=str(values.get("name") or ""),
265
+ discovery_prefix=prefix,
266
+ base_topic=base,
267
+ legacy_topic=legacy or f"{prefix}/sensor/evmqtt",
268
+ auto_discover=auto,
269
+ keystates=parse_keystates(values.get("keystates")),
270
+ devices=devices,
271
+ enabled_devices=_str_list("enabled_devices", values.get("enabled_devices")),
272
+ state_file=Path(state_file) if state_file else None,
273
+ rescan_interval=float(rescan),
274
+ cleanup_legacy=_bool("cleanup_legacy", values.get("cleanup_legacy", True)),
275
+ log_level=level,
276
+ addon=addon,
277
+ warnings=tuple(warnings),
278
+ )
279
+
280
+ @classmethod
281
+ def load(cls, config_path: str | Path | None = None) -> Config:
282
+ """Load from, in order: config_path, $EVMQTT_CONFIG, /data/options.json
283
+ (add-on), ./config.local.json, ./config.json."""
284
+ addon = False
285
+ if config_path is not None:
286
+ path = Path(config_path)
287
+ elif env_path := os.environ.get("EVMQTT_CONFIG"):
288
+ path = Path(env_path)
289
+ elif HA_OPTIONS_PATH.is_file():
290
+ path = HA_OPTIONS_PATH
291
+ addon = True
292
+ elif Path("config.local.json").is_file():
293
+ path = Path("config.local.json")
294
+ elif Path("config.json").is_file():
295
+ path = Path("config.json")
296
+ else:
297
+ raise FileNotFoundError(
298
+ "No configuration file found. "
299
+ "Create config.json or set EVMQTT_CONFIG environment variable."
300
+ )
301
+ logger.info("Loading configuration from '%s'", path)
302
+ with open(path, encoding="utf-8") as f:
303
+ data = json.load(f)
304
+ return cls.from_dict(data, addon=addon)
@@ -0,0 +1,65 @@
1
+ """evmqtt core: evdev discovery, asyncio reader, key/modifier handling.
2
+
3
+ No MQTT dependency. Runtime dependency: evdev.
4
+ """
5
+
6
+ from evmqtt.core.devices import (
7
+ SYSTEM_KEYS,
8
+ DeviceInfo,
9
+ InputDeviceLike,
10
+ describe,
11
+ has_key_events,
12
+ is_keyboard_code,
13
+ is_keyboard_like,
14
+ is_placeholder_serial,
15
+ list_devices,
16
+ make_device_id,
17
+ open_device,
18
+ phys_interface,
19
+ slugify,
20
+ )
21
+ from evmqtt.core.events import KeyEvent, KeyState, ReaderStopped, StopReason
22
+ from evmqtt.core.keys import (
23
+ DEFAULT_IGNORED,
24
+ DEFAULT_MODIFIERS,
25
+ KeyConfig,
26
+ ModifierTracker,
27
+ canonical_name,
28
+ key_name,
29
+ key_names,
30
+ )
31
+ from evmqtt.core.reader import DeviceReader, EventCallback, GrabMode, StoppedCallback
32
+ from evmqtt.core.watcher import DeviceCallback, DeviceWatcher
33
+
34
+ __all__ = [
35
+ "DEFAULT_IGNORED",
36
+ "DEFAULT_MODIFIERS",
37
+ "SYSTEM_KEYS",
38
+ "DeviceCallback",
39
+ "DeviceInfo",
40
+ "DeviceReader",
41
+ "DeviceWatcher",
42
+ "EventCallback",
43
+ "GrabMode",
44
+ "InputDeviceLike",
45
+ "KeyConfig",
46
+ "KeyEvent",
47
+ "KeyState",
48
+ "ModifierTracker",
49
+ "ReaderStopped",
50
+ "StopReason",
51
+ "StoppedCallback",
52
+ "canonical_name",
53
+ "describe",
54
+ "has_key_events",
55
+ "is_keyboard_code",
56
+ "is_keyboard_like",
57
+ "is_placeholder_serial",
58
+ "key_name",
59
+ "key_names",
60
+ "list_devices",
61
+ "make_device_id",
62
+ "open_device",
63
+ "phys_interface",
64
+ "slugify",
65
+ ]