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.
- mercury_proxy/__init__.py +51 -0
- mercury_proxy/__main__.py +91 -0
- mercury_proxy/api.py +337 -0
- mercury_proxy/config.py +339 -0
- mercury_proxy/exceptions.py +23 -0
- mercury_proxy/log.py +72 -0
- mercury_proxy/mercury-proxy.service +48 -0
- mercury_proxy/meters.py +127 -0
- mercury_proxy/proxy.py +198 -0
- mercury_proxy/py.typed +1 -0
- mercury_proxy/service.py +215 -0
- mercury_proxy/systemd.py +53 -0
- mercury_proxy-2.0.0.dist-info/METADATA +650 -0
- mercury_proxy-2.0.0.dist-info/RECORD +18 -0
- mercury_proxy-2.0.0.dist-info/WHEEL +5 -0
- mercury_proxy-2.0.0.dist-info/entry_points.txt +2 -0
- mercury_proxy-2.0.0.dist-info/licenses/LICENSE.md +13 -0
- mercury_proxy-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -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¶ms=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")
|