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 +39 -0
- evmqtt/__main__.py +163 -0
- evmqtt/config.py +304 -0
- evmqtt/core/__init__.py +65 -0
- evmqtt/core/devices.py +238 -0
- evmqtt/core/events.py +59 -0
- evmqtt/core/keys.py +104 -0
- evmqtt/core/reader.py +222 -0
- evmqtt/core/watcher.py +67 -0
- evmqtt/gateway.py +435 -0
- evmqtt/ha.py +227 -0
- evmqtt/mqtt_client.py +232 -0
- evmqtt/state.py +79 -0
- evmqtt/supervisor.py +118 -0
- evmqtt/sysinfo.py +61 -0
- evmqtt-2.0.0.dist-info/METADATA +577 -0
- evmqtt-2.0.0.dist-info/RECORD +21 -0
- evmqtt-2.0.0.dist-info/WHEEL +5 -0
- evmqtt-2.0.0.dist-info/entry_points.txt +2 -0
- evmqtt-2.0.0.dist-info/licenses/LICENSE +21 -0
- evmqtt-2.0.0.dist-info/top_level.txt +1 -0
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)
|
evmqtt/core/__init__.py
ADDED
|
@@ -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
|
+
]
|