cremalink 0.1.0b5__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.
Files changed (47) hide show
  1. cremalink/__init__.py +33 -0
  2. cremalink/clients/__init__.py +10 -0
  3. cremalink/clients/cloud.py +130 -0
  4. cremalink/core/__init__.py +6 -0
  5. cremalink/core/binary.py +102 -0
  6. cremalink/crypto/__init__.py +142 -0
  7. cremalink/devices/AY008ESP1.json +114 -0
  8. cremalink/devices/__init__.py +116 -0
  9. cremalink/domain/__init__.py +11 -0
  10. cremalink/domain/device.py +245 -0
  11. cremalink/domain/factory.py +98 -0
  12. cremalink/local_server.py +76 -0
  13. cremalink/local_server_app/__init__.py +20 -0
  14. cremalink/local_server_app/api.py +272 -0
  15. cremalink/local_server_app/config.py +64 -0
  16. cremalink/local_server_app/device_adapter.py +96 -0
  17. cremalink/local_server_app/jobs.py +104 -0
  18. cremalink/local_server_app/logging.py +116 -0
  19. cremalink/local_server_app/models.py +76 -0
  20. cremalink/local_server_app/protocol.py +135 -0
  21. cremalink/local_server_app/state.py +358 -0
  22. cremalink/parsing/__init__.py +7 -0
  23. cremalink/parsing/monitor/__init__.py +22 -0
  24. cremalink/parsing/monitor/decode.py +79 -0
  25. cremalink/parsing/monitor/extractors.py +69 -0
  26. cremalink/parsing/monitor/frame.py +132 -0
  27. cremalink/parsing/monitor/model.py +42 -0
  28. cremalink/parsing/monitor/profile.py +144 -0
  29. cremalink/parsing/monitor/view.py +196 -0
  30. cremalink/parsing/properties/__init__.py +9 -0
  31. cremalink/parsing/properties/decode.py +53 -0
  32. cremalink/resources/__init__.py +10 -0
  33. cremalink/resources/api_config.json +14 -0
  34. cremalink/resources/api_config.py +30 -0
  35. cremalink/resources/lang.json +223 -0
  36. cremalink/transports/__init__.py +7 -0
  37. cremalink/transports/base.py +94 -0
  38. cremalink/transports/cloud/__init__.py +9 -0
  39. cremalink/transports/cloud/transport.py +166 -0
  40. cremalink/transports/local/__init__.py +9 -0
  41. cremalink/transports/local/transport.py +164 -0
  42. cremalink-0.1.0b5.dist-info/METADATA +138 -0
  43. cremalink-0.1.0b5.dist-info/RECORD +47 -0
  44. cremalink-0.1.0b5.dist-info/WHEEL +5 -0
  45. cremalink-0.1.0b5.dist-info/entry_points.txt +2 -0
  46. cremalink-0.1.0b5.dist-info/licenses/LICENSE +661 -0
  47. cremalink-0.1.0b5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,272 @@
1
+ """
2
+ This module defines the FastAPI application for the local proxy server.
3
+ It creates all the API endpoints, manages application state, and handles the
4
+ startup and shutdown of background services.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+ from typing import Optional, AsyncIterator
11
+ from contextlib import asynccontextmanager
12
+
13
+ from fastapi import Depends, FastAPI, HTTPException, Response, status
14
+ from fastapi.responses import JSONResponse, PlainTextResponse
15
+ from fastapi.routing import APIRouter
16
+
17
+ from cremalink.local_server_app.config import ServerSettings, get_settings
18
+ from cremalink.local_server_app.device_adapter import DeviceAdapter
19
+ from cremalink.local_server_app.jobs import (
20
+ JobManager,
21
+ monitor_job,
22
+ nudger_job,
23
+ rekey_job,
24
+ )
25
+ from cremalink.local_server_app.logging import create_logger
26
+ from cremalink.local_server_app.models import (
27
+ CommandPollResponse,
28
+ CommandRequest,
29
+ ConfigureRequest,
30
+ EncPayload,
31
+ KeyExchangeRequest,
32
+ MonitorResponse,
33
+ PropertiesResponse,
34
+ )
35
+ from cremalink.local_server_app import protocol
36
+ from cremalink.local_server_app.state import LocalServerState
37
+
38
+
39
+ def create_app(
40
+ settings: Optional[ServerSettings] = None,
41
+ device_adapter: Optional[DeviceAdapter] = None,
42
+ logger=None,
43
+ ) -> FastAPI:
44
+ """
45
+ Application factory for the FastAPI server.
46
+
47
+ Initializes all components (state, settings, adapter, jobs) and wires up
48
+ the API routes, startup/shutdown events, and dependencies.
49
+
50
+ Returns:
51
+ A configured FastAPI application instance.
52
+ """
53
+ # Initialize core components, allowing for dependency injection in tests.
54
+ settings = settings or get_settings()
55
+ logger = logger or create_logger("local_server", settings.log_ring_size)
56
+ state = LocalServerState(settings, logger)
57
+ adapter = device_adapter or DeviceAdapter(settings, logger)
58
+ stop_event = asyncio.Event()
59
+ jobs = JobManager()
60
+
61
+ # Define the application lifespan context manager for startup/shutdown events.
62
+ @asynccontextmanager
63
+ async def lifespan(app_: FastAPI) -> AsyncIterator[None]:
64
+ await app_.router.startup()
65
+ try:
66
+ yield
67
+ finally:
68
+ await app_.router.shutdown()
69
+
70
+ app = FastAPI(title="cremalink Local Server", version="2.0.0")
71
+ app.state.local_state = state
72
+ app.state.settings = settings
73
+ app.state.adapter = adapter
74
+ app.state.jobs = jobs
75
+ app.state.stop_event = stop_event
76
+ app.state.logger = logger
77
+
78
+ router = APIRouter()
79
+
80
+ # --- Dependency Injection ---
81
+ async def get_state() -> LocalServerState:
82
+ return state
83
+
84
+ async def get_adapter() -> DeviceAdapter:
85
+ return adapter
86
+
87
+ @router.post("/configure")
88
+ async def configure(req: ConfigureRequest, st: LocalServerState = Depends(get_state)):
89
+ """Configures the server with device connection details."""
90
+ await st.configure(
91
+ dsn=req.dsn,
92
+ device_ip=req.device_ip,
93
+ lan_key=req.lan_key,
94
+ device_scheme=req.device_scheme,
95
+ monitor_property_name=req.monitor_property_name,
96
+ )
97
+ # Attempt an initial registration with the device.
98
+ try:
99
+ await adapter.register_with_device(st)
100
+ except Exception as exc:
101
+ st.log("local_reg_initial_failed", {"error": str(exc)})
102
+ return {"status": "configured", "dsn": req.dsn, "device_scheme": req.device_scheme}
103
+
104
+ @router.post("/command")
105
+ async def command(req: CommandRequest, st: LocalServerState = Depends(get_state), ad: DeviceAdapter = Depends(get_adapter)):
106
+ """Queues a command to be sent to the device."""
107
+ if not st.is_configured():
108
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Server not configured")
109
+ try:
110
+ await ad.register_with_device(st)
111
+ await st.queue_command(req.command)
112
+ except OverflowError as exc:
113
+ raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(exc))
114
+ except ConnectionError as exc:
115
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
116
+ return {"status": "queued", "seq": st.seq}
117
+
118
+ @router.get("/get_monitor", response_model=MonitorResponse)
119
+ async def get_monitor(st: LocalServerState = Depends(get_state)):
120
+ """Gets the last known monitor status."""
121
+ return await st.snapshot_monitor()
122
+
123
+ @router.get("/refresh_monitor")
124
+ async def refresh_monitor(st: LocalServerState = Depends(get_state), ad: DeviceAdapter = Depends(get_adapter)):
125
+ """Queues a request to refresh the monitor status."""
126
+ try:
127
+ await ad.register_with_device(st)
128
+ await st.queue_monitor()
129
+ except ConnectionError as exc:
130
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
131
+ return PlainTextResponse("queued monitor refresh")
132
+
133
+ @router.get("/get_properties", response_model=PropertiesResponse)
134
+ async def get_properties(st: LocalServerState = Depends(get_state), ad: DeviceAdapter = Depends(get_adapter)):
135
+ """Gets the last known device properties."""
136
+ try:
137
+ await ad.register_with_device(st)
138
+ await st.queue_properties()
139
+ except ConnectionError as exc:
140
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
141
+ return await st.snapshot_properties()
142
+
143
+ @router.get("/properties/{property_name}")
144
+ async def get_property(property_name: str, st: LocalServerState = Depends(get_state)):
145
+ """Gets a single property value from the last known snapshot."""
146
+ value = await st.get_property_value(property_name)
147
+ if value is None:
148
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Property not found")
149
+ return {"name": property_name, "value": value}
150
+
151
+ @router.get("/health")
152
+ async def health():
153
+ return PlainTextResponse("ok")
154
+
155
+ @router.get("/logs")
156
+ async def logs():
157
+ ring_handler = next((h for h in logger.handlers if hasattr(h, "get_events")), None)
158
+ events = ring_handler.__getattribute__("get_events") if ring_handler else []
159
+ return {"events": events, "last_command": state.last_command}
160
+
161
+ @router.get("/debug_queue")
162
+ async def debug_queue(st: LocalServerState = Depends(get_state)):
163
+ async with st.lock: # type: ignore[attr-defined]
164
+ next_payload = st.command_queue[0] if st.command_queue else None
165
+ queued = len(st.command_queue)
166
+ seq = st.seq
167
+ return {"queued": queued, "next_payload": next_payload, "seq": seq}
168
+
169
+ @router.get("/monitor")
170
+ async def monitor(st: LocalServerState = Depends(get_state)):
171
+ async with st.lock: # type: ignore[attr-defined]
172
+ return JSONResponse(st.last_monitor)
173
+
174
+ # --- Device-Facing API Endpoints (called by the coffee machine) ---
175
+
176
+ @router.post("/local_lan/key_exchange.json")
177
+ async def key_exchange(req: KeyExchangeRequest, st: LocalServerState = Depends(get_state)):
178
+ """Handles the cryptographic key exchange request from the device."""
179
+ if not st.lan_key:
180
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Server not configured")
181
+ exchange = req.key_exchange
182
+ await st.init_crypto(random_1=exchange.random_1, time_1=exchange.time_1)
183
+ st.log("key_exchange", {"random_1": exchange.random_1, "time_1": exchange.time_1})
184
+ return JSONResponse({"random_2": st.random_2, "time_2": int(st.time_2)}, status_code=status.HTTP_202_ACCEPTED)
185
+
186
+ async def serve_command_poll(st: LocalServerState) -> CommandPollResponse:
187
+ """Shared logic for serving the next command to the device."""
188
+ if not st.keys_ready():
189
+ st.log("command_poll_no_keys", {"queued": len(st.command_queue)})
190
+ return CommandPollResponse(enc="", sign="", seq=st.seq)
191
+
192
+ next_item = await st.next_command_payload()
193
+ payload, current_seq = next_item["payload"], next_item["seq"]
194
+
195
+ enc, new_iv = protocol.encrypt_payload(payload, st.app_crypto_key, st.app_iv_seed)
196
+ st.app_iv_seed = new_iv
197
+ sign = protocol.sign_payload(payload, st.app_sign_key)
198
+ async with st.lock:
199
+ st.command_payload = protocol.build_empty_payload(st.seq)
200
+ st.log(
201
+ "command_served",
202
+ {"seq": current_seq, "queued_remaining": len(st.command_queue), "payload_size": len(payload)},
203
+ )
204
+ return CommandPollResponse(enc=enc, sign=sign, seq=current_seq)
205
+
206
+ @router.get("/local_lan/commands.json", response_model=CommandPollResponse)
207
+ async def poll_commands_get(st: LocalServerState = Depends(get_state)):
208
+ """Endpoint for the device to poll for commands (GET)."""
209
+ return await serve_command_poll(st)
210
+
211
+ @router.post("/local_lan/commands.json", response_model=CommandPollResponse)
212
+ async def poll_commands_post(st: LocalServerState = Depends(get_state)):
213
+ """Endpoint for the device to poll for commands (POST)."""
214
+ return await serve_command_poll(st)
215
+
216
+ @router.post("/local_lan/property/datapoint.json")
217
+ async def datapoint(payload: EncPayload, st: LocalServerState = Depends(get_state)):
218
+ """Endpoint for the device to push encrypted data to."""
219
+ if not st.dev_crypto_key or not st.dev_iv_seed:
220
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Keys not initialized")
221
+
222
+ decrypted_bytes, new_iv = protocol.decrypt_payload(payload.enc, st.dev_crypto_key, st.dev_iv_seed)
223
+ st.dev_iv_seed = new_iv
224
+
225
+ try:
226
+ decoded = decrypted_bytes.decode("utf-8")
227
+ decoded_json = json.loads(decoded)
228
+ except UnicodeDecodeError:
229
+ st.log("datapoint_decode_failed_utf8", {"cipher": payload.enc[:32]})
230
+ async with st.lock:
231
+ st._monitor_request_pending = False
232
+ st._properties_request_pending = False
233
+ return Response(status_code=status.HTTP_200_OK)
234
+ except json.JSONDecodeError:
235
+ st.log("datapoint_decode_failed_json", {"decoded_prefix": decrypted_bytes[:64].decode('utf-8', 'ignore')})
236
+ async with st.lock:
237
+ st._monitor_request_pending = False
238
+ st._properties_request_pending = False
239
+ return Response(status_code=status.HTTP_200_OK)
240
+
241
+ await st.handle_datapoint(decoded_json)
242
+ return {}
243
+
244
+ @router.get("/register")
245
+ async def register(st: LocalServerState = Depends(get_state), ad: DeviceAdapter = Depends(get_adapter)):
246
+ try:
247
+ await ad.register_with_device(st)
248
+ except Exception as exc:
249
+ st.log("internal_server_error", {"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR, "error": str(exc)})
250
+ return PlainTextResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
251
+ return PlainTextResponse("registered")
252
+
253
+ app.include_router(router)
254
+
255
+ async def startup_event():
256
+ if settings.enable_nudger_job:
257
+ jobs.start(nudger_job(state, adapter, settings, stop_event), name="nudger")
258
+ if settings.enable_monitor_job:
259
+ jobs.start(monitor_job(state, settings, stop_event), name="monitor")
260
+ if settings.enable_rekey_job:
261
+ jobs.start(rekey_job(state, adapter, settings, stop_event), name="rekey")
262
+
263
+ app.add_event_handler("startup", startup_event)
264
+
265
+ async def shutdown_event():
266
+ stop_event.set()
267
+ await jobs.stop()
268
+ await adapter.close()
269
+
270
+ app.add_event_handler("shutdown", shutdown_event)
271
+
272
+ return app
@@ -0,0 +1,64 @@
1
+ """
2
+ This module defines the configuration settings for the local server application
3
+ using Pydantic's settings management.
4
+ """
5
+ from functools import lru_cache
6
+ from typing import Optional
7
+
8
+ from pydantic import Field
9
+ from pydantic_settings import SettingsConfigDict, BaseSettings
10
+
11
+
12
+ class ServerSettings(BaseSettings):
13
+ """
14
+ Defines the application's settings, which can be loaded from environment
15
+ variables or a .env file. This provides a centralized and type-safe way
16
+ to configure the server's behavior.
17
+ """
18
+ # --- Server Network Settings ---
19
+ server_ip: str = Field("127.0.0.1", validation_alias="SERVER_IP", description="IP address for the server to bind to.")
20
+ server_port: int = Field(10280, validation_alias="SERVER_PORT", description="Port for the server to listen on.")
21
+
22
+ # --- Job Interval Settings ---
23
+ nudger_poll_interval: float = Field(1.0, validation_alias="NUDGER_POLL_INTERVAL", description="Interval in seconds for the 'nudger' job to poll for command responses.")
24
+ monitor_poll_interval: float = Field(5.0, validation_alias="MONITOR_POLL_INTERVAL", description="Interval in seconds for the monitor job to fetch device status.")
25
+ rekey_interval_seconds: float = Field(60.0, validation_alias="REKEY_INTERVAL_SECONDS", description="Interval in seconds to perform the authentication key exchange.")
26
+
27
+ # --- Buffer/Queue Size Settings ---
28
+ queue_max_size: int = Field(200, validation_alias="QUEUE_MAX_SIZE", description="Maximum size of the command queue.")
29
+ log_ring_size: int = Field(200, validation_alias="LOG_RING_SIZE", description="Maximum number of log entries to keep in the in-memory ring buffer.")
30
+
31
+ # --- Device Registration Settings ---
32
+ device_register_verify: bool = Field(False, validation_alias="DEVICE_REGISTER_VERIFY", description="Whether to verify SSL certificates during device registration.")
33
+ device_register_ca_path: Optional[str] = Field(None, validation_alias="DEVICE_REGISTER_CA_PATH", description="Path to a custom CA bundle for SSL verification.")
34
+ device_register_timeout: float = Field(10.0, validation_alias="DEVICE_REGISTER_TIMEOUT", description="Timeout in seconds for the device registration request.")
35
+ enable_device_register: bool = Field(True, validation_alias="ENABLE_DEVICE_REGISTER", description="Feature flag to enable the device registration process.")
36
+
37
+ # --- Job Feature Flags ---
38
+ enable_nudger_job: bool = Field(True, validation_alias="ENABLE_NUDGER_JOB", description="Feature flag to enable the command polling job.")
39
+ enable_monitor_job: bool = Field(True, validation_alias="ENABLE_MONITOR_JOB", description="Feature flag to enable the status monitoring job.")
40
+ enable_rekey_job: bool = Field(True, validation_alias="ENABLE_REKEY_JOB", description="Feature flag to enable the periodic re-keying job.")
41
+
42
+ # --- Testing / Determinism Hooks ---
43
+ # These fields allow for injecting fixed values during tests to make
44
+ # cryptographic operations deterministic.
45
+ fixed_random_2: Optional[str] = Field(None, validation_alias="FIXED_RANDOM_2")
46
+ fixed_time_2: Optional[str] = Field(None, validation_alias="FIXED_TIME_2")
47
+
48
+ # Pydantic settings configuration
49
+ model_config = SettingsConfigDict(
50
+ env_file=".env", # Load settings from a .env file.
51
+ env_file_encoding="utf-8",
52
+ populate_by_name=True, # Allow population by field name in addition to alias.
53
+ extra="ignore" # Ignore extra fields from the env file.
54
+ )
55
+
56
+
57
+ @lru_cache
58
+ def get_settings() -> ServerSettings:
59
+ """
60
+ Provides a cached, global instance of the ServerSettings.
61
+ Using lru_cache ensures that the settings are loaded from the environment
62
+ only once.
63
+ """
64
+ return ServerSettings()
@@ -0,0 +1,96 @@
1
+ """
2
+ This module provides an adapter for communicating directly with the coffee
3
+ machine device on the local network. Its main purpose is to handle the
4
+ device registration process.
5
+ """
6
+ from typing import Optional
7
+
8
+ import httpx
9
+
10
+ from cremalink.local_server_app.config import ServerSettings
11
+ from cremalink.local_server_app.state import LocalServerState
12
+
13
+
14
+ class DeviceAdapter:
15
+ """
16
+ Handles direct HTTP communication with the physical coffee machine.
17
+
18
+ This class is responsible for the 'registration' step, where this local
19
+ proxy server informs the coffee machine of its presence, telling it where
20
+ to send data pushes.
21
+ """
22
+
23
+ def __init__(self, settings: ServerSettings, logger):
24
+ """
25
+ Initializes the DeviceAdapter.
26
+
27
+ Args:
28
+ settings: The application's configuration settings.
29
+ logger: The application's logger instance.
30
+ """
31
+ self.settings = settings
32
+ self.logger = logger
33
+ self._client: Optional[httpx.AsyncClient] = None
34
+
35
+ async def _get_client(self) -> httpx.AsyncClient:
36
+ """
37
+ Provides a singleton instance of an `httpx.AsyncClient`.
38
+
39
+ The client is configured with timeout and SSL verification settings
40
+ from the application configuration.
41
+ """
42
+ if self._client is None:
43
+ self._client = httpx.AsyncClient(
44
+ timeout=self.settings.device_register_timeout,
45
+ verify=self.settings.device_register_ca_path or self.settings.device_register_verify,
46
+ )
47
+ return self._client
48
+
49
+ async def register_with_device(self, state: LocalServerState) -> None:
50
+ """
51
+ Sends a registration request to the coffee machine.
52
+
53
+ This tells the device to send its data updates (like monitor status)
54
+ to this server's `/local_lan` endpoint.
55
+
56
+ Args:
57
+ state: The current server state, containing device IP and scheme.
58
+
59
+ Raises:
60
+ ValueError: If the device IP is not configured in the state.
61
+ ConnectionError: If the HTTP request to the device fails.
62
+ """
63
+ if not self.settings.enable_device_register:
64
+ self.logger.info("register_skipped", extra={"details": {"reason": "disabled"}})
65
+ return
66
+
67
+ if not state.device_ip:
68
+ raise ValueError("Device IP not configured")
69
+
70
+ api_url = f"{state.device_scheme}://{state.device_ip}/local_reg.json"
71
+ # The payload tells the device this server's IP, port, and notification endpoint.
72
+ payload = {
73
+ "local_reg": {
74
+ "ip": self.settings.server_ip,
75
+ "notify": 1,
76
+ "port": self.settings.server_port,
77
+ "uri": "/local_lan",
78
+ }
79
+ }
80
+ client = await self._get_client()
81
+ try:
82
+ resp = await client.put(api_url, json=payload)
83
+ resp.raise_for_status()
84
+ except httpx.HTTPError as exc:
85
+ await state.set_registered(False)
86
+ state.log("local_reg_failed", {"error": str(exc)})
87
+ raise ConnectionError(f"local_reg failed: {exc}") from exc
88
+ else:
89
+ await state.set_registered(True)
90
+ state.log("local_reg_ok", {"device_ip": state.device_ip, "scheme": state.device_scheme})
91
+
92
+ async def close(self) -> None:
93
+ """Closes the underlying httpx client if it exists."""
94
+ if self._client:
95
+ await self._client.aclose()
96
+ self._client = None
@@ -0,0 +1,104 @@
1
+ """
2
+ This module defines and manages the background jobs for the local server.
3
+ These jobs run periodically in asyncio tasks to handle keep-alive, status
4
+ monitoring, and re-keying operations.
5
+ """
6
+ import asyncio
7
+ from typing import List
8
+
9
+ from cremalink.local_server_app.device_adapter import DeviceAdapter
10
+ from cremalink.local_server_app.state import LocalServerState
11
+ from cremalink.local_server_app.config import ServerSettings
12
+
13
+
14
+ class JobManager:
15
+ """A simple manager for starting and stopping asyncio background tasks."""
16
+
17
+ def __init__(self):
18
+ self.tasks: List[asyncio.Task] = []
19
+
20
+ def start(self, coro, name: str):
21
+ """Creates an asyncio task from a coroutine and adds it to the manager."""
22
+ task = asyncio.create_task(coro, name=name)
23
+ self.tasks.append(task)
24
+
25
+ async def stop(self):
26
+ """Cancels and cleans up all managed tasks."""
27
+ for task in self.tasks:
28
+ task.cancel()
29
+ # Wait for all tasks to acknowledge cancellation
30
+ await asyncio.gather(*self.tasks, return_exceptions=True)
31
+ self.tasks.clear()
32
+
33
+
34
+ async def nudger_job(st: LocalServerState, adapter: DeviceAdapter, settings: ServerSettings, stop_event: asyncio.Event):
35
+ """
36
+ Periodically "nudges" the device by sending a registration request.
37
+
38
+ This is a key part of the protocol. The device often needs to be prompted
39
+ to send data. This job ensures that registration is maintained, especially
40
+ if there are pending commands in the queue.
41
+ """
42
+ interval = settings.nudger_poll_interval
43
+ while not stop_event.is_set():
44
+ try:
45
+ # Nudge if there are commands waiting or if we aren't registered.
46
+ async with st.lock:
47
+ should_nudge = len(st.command_queue) > 0 or not st.registered
48
+ if should_nudge:
49
+ await adapter.register_with_device(st)
50
+ except Exception as exc:
51
+ st.log("local_reg_nudge_failed", {"error": str(exc)})
52
+ # If nudging fails, it might be a key issue, so trigger a rekey.
53
+ await st.rekey()
54
+
55
+ try:
56
+ # Wait for the specified interval or until the stop event is set.
57
+ await asyncio.wait_for(stop_event.wait(), timeout=interval)
58
+ except asyncio.TimeoutError:
59
+ continue
60
+
61
+
62
+ async def monitor_job(st: LocalServerState, settings: ServerSettings, stop_event: asyncio.Event):
63
+ """
64
+ Periodically queues a request to fetch the device's monitoring status.
65
+ """
66
+ interval = settings.monitor_poll_interval
67
+ while not stop_event.is_set():
68
+ try:
69
+ # Only queue a request if the server is configured and another request isn't already pending.
70
+ async with st.lock:
71
+ ready = st.is_configured() and not st._monitor_request_pending
72
+ if ready:
73
+ await st.queue_monitor()
74
+ except Exception as exc:
75
+ st.log("monitor_poll_failed", {"error": str(exc)})
76
+
77
+ try:
78
+ await asyncio.wait_for(stop_event.wait(), timeout=interval)
79
+ except asyncio.TimeoutError:
80
+ continue
81
+
82
+
83
+ async def rekey_job(state: LocalServerState, adapter: DeviceAdapter, settings: ServerSettings, stop_event: asyncio.Event):
84
+ """
85
+ Periodically triggers a full cryptographic re-keying process.
86
+
87
+ This enhances security by ensuring session keys are not long-lived.
88
+ """
89
+ interval = settings.rekey_interval_seconds
90
+ while not stop_event.is_set():
91
+ try:
92
+ # Wait for the rekey interval.
93
+ await asyncio.wait_for(stop_event.wait(), timeout=interval)
94
+ break
95
+ except asyncio.TimeoutError:
96
+ # Interval elapsed, proceed with re-keying.
97
+ pass
98
+ try:
99
+ state.log("rekey_triggered", {"interval": interval})
100
+ # Reset keys and re-register with the device.
101
+ await state.rekey()
102
+ await adapter.register_with_device(state)
103
+ except Exception as exc:
104
+ state.log("rekey_failed", {"error": str(exc)})
@@ -0,0 +1,116 @@
1
+ """
2
+ This module provides custom logging setup for the local server application,
3
+ including an in-memory ring buffer for recent log events and a redaction
4
+ function for sensitive data.
5
+ """
6
+ import logging
7
+ import threading
8
+ from collections import deque
9
+ from typing import Deque, Dict, List, Optional
10
+
11
+
12
+ class RingBufferHandler(logging.Handler):
13
+ """
14
+ A custom logging handler that stores the most recent log records in a
15
+ fixed-size in-memory deque (a ring buffer).
16
+
17
+ This is useful for exposing recent server activity via an API endpoint
18
+ without needing to read from a log file.
19
+ """
20
+
21
+ def __init__(self, max_entries: int = 200):
22
+ """
23
+ Initializes the handler.
24
+
25
+ Args:
26
+ max_entries: The maximum number of log entries to store.
27
+ """
28
+ super().__init__()
29
+ self.max_entries = max_entries
30
+ self._events: Deque[Dict] = deque(maxlen=max_entries)
31
+ self._lock = threading.Lock() # Lock for thread-safe access to the deque.
32
+
33
+ def emit(self, record: logging.LogRecord) -> None:
34
+ """
35
+ Formats and adds a log record to the ring buffer.
36
+
37
+ Args:
38
+ record: The log record to be processed.
39
+ """
40
+ # Construct a dictionary from the log record for easy JSON serialization.
41
+ event = {
42
+ "event": record.getMessage(),
43
+ "level": record.levelname,
44
+ "ts": record.created,
45
+ "details": getattr(record, "details", {}),
46
+ }
47
+ with self._lock:
48
+ self._events.append(event)
49
+
50
+ def get_events(self) -> List[Dict]:
51
+ """
52
+ Retrieves a thread-safe copy of all events currently in the buffer.
53
+
54
+ Returns:
55
+ A list of log event dictionaries.
56
+ """
57
+ with self._lock:
58
+ return list(self._events)
59
+
60
+
61
+ def create_logger(name: str, ring_size: int) -> logging.Logger:
62
+ """
63
+ Creates and configures a logger with the RingBufferHandler.
64
+
65
+ This function ensures that handlers are not added multiple times to the
66
+ same logger instance.
67
+
68
+ Args:
69
+ name: The name of the logger.
70
+ ring_size: The size of the ring buffer for the handler.
71
+
72
+ Returns:
73
+ A configured logging.Logger instance.
74
+ """
75
+ logger = logging.getLogger(name)
76
+ # If the logger already has handlers, assume it's already configured.
77
+ if logger.handlers:
78
+ return logger
79
+
80
+ logger.setLevel(logging.INFO)
81
+ handler = RingBufferHandler(max_entries=ring_size)
82
+ formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
83
+ handler.setFormatter(formatter)
84
+ logger.addHandler(handler)
85
+ # Stop log messages from propagating to the root logger.
86
+ logger.propagate = False
87
+ return logger
88
+
89
+
90
+ def redact(details: Optional[dict]) -> dict:
91
+ """
92
+ Filters a dictionary, replacing values of sensitive keys with '***'.
93
+
94
+ This is a security measure to prevent secret keys, tokens, and other
95
+ sensitive data from being exposed in logs.
96
+
97
+ Args:
98
+ details: A dictionary that may contain sensitive data.
99
+
100
+ Returns:
101
+ A new dictionary with sensitive values redacted.
102
+ """
103
+ if not details:
104
+ return {}
105
+
106
+ redacted_keys = {
107
+ "lan_key", "app_crypto_key", "dev_crypto_key", "app_iv_seed",
108
+ "dev_iv_seed", "enc", "sign"
109
+ }
110
+ cleaned = {}
111
+ for key, value in details.items():
112
+ if key in redacted_keys:
113
+ cleaned[key] = "***"
114
+ else:
115
+ cleaned[key] = value
116
+ return cleaned