mercury-proxy 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.
@@ -0,0 +1,51 @@
1
+ """
2
+ Прокси-сервер и REST API для счётчиков Инкотекс Меркурий.
3
+
4
+ Сервис подключает счётчики по последовательной шине или TCP, ждёт клиента,
5
+ говорящего на протоколе счётчика (обычно Конфигуратор), и пересылает его кадры
6
+ счётчику, которому они адресованы. REST API отдаёт команды ``mercury_base``
7
+ интеграциям вроде Home Assistant.
8
+
9
+ Конфигурация и цикл работы принадлежат `Service`; консольный скрипт пакета
10
+ собирает его из YAML-файла и останавливает по SIGINT или SIGTERM.
11
+
12
+ Copyright (c) 2023–2026 webtoucher
13
+ Distributed under the BSD 3-Clause license. See LICENSE.md for more info.
14
+ """
15
+
16
+ __version__ = "2.0.0"
17
+
18
+ from mercury_proxy.api import RestApi
19
+ from mercury_proxy.config import (
20
+ ApiConfig,
21
+ Config,
22
+ LoggingConfig,
23
+ MeterConfig,
24
+ ProxyConfig,
25
+ ServiceConfig,
26
+ load_config,
27
+ )
28
+ from mercury_proxy.exceptions import ConfigError, ProxyError, UnknownMeterError
29
+ from mercury_proxy.log import configure_logging
30
+ from mercury_proxy.meters import MeterHub
31
+ from mercury_proxy.proxy import SocketProxy
32
+ from mercury_proxy.service import Service
33
+
34
+ __all__ = [
35
+ "Config",
36
+ "MeterConfig",
37
+ "ProxyConfig",
38
+ "ApiConfig",
39
+ "LoggingConfig",
40
+ "ServiceConfig",
41
+ "load_config",
42
+ "configure_logging",
43
+ "ConfigError",
44
+ "ProxyError",
45
+ "UnknownMeterError",
46
+ "MeterHub",
47
+ "SocketProxy",
48
+ "RestApi",
49
+ "Service",
50
+ "__version__",
51
+ ]
@@ -0,0 +1,91 @@
1
+ """
2
+ Точка входа командной строки: ``python -m mercury_proxy`` или ``mercury-proxy``.
3
+
4
+ Она загружает конфигурацию, открывает файл лога и работает до прихода SIGINT
5
+ или SIGTERM. Конфигурация, которую не удалось прочитать или понять, сообщается
6
+ в stderr и завершает процесс с кодом 2, чтобы systemd и другие супервизоры
7
+ не перезапускали сервис, который не может стартовать. Флаг ``--print-unit``
8
+ печатает готовый юнит systemd для этой установки и выходит.
9
+
10
+ Copyright (c) 2023–2026 webtoucher
11
+ Distributed under the BSD 3-Clause license. See LICENSE.md for more info.
12
+ """
13
+
14
+ import argparse
15
+ import signal
16
+ import sys
17
+
18
+ from typing import Any, Optional, Sequence
19
+
20
+ from mercury_proxy.config import DEFAULT_CONFIG_PATH, load_config
21
+ from mercury_proxy.exceptions import ConfigError, ProxyError
22
+ from mercury_proxy.log import configure_logging
23
+ from mercury_proxy.service import Service
24
+ from mercury_proxy.systemd import render_unit
25
+
26
+ __all__ = ["main"]
27
+
28
+
29
+ def main(argv: Optional[Sequence[str]] = None) -> int:
30
+ """Запустить сервис из командной строки; возвращает код возврата."""
31
+ parser = argparse.ArgumentParser(
32
+ prog="mercury-proxy",
33
+ description="Proxy server and REST API for Incotex Mercury meters",
34
+ )
35
+ parser.add_argument(
36
+ "-c",
37
+ "--config",
38
+ default=DEFAULT_CONFIG_PATH,
39
+ metavar="FILE",
40
+ help="YAML configuration file (default: %(default)s)",
41
+ )
42
+ parser.add_argument(
43
+ "--print-unit",
44
+ action="store_true",
45
+ help="print a systemd unit for this installation and exit",
46
+ )
47
+ arguments = parser.parse_args(argv)
48
+ if arguments.print_unit:
49
+ print(render_unit(arguments.config))
50
+ return 0
51
+
52
+ try:
53
+ config = load_config(arguments.config)
54
+ logger = configure_logging(config.logging)
55
+ except ConfigError as error:
56
+ print("mercury-proxy: %s" % error, file=sys.stderr)
57
+ return 2
58
+
59
+ service = Service(config)
60
+
61
+ def on_signal(signum: int, _frame: Any) -> None:
62
+ logger.info("Signal %d received, stopping", signum)
63
+ # Только флаг: сокеты закрывает run() в главном потоке.
64
+ service.request_stop()
65
+
66
+ for name in ("SIGINT", "SIGTERM"):
67
+ number = getattr(signal, name, None)
68
+ if number is None:
69
+ continue
70
+ try:
71
+ signal.signal(number, on_signal)
72
+ except (OSError, ValueError):
73
+ # В Windows нет настоящего SIGTERM, а рабочий поток не может
74
+ # ставить обработчики; Ctrl+C всё равно работает через
75
+ # KeyboardInterrupt.
76
+ pass
77
+
78
+ logger.info("Configuration is loaded from %s", arguments.config)
79
+ try:
80
+ service.run()
81
+ except KeyboardInterrupt:
82
+ logger.info("Interrupted, stopping")
83
+ service.request_stop()
84
+ except (ProxyError, OSError) as error:
85
+ logger.exception("Mercury proxy has stopped: %s", error)
86
+ return 1
87
+ return 0
88
+
89
+
90
+ if __name__ == "__main__":
91
+ sys.exit(main())
mercury_proxy/api.py ADDED
@@ -0,0 +1,337 @@
1
+ """
2
+ REST API над командами ``mercury_base``.
3
+
4
+ API — обычный ``http.server``: ни фреймворка, ни лишней зависимости, поток на
5
+ запрос. Каждое обращение к счётчику сериализует хаб, поэтому медленный счётчик
6
+ задерживает только свои запросы, а прокси держит собственное подключение к той
7
+ же шине. Ответы — JSON; команда отвечает ``{"result": ...}``, ошибка —
8
+ ``{"error": "..."}``. Если в конфигурации задан ``api.token``, запрос без
9
+ заголовка ``Authorization: Bearer ...`` получает ``401``; без токена API открыт
10
+ всем, кто дотянулся до порта.
11
+
12
+ Copyright (c) 2023–2026 webtoucher
13
+ Distributed under the BSD 3-Clause license. See LICENSE.md for more info.
14
+ """
15
+
16
+ import hmac
17
+ import json
18
+ import logging
19
+ import threading
20
+
21
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
22
+ from typing import Any, Dict, List, Mapping, Optional, Tuple
23
+ from urllib.parse import parse_qs, unquote, urlsplit
24
+
25
+ from mercury_base import CommunicationError, MercuryError, UnknownCommandError
26
+
27
+ from mercury_proxy.config import ApiConfig
28
+ from mercury_proxy.exceptions import UnknownMeterError
29
+ from mercury_proxy.meters import MeterHub
30
+
31
+ __all__ = ["RestApi", "API_PREFIX"]
32
+
33
+ #: Все маршруты живут под этим префиксом, чтобы следующая версия могла
34
+ #: существовать рядом.
35
+ API_PREFIX = "/v1.0"
36
+
37
+ #: Тело запроса больше этого размера отклоняется, а не читается в память.
38
+ _MAX_BODY = 65536
39
+
40
+ #: Наибольшее значение параметра команды: это один байт кадра.
41
+ _MAX_PARAM = 0xFF
42
+
43
+ #: Адреса, с которых запрос не выходит за пределы машины.
44
+ _LOOPBACK = ("127.0.0.1", "::1", "localhost")
45
+
46
+ #: Схема авторизации: заголовок ``Authorization: Bearer <token>``.
47
+ _AUTH_SCHEME = "Bearer"
48
+
49
+ _STOP_TIMEOUT = 10.0
50
+
51
+
52
+ class _BadRequest(Exception):
53
+ """Сам запрос неверен; отвечается кодом 400."""
54
+
55
+
56
+ class _ApiServer(ThreadingHTTPServer):
57
+ """Слушающий сокет: поток на запрос, хаб для каждого обработчика."""
58
+
59
+ daemon_threads = True
60
+ allow_reuse_address = True
61
+
62
+ def __init__(self, address: Tuple[str, int], hub: MeterHub, token: Optional[str]) -> None:
63
+ super().__init__(address, _RequestHandler)
64
+ self.hub = hub
65
+ self.token = token
66
+ self.logger = logging.getLogger(__name__)
67
+
68
+
69
+ class _RequestHandler(BaseHTTPRequestHandler):
70
+ """Разбирает один запрос, зовёт хаб и пишет ответ в JSON."""
71
+
72
+ server: _ApiServer
73
+ protocol_version = "HTTP/1.1"
74
+
75
+ def do_GET(self) -> None:
76
+ self.__dispatch("GET")
77
+
78
+ def do_POST(self) -> None:
79
+ self.__dispatch("POST")
80
+
81
+ def log_message(self, format: str, *args: Any) -> None:
82
+ """Направить лог доступа http.server в логгер сервиса."""
83
+ self.server.logger.debug("%s - %s", self.address_string(), format % args)
84
+
85
+ def __dispatch(self, method: str) -> None:
86
+ if not self.__authorized():
87
+ self.server.logger.warning(
88
+ "Rejected a request to %s from %s: the token is missing or wrong",
89
+ self.path,
90
+ self.address_string(),
91
+ )
92
+ # Тело запроса не читается, поэтому соединение дальше не переиспользуем:
93
+ # иначе оставшиеся байты были бы разобраны как следующий запрос.
94
+ self.close_connection = True
95
+ self.__send(
96
+ 401,
97
+ {"error": "unauthorized"},
98
+ {"WWW-Authenticate": '%s realm="mercury-proxy"' % _AUTH_SCHEME},
99
+ )
100
+ return
101
+ segments = self.__segments()
102
+ if segments is None:
103
+ self.__send(404, {"error": "unknown path: %s" % self.path})
104
+ return
105
+ try:
106
+ status, payload = self.__serve(method, segments)
107
+ except _BadRequest as error:
108
+ status, payload = 400, {"error": str(error)}
109
+ except UnknownMeterError as error:
110
+ status, payload = 404, {"error": str(error)}
111
+ except UnknownCommandError as error:
112
+ status, payload = 404, {"error": str(error)}
113
+ except CommunicationError as error:
114
+ # Счётчик спросили, а он не ответил или ответил мусором.
115
+ status, payload = 504, {"error": str(error)}
116
+ except MercuryError as error:
117
+ status, payload = 502, {"error": str(error)}
118
+ except Exception: # noqa: BLE001 - обработчик обязан ответить, а не упасть
119
+ self.server.logger.exception("Request %s %s failed", method, self.path)
120
+ status, payload = 500, {"error": "internal error"}
121
+ self.__send(status, payload)
122
+
123
+ def __authorized(self) -> bool:
124
+ """Проверить токен из заголовка; без настроенного токена пускаем всех."""
125
+ token = self.server.token
126
+ if token is None:
127
+ return True
128
+ scheme, _, value = self.headers.get("Authorization", "").partition(" ")
129
+ if scheme.lower() != _AUTH_SCHEME.lower():
130
+ return False
131
+ # Сравнение постоянного времени: по задержке нельзя подобрать токен.
132
+ return hmac.compare_digest(value.strip().encode("utf-8"), token.encode("utf-8"))
133
+
134
+ def __serve(self, method: str, segments: List[str]) -> Tuple[int, Any]:
135
+ """Провести запрос по маршрутам; возвращает код и полезную нагрузку."""
136
+ if not segments:
137
+ return 200, {"name": "mercury-proxy", "api": "1.0"}
138
+ if segments[0] == "health":
139
+ return 200, {"status": "ok", "meters": len(self.server.hub.meters)}
140
+ if segments[0] != "meters":
141
+ raise _BadRequest("unknown path: %s" % self.path)
142
+ if len(segments) == 1:
143
+ return 200, {"meters": [self.__info(meter) for meter in self.server.hub.meters]}
144
+ if len(segments) == 2:
145
+ return 200, self.__meter(segments[1])
146
+ if len(segments) == 3 and method in ("GET", "POST"):
147
+ return 200, self.__command(segments[1], segments[2], method)
148
+ raise _BadRequest("unknown path: %s" % self.path)
149
+
150
+ def __meter(self, raw_serial: str) -> Dict[str, Any]:
151
+ serial_number = self.__serial(raw_serial)
152
+ meter = self.server.hub.find_by_serial_number(serial_number)
153
+ if meter is None:
154
+ raise UnknownMeterError("meter %s is not connected" % serial_number)
155
+ return self.__info(meter)
156
+
157
+ def __command(self, raw_serial: str, command: str, method: str) -> Dict[str, Any]:
158
+ serial_number = self.__serial(raw_serial)
159
+ params = self.__query_params() if method == "GET" else self.__body_params()
160
+ self.server.logger.debug(
161
+ "Request %s /v1.0/meters/%s/%s with params %r",
162
+ method,
163
+ serial_number,
164
+ command,
165
+ params,
166
+ )
167
+ return {"result": self.server.hub.command(serial_number, command, params)}
168
+
169
+ @staticmethod
170
+ def __info(meter: Any) -> Dict[str, Any]:
171
+ """Описание счётчика в том виде, в каком его показывает API."""
172
+ info = meter.info
173
+ return {
174
+ "serial_number": info.serial_number,
175
+ "model": info.model,
176
+ "firmware_version": info.firmware_version,
177
+ "date_of_manufacture": info.date_of_manufacture,
178
+ "meter_version": info.meter_version,
179
+ "features": list(info.features),
180
+ "port": meter.transport.port,
181
+ }
182
+
183
+ def __segments(self) -> Optional[List[str]]:
184
+ """Сегменты пути после префикса версии; None для чужого префикса."""
185
+ path = unquote(urlsplit(self.path).path)
186
+ if path in (API_PREFIX, API_PREFIX + "/"):
187
+ return []
188
+ if not path.startswith(API_PREFIX + "/"):
189
+ return None
190
+ return [segment for segment in path[len(API_PREFIX) + 1:].split("/") if segment]
191
+
192
+ @staticmethod
193
+ def __serial(text: str) -> int:
194
+ try:
195
+ return int(text, 10)
196
+ except ValueError:
197
+ raise _BadRequest("the serial number must be an integer, got %r" % text) from None
198
+
199
+ def __query_params(self) -> List[int]:
200
+ """Параметры команды из строки запроса: ``?params=1,0`` или ``?params=1&params=0``."""
201
+ query = parse_qs(urlsplit(self.path).query, keep_blank_values=False)
202
+ raw: List[str] = []
203
+ for value in query.get("params", []):
204
+ raw.extend(part.strip() for part in value.split(",") if part.strip())
205
+ params = []
206
+ for text in raw:
207
+ base = 16 if text.lower().startswith("0x") else 10
208
+ try:
209
+ params.append(int(text, base))
210
+ except ValueError:
211
+ raise _BadRequest(
212
+ "params must be integers, got %r" % text
213
+ ) from None
214
+ return [self.__param(value) for value in params]
215
+
216
+ def __body_params(self) -> List[int]:
217
+ """Параметры команды из тела JSON: ``{"params": [1, 0]}`` или ``[1, 0]``."""
218
+ try:
219
+ size = int(self.headers.get("Content-Length") or 0)
220
+ except ValueError:
221
+ raise _BadRequest("invalid Content-Length header") from None
222
+ if size > _MAX_BODY:
223
+ raise _BadRequest("the request body must not exceed %d bytes" % _MAX_BODY)
224
+ if size <= 0:
225
+ return []
226
+ body = self.rfile.read(size)
227
+ try:
228
+ document = json.loads(body.decode("utf-8"))
229
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
230
+ raise _BadRequest("the body is not valid JSON: %s" % error) from None
231
+ if document is None:
232
+ return []
233
+ if isinstance(document, Mapping):
234
+ unknown = sorted(str(key) for key in document if key != "params")
235
+ if unknown:
236
+ raise _BadRequest("unknown keys in the body: %s" % ", ".join(unknown))
237
+ document = document.get("params") or []
238
+ if not isinstance(document, list):
239
+ raise _BadRequest("params must be a list of integers, got %r" % (document,))
240
+ return [self.__param(value) for value in document]
241
+
242
+ @staticmethod
243
+ def __param(value: Any) -> int:
244
+ if isinstance(value, bool) or not isinstance(value, int):
245
+ raise _BadRequest("a command parameter must be an integer, got %r" % (value,))
246
+ if not 0 <= value <= _MAX_PARAM:
247
+ raise _BadRequest(
248
+ "a command parameter must be between 0 and %d, got %d"
249
+ % (_MAX_PARAM, value)
250
+ )
251
+ return value
252
+
253
+ def __send(
254
+ self, status: int, payload: Any, headers: Optional[Mapping[str, str]] = None
255
+ ) -> None:
256
+ body = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
257
+ try:
258
+ self.send_response(status)
259
+ self.send_header("Content-Type", "application/json; charset=utf-8")
260
+ self.send_header("Content-Length", str(len(body)))
261
+ for name, value in (headers or {}).items():
262
+ self.send_header(name, value)
263
+ self.end_headers()
264
+ self.wfile.write(body)
265
+ except OSError:
266
+ # Клиент закрыл свою сторону раньше, чем был записан ответ.
267
+ self.server.logger.debug("The client %s is gone", self.address_string())
268
+
269
+
270
+ class RestApi:
271
+ """Сервер REST API: сокет, поток и порт, который он получил."""
272
+
273
+ def __init__(self, hub: MeterHub, config: ApiConfig) -> None:
274
+ self.__hub = hub
275
+ self.__config = config
276
+ self.__logger = logging.getLogger(__name__)
277
+ self.__server: Optional[_ApiServer] = None
278
+ self.__thread: Optional[threading.Thread] = None
279
+
280
+ @property
281
+ def is_running(self) -> bool:
282
+ """True, пока API принимает запросы."""
283
+ return self.__server is not None
284
+
285
+ @property
286
+ def host(self) -> str:
287
+ """Адрес, к которому привязан API."""
288
+ return self.__config.host
289
+
290
+ @property
291
+ def port(self) -> int:
292
+ """Порт, к которому привязан API; настоящий, если в конфигурации был 0."""
293
+ server = self.__server
294
+ if server is None:
295
+ return self.__config.port
296
+ return server.server_address[1]
297
+
298
+ def start(self) -> None:
299
+ """Связать адрес и обслуживать в фоновом потоке.
300
+
301
+ Поднимает OSError, если порт занять не удалось; тогда вызывается
302
+ ``server_close()``, и ничего не остаётся открытым.
303
+ """
304
+ if self.__server is not None:
305
+ raise RuntimeError("the API is already running")
306
+ server = _ApiServer(
307
+ (self.__config.host, self.__config.port), self.__hub, self.__config.token
308
+ )
309
+ self.__server = server
310
+ self.__thread = threading.Thread(
311
+ target=server.serve_forever,
312
+ kwargs={"poll_interval": 0.1},
313
+ name="mercury-api",
314
+ daemon=True,
315
+ )
316
+ self.__thread.start()
317
+ self.__logger.info(
318
+ "API server is listening on %s:%s", self.__config.host, self.port
319
+ )
320
+ if self.__config.token is None and self.__config.host not in _LOOPBACK:
321
+ self.__logger.warning(
322
+ "API has no token: anyone who can reach %s:%s can read the meters",
323
+ self.__config.host,
324
+ self.port,
325
+ )
326
+
327
+ def stop(self) -> None:
328
+ """Перестать обслуживать и закрыть слушающий сокет. Безопасно вызывать дважды."""
329
+ server, self.__server = self.__server, None
330
+ thread, self.__thread = self.__thread, None
331
+ if server is None:
332
+ return
333
+ server.shutdown()
334
+ server.server_close()
335
+ if thread is not None and thread is not threading.current_thread():
336
+ thread.join(_STOP_TIMEOUT)
337
+ self.__logger.info("API server has stopped")