portacode 1.3.32__py3-none-any.whl → 1.4.11.dev0__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.
Potentially problematic release.
This version of portacode might be problematic. Click here for more details.
- portacode/_version.py +2 -2
- portacode/cli.py +119 -14
- portacode/connection/client.py +127 -8
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +301 -4
- portacode/connection/handlers/__init__.py +10 -1
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +674 -17
- portacode/connection/handlers/project_aware_file_handlers.py +11 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +31 -61
- portacode/connection/handlers/project_state/git_manager.py +139 -572
- portacode/connection/handlers/project_state/handlers.py +28 -14
- portacode/connection/handlers/project_state/manager.py +226 -101
- portacode/connection/handlers/proxmox_infra.py +307 -0
- portacode/connection/handlers/session.py +465 -84
- portacode/connection/handlers/system_handlers.py +140 -8
- portacode/connection/handlers/tab_factory.py +1 -47
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/terminal.py +51 -10
- portacode/keypair.py +63 -1
- portacode/link_capture/__init__.py +38 -0
- portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
- portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
- portacode/link_capture/bin/elinks +3 -0
- portacode/link_capture/bin/gio-open +3 -0
- portacode/link_capture/bin/gnome-open +3 -0
- portacode/link_capture/bin/gvfs-open +3 -0
- portacode/link_capture/bin/kde-open +3 -0
- portacode/link_capture/bin/kfmclient +3 -0
- portacode/link_capture/bin/link_capture_exec.sh +11 -0
- portacode/link_capture/bin/link_capture_wrapper.py +75 -0
- portacode/link_capture/bin/links +3 -0
- portacode/link_capture/bin/links2 +3 -0
- portacode/link_capture/bin/lynx +3 -0
- portacode/link_capture/bin/mate-open +3 -0
- portacode/link_capture/bin/netsurf +3 -0
- portacode/link_capture/bin/sensible-browser +3 -0
- portacode/link_capture/bin/w3m +3 -0
- portacode/link_capture/bin/x-www-browser +3 -0
- portacode/link_capture/bin/xdg-open +3 -0
- portacode/pairing.py +103 -0
- portacode/static/js/utils/ntp-clock.js +170 -79
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +45 -131
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/METADATA +71 -3
- portacode-1.4.11.dev0.dist-info/RECORD +97 -0
- test_modules/test_device_online.py +1 -1
- test_modules/test_login_flow.py +8 -4
- test_modules/test_play_store_screenshots.py +294 -0
- testing_framework/.env.example +4 -1
- testing_framework/core/playwright_manager.py +63 -9
- portacode-1.3.32.dist-info/RECORD +0 -70
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +0 -0
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/top_level.txt +0 -0
portacode/_version.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '1.
|
|
32
|
-
__version_tuple__ = version_tuple = (1,
|
|
31
|
+
__version__ = version = '1.4.11.dev0'
|
|
32
|
+
__version_tuple__ = version_tuple = (1, 4, 11, 'dev0')
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
portacode/cli.py
CHANGED
|
@@ -7,17 +7,25 @@ from multiprocessing import Process
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
import signal
|
|
9
9
|
import json
|
|
10
|
+
import socket
|
|
10
11
|
|
|
11
12
|
import click
|
|
12
13
|
import pyperclip
|
|
13
14
|
|
|
14
15
|
from . import __version__
|
|
15
16
|
from .data import get_pid_file, is_process_running
|
|
16
|
-
from .keypair import
|
|
17
|
+
from .keypair import (
|
|
18
|
+
get_or_create_keypair,
|
|
19
|
+
fingerprint_public_key,
|
|
20
|
+
generate_in_memory_keypair,
|
|
21
|
+
keypair_files_exist,
|
|
22
|
+
)
|
|
23
|
+
from .pairing import PairingError, pair_device_with_code
|
|
17
24
|
from .connection.client import ConnectionManager, run_until_interrupt
|
|
18
25
|
|
|
19
26
|
GATEWAY_URL = "wss://portacode.com/gateway"
|
|
20
27
|
GATEWAY_ENV = "PORTACODE_GATEWAY"
|
|
28
|
+
MAX_PROJECT_PATHS = 10
|
|
21
29
|
|
|
22
30
|
|
|
23
31
|
@click.group()
|
|
@@ -33,7 +41,24 @@ def cli() -> None:
|
|
|
33
41
|
@click.option("--log-categories", "log_categories", help="Comma-separated list of log categories to show (e.g., 'connection,auth,git'). Use 'list' to see available categories.")
|
|
34
42
|
@click.option("--non-interactive", "non_interactive", is_flag=True, envvar="PORTACODE_NON_INTERACTIVE", hidden=True,
|
|
35
43
|
help="Skip interactive prompts (used by background service)")
|
|
36
|
-
|
|
44
|
+
@click.option("--pairing-code", "pairing_code_opt", envvar="PORTACODE_PAIRING_CODE", help="Provide a temporary pairing code for zero-copy onboarding")
|
|
45
|
+
@click.option("--device-name", "device_name_opt", envvar="PORTACODE_DEVICE_NAME", help="Custom device name to display during pairing")
|
|
46
|
+
@click.option(
|
|
47
|
+
"--project-path",
|
|
48
|
+
"project_paths_opt",
|
|
49
|
+
multiple=True,
|
|
50
|
+
help="Project folder to register during pairing (repeat for multiple paths)",
|
|
51
|
+
)
|
|
52
|
+
def connect(
|
|
53
|
+
gateway: str | None,
|
|
54
|
+
detach: bool,
|
|
55
|
+
debug: bool,
|
|
56
|
+
log_categories: str | None,
|
|
57
|
+
non_interactive: bool,
|
|
58
|
+
pairing_code_opt: str | None,
|
|
59
|
+
device_name_opt: str | None,
|
|
60
|
+
project_paths_opt: tuple[str, ...],
|
|
61
|
+
) -> None: # noqa: D401 – Click callback
|
|
37
62
|
"""Connect this machine to Portacode gateway."""
|
|
38
63
|
|
|
39
64
|
# Set up debug logging if requested
|
|
@@ -74,17 +99,31 @@ def connect(gateway: str | None, detach: bool, debug: bool, log_categories: str
|
|
|
74
99
|
other_pid = None
|
|
75
100
|
|
|
76
101
|
if other_pid and is_process_running(other_pid):
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
102
|
+
if other_pid == os.getpid():
|
|
103
|
+
# We just wrote our own PID (stale pidfile from previous run) – ignore safely.
|
|
104
|
+
click.echo(
|
|
105
|
+
click.style(
|
|
106
|
+
"Detected stale portacode pid file referencing the current process; ignoring.",
|
|
107
|
+
fg="bright_black",
|
|
108
|
+
)
|
|
80
109
|
)
|
|
81
|
-
)
|
|
82
|
-
if click.confirm("Terminate the existing connection?", default=False):
|
|
83
|
-
_terminate_process(other_pid)
|
|
84
110
|
pid_file.unlink(missing_ok=True)
|
|
85
111
|
else:
|
|
86
|
-
click.echo(
|
|
87
|
-
|
|
112
|
+
click.echo(
|
|
113
|
+
click.style(
|
|
114
|
+
f"Another portacode connection (PID {other_pid}) is active.", fg="yellow"
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
if non_interactive:
|
|
118
|
+
click.echo(click.style("Non-interactive mode: terminating the existing connection automatically.", fg="bright_black"))
|
|
119
|
+
_terminate_process(other_pid)
|
|
120
|
+
pid_file.unlink(missing_ok=True)
|
|
121
|
+
elif click.confirm("Terminate the existing connection?", default=False):
|
|
122
|
+
_terminate_process(other_pid)
|
|
123
|
+
pid_file.unlink(missing_ok=True)
|
|
124
|
+
else:
|
|
125
|
+
click.echo("Aborting.")
|
|
126
|
+
sys.exit(1)
|
|
88
127
|
else:
|
|
89
128
|
# Stale pidfile
|
|
90
129
|
pid_file.unlink(missing_ok=True)
|
|
@@ -92,12 +131,78 @@ def connect(gateway: str | None, detach: bool, debug: bool, log_categories: str
|
|
|
92
131
|
# Determine gateway URL
|
|
93
132
|
target_gateway = gateway or os.getenv(GATEWAY_ENV) or GATEWAY_URL
|
|
94
133
|
|
|
95
|
-
|
|
96
|
-
|
|
134
|
+
pairing_code = pairing_code_opt.strip() if pairing_code_opt else None
|
|
135
|
+
device_name = device_name_opt.strip() if device_name_opt else None
|
|
136
|
+
normalized_project_paths: list[str] = []
|
|
137
|
+
for raw in project_paths_opt:
|
|
138
|
+
if not raw:
|
|
139
|
+
continue
|
|
140
|
+
cleaned = raw.strip()
|
|
141
|
+
if not cleaned:
|
|
142
|
+
continue
|
|
143
|
+
if len(cleaned) > 500:
|
|
144
|
+
click.echo(click.style(f"Project path too long (max 500 chars): {cleaned[:60]}…", fg="red"))
|
|
145
|
+
sys.exit(1)
|
|
146
|
+
if cleaned not in normalized_project_paths:
|
|
147
|
+
normalized_project_paths.append(cleaned)
|
|
148
|
+
if len(normalized_project_paths) > MAX_PROJECT_PATHS:
|
|
149
|
+
click.echo(
|
|
150
|
+
click.style(
|
|
151
|
+
f"Too many project paths provided ({len(normalized_project_paths)}). "
|
|
152
|
+
f"Maximum allowed is {MAX_PROJECT_PATHS}.",
|
|
153
|
+
fg="red",
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
sys.exit(1)
|
|
157
|
+
|
|
158
|
+
pairing_requested = bool(pairing_code)
|
|
159
|
+
existing_identity = keypair_files_exist()
|
|
160
|
+
should_pair = pairing_requested and not existing_identity
|
|
161
|
+
|
|
162
|
+
if normalized_project_paths and not pairing_requested:
|
|
163
|
+
click.echo(
|
|
164
|
+
click.style(
|
|
165
|
+
"⚠ Project paths were provided but no pairing code was supplied; "
|
|
166
|
+
"they will be ignored.",
|
|
167
|
+
fg="yellow",
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# 2. Load or create keypair (in memory if pairing)
|
|
172
|
+
if should_pair:
|
|
173
|
+
keypair = generate_in_memory_keypair()
|
|
174
|
+
else:
|
|
175
|
+
keypair = get_or_create_keypair()
|
|
176
|
+
|
|
177
|
+
if should_pair:
|
|
178
|
+
if not device_name:
|
|
179
|
+
device_name = socket.gethostname() or "Portacode Device"
|
|
180
|
+
click.echo(click.style(f"🔐 Pairing code detected; pairing as '{device_name}'...", fg="cyan"))
|
|
181
|
+
try:
|
|
182
|
+
pair_device_with_code(
|
|
183
|
+
keypair,
|
|
184
|
+
pairing_code=pairing_code,
|
|
185
|
+
device_name=device_name,
|
|
186
|
+
project_paths=normalized_project_paths or None,
|
|
187
|
+
)
|
|
188
|
+
except PairingError as exc:
|
|
189
|
+
click.echo(click.style(f"Pairing failed: {exc}", fg="red"))
|
|
190
|
+
sys.exit(1)
|
|
191
|
+
# Persist keypair after successful approval
|
|
192
|
+
keypair = keypair.persist()
|
|
193
|
+
click.echo(click.style("✅ Pairing approved. Continuing with connection…", fg="green"))
|
|
194
|
+
elif pairing_requested and existing_identity:
|
|
195
|
+
click.echo(
|
|
196
|
+
click.style(
|
|
197
|
+
"ℹ Pairing code provided but an existing device identity was found; skipping pairing step.",
|
|
198
|
+
fg="yellow",
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
97
202
|
fingerprint = fingerprint_public_key(keypair.public_key_pem)
|
|
98
203
|
|
|
99
204
|
pubkey_b64 = keypair.public_key_der_b64()
|
|
100
|
-
if not non_interactive:
|
|
205
|
+
if not non_interactive and not pairing_requested:
|
|
101
206
|
# Show key generation status
|
|
102
207
|
if getattr(keypair, '_is_new', False):
|
|
103
208
|
click.echo()
|
|
@@ -399,4 +504,4 @@ def service_status(verbose: bool) -> None: # noqa: D401
|
|
|
399
504
|
click.echo("\n--- system output ---")
|
|
400
505
|
click.echo(mgr.status_verbose())
|
|
401
506
|
except Exception as exc:
|
|
402
|
-
click.echo(click.style(f"Failed: {exc}", fg="red"))
|
|
507
|
+
click.echo(click.style(f"Failed: {exc}", fg="red"))
|
portacode/connection/client.py
CHANGED
|
@@ -8,6 +8,8 @@ from typing import Optional
|
|
|
8
8
|
import json
|
|
9
9
|
import base64
|
|
10
10
|
import sys
|
|
11
|
+
import secrets
|
|
12
|
+
import time
|
|
11
13
|
|
|
12
14
|
import websockets
|
|
13
15
|
from websockets import WebSocketClientProtocol
|
|
@@ -36,6 +38,12 @@ class ConnectionManager:
|
|
|
36
38
|
service manager restart.
|
|
37
39
|
"""
|
|
38
40
|
|
|
41
|
+
CLOCK_SYNC_INTERVAL = 60.0
|
|
42
|
+
CLOCK_SYNC_FAST_INTERVAL = 1.0
|
|
43
|
+
CLOCK_SYNC_INITIAL_REQUESTS = 5
|
|
44
|
+
CLOCK_SYNC_TIMEOUT = 20.0
|
|
45
|
+
CLOCK_SYNC_MAX_FAILURES = 3
|
|
46
|
+
|
|
39
47
|
def __init__(self, gateway_url: str, keypair: KeyPair, reconnect_delay: float = 1.0, max_retries: int = None, debug: bool = False):
|
|
40
48
|
self.gateway_url = gateway_url
|
|
41
49
|
self.keypair = keypair
|
|
@@ -49,6 +57,12 @@ class ConnectionManager:
|
|
|
49
57
|
|
|
50
58
|
self.websocket: Optional[WebSocketClientProtocol] = None
|
|
51
59
|
self.mux: Optional[Multiplexer] = None
|
|
60
|
+
self._clock_sync_task: Optional[asyncio.Task[None]] = None
|
|
61
|
+
self._clock_sync_future: Optional[asyncio.Future] = None
|
|
62
|
+
self._clock_sync_request_id: Optional[str] = None
|
|
63
|
+
self._clock_sync_sent_at: Optional[float] = None
|
|
64
|
+
self._clock_sync_failures = 0
|
|
65
|
+
self._remaining_initial_syncs = self.CLOCK_SYNC_INITIAL_REQUESTS
|
|
52
66
|
|
|
53
67
|
async def start(self) -> None:
|
|
54
68
|
"""Start the background task that maintains the connection."""
|
|
@@ -93,8 +107,12 @@ class ConnectionManager:
|
|
|
93
107
|
except Exception as exc:
|
|
94
108
|
logger.warning("TerminalManager unavailable: %s", LogCategory.TERMINAL, exc)
|
|
95
109
|
|
|
96
|
-
|
|
97
|
-
|
|
110
|
+
self._start_clock_sync_task()
|
|
111
|
+
try:
|
|
112
|
+
# Start main receive loop until closed or stop requested
|
|
113
|
+
await self._listen()
|
|
114
|
+
finally:
|
|
115
|
+
await self._stop_clock_sync_task()
|
|
98
116
|
except (OSError, websockets.WebSocketException, asyncio.TimeoutError) as exc:
|
|
99
117
|
attempt += 1
|
|
100
118
|
logger.warning("Connection error: %s", LogCategory.CONNECTION, exc)
|
|
@@ -142,6 +160,7 @@ class ConnectionManager:
|
|
|
142
160
|
print("Press Cmd+C to close the connection.")
|
|
143
161
|
else:
|
|
144
162
|
print("Press Ctrl+C to close the connection.")
|
|
163
|
+
# Finished authentication flow.
|
|
145
164
|
|
|
146
165
|
async def _listen(self) -> None:
|
|
147
166
|
assert self.websocket is not None, "WebSocket not ready"
|
|
@@ -149,11 +168,17 @@ class ConnectionManager:
|
|
|
149
168
|
try:
|
|
150
169
|
message = await asyncio.wait_for(self.websocket.recv(), timeout=1.0)
|
|
151
170
|
|
|
152
|
-
# Add device_receive timestamp if trace present
|
|
153
171
|
try:
|
|
154
|
-
import json
|
|
155
172
|
data = json.loads(message)
|
|
173
|
+
except Exception:
|
|
174
|
+
data = None
|
|
175
|
+
|
|
176
|
+
if isinstance(data, dict):
|
|
156
177
|
payload = data.get("payload", {})
|
|
178
|
+
if isinstance(payload, dict) and payload.get("event") == "clock_sync_response":
|
|
179
|
+
receive_time_ms = int(time.time() * 1000)
|
|
180
|
+
await self._handle_clock_sync_response(payload, receive_time_ms)
|
|
181
|
+
continue
|
|
157
182
|
if isinstance(payload, dict) and "trace" in payload and "request_id" in payload:
|
|
158
183
|
from portacode.utils.ntp_clock import ntp_clock
|
|
159
184
|
device_receive_time = ntp_clock.now_ms()
|
|
@@ -161,11 +186,8 @@ class ConnectionManager:
|
|
|
161
186
|
payload["trace"]["device_receive"] = device_receive_time
|
|
162
187
|
if "client_send" in payload["trace"]:
|
|
163
188
|
payload["trace"]["ping"] = device_receive_time - payload["trace"]["client_send"]
|
|
164
|
-
# Re-serialize with updated trace
|
|
165
189
|
message = json.dumps(data)
|
|
166
190
|
logger.info(f"📥 Device received traced message: {payload['request_id']}")
|
|
167
|
-
except:
|
|
168
|
-
pass # Not a traced message, continue normally
|
|
169
191
|
|
|
170
192
|
if self.mux:
|
|
171
193
|
await self.mux.on_raw_message(message)
|
|
@@ -179,6 +201,103 @@ class ConnectionManager:
|
|
|
179
201
|
except Exception:
|
|
180
202
|
pass
|
|
181
203
|
|
|
204
|
+
def _start_clock_sync_task(self) -> None:
|
|
205
|
+
if self._clock_sync_task:
|
|
206
|
+
self._clock_sync_task.cancel()
|
|
207
|
+
self._clock_sync_task = asyncio.create_task(self._clock_sync_loop())
|
|
208
|
+
self._remaining_initial_syncs = self.CLOCK_SYNC_INITIAL_REQUESTS
|
|
209
|
+
|
|
210
|
+
async def _stop_clock_sync_task(self) -> None:
|
|
211
|
+
if self._clock_sync_task:
|
|
212
|
+
self._clock_sync_task.cancel()
|
|
213
|
+
try:
|
|
214
|
+
await self._clock_sync_task
|
|
215
|
+
except asyncio.CancelledError:
|
|
216
|
+
pass
|
|
217
|
+
self._clock_sync_task = None
|
|
218
|
+
if self._clock_sync_future and not self._clock_sync_future.done():
|
|
219
|
+
self._clock_sync_future.cancel()
|
|
220
|
+
self._clock_sync_future = None
|
|
221
|
+
self._clock_sync_request_id = None
|
|
222
|
+
self._clock_sync_sent_at = None
|
|
223
|
+
|
|
224
|
+
async def _clock_sync_loop(self) -> None:
|
|
225
|
+
try:
|
|
226
|
+
while self._remaining_initial_syncs > 0 and not self._stop_event.is_set():
|
|
227
|
+
await self._perform_clock_sync()
|
|
228
|
+
self._remaining_initial_syncs -= 1
|
|
229
|
+
if self._remaining_initial_syncs > 0:
|
|
230
|
+
await asyncio.sleep(self.CLOCK_SYNC_FAST_INTERVAL)
|
|
231
|
+
except asyncio.CancelledError:
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
async def _perform_clock_sync(self) -> None:
|
|
235
|
+
if not self.websocket or getattr(self.websocket, "closed", False):
|
|
236
|
+
return
|
|
237
|
+
request_id = f"clock_sync:{secrets.token_urlsafe(6)}"
|
|
238
|
+
payload = {
|
|
239
|
+
"event": "clock_sync_request",
|
|
240
|
+
"request_id": request_id,
|
|
241
|
+
}
|
|
242
|
+
message = json.dumps({"channel": 0, "payload": payload})
|
|
243
|
+
self._clock_sync_request_id = request_id
|
|
244
|
+
self._clock_sync_sent_at = int(time.time() * 1000)
|
|
245
|
+
loop = asyncio.get_running_loop()
|
|
246
|
+
self._clock_sync_future = loop.create_future()
|
|
247
|
+
try:
|
|
248
|
+
await self.websocket.send(message)
|
|
249
|
+
except Exception as exc:
|
|
250
|
+
logger.warning("Clock sync send failed: %s", exc)
|
|
251
|
+
await self._handle_clock_sync_timeout()
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
await asyncio.wait_for(self._clock_sync_future, timeout=self.CLOCK_SYNC_TIMEOUT)
|
|
256
|
+
self._clock_sync_failures = 0
|
|
257
|
+
except asyncio.TimeoutError:
|
|
258
|
+
await self._handle_clock_sync_timeout()
|
|
259
|
+
finally:
|
|
260
|
+
self._clock_sync_future = None
|
|
261
|
+
self._clock_sync_request_id = None
|
|
262
|
+
self._clock_sync_sent_at = None
|
|
263
|
+
|
|
264
|
+
async def _handle_clock_sync_response(self, payload: dict, receive_time_ms: int) -> None:
|
|
265
|
+
if payload.get("request_id") != self._clock_sync_request_id:
|
|
266
|
+
return
|
|
267
|
+
if self._clock_sync_future and not self._clock_sync_future.done():
|
|
268
|
+
self._clock_sync_future.set_result(payload)
|
|
269
|
+
server_receive_time = payload.get("server_receive_time")
|
|
270
|
+
server_send_time = payload.get("server_send_time")
|
|
271
|
+
if server_receive_time is not None and server_send_time is not None:
|
|
272
|
+
processing_latency = max(server_send_time - server_receive_time, 0)
|
|
273
|
+
server_time = round(server_receive_time + processing_latency / 2)
|
|
274
|
+
elif server_receive_time is not None:
|
|
275
|
+
server_time = server_receive_time
|
|
276
|
+
elif server_send_time is not None:
|
|
277
|
+
server_time = server_send_time
|
|
278
|
+
else:
|
|
279
|
+
server_time = payload.get("server_time")
|
|
280
|
+
sent_at = self._clock_sync_sent_at
|
|
281
|
+
if server_time is None or sent_at is None:
|
|
282
|
+
return
|
|
283
|
+
round_trip_ms = max(receive_time_ms - sent_at, 0)
|
|
284
|
+
from portacode.utils.ntp_clock import ntp_clock
|
|
285
|
+
ntp_clock.update_from_server(server_time, round_trip_ms)
|
|
286
|
+
self._clock_sync_failures = 0
|
|
287
|
+
|
|
288
|
+
async def _handle_clock_sync_timeout(self) -> None:
|
|
289
|
+
self._clock_sync_failures += 1
|
|
290
|
+
logger.warning(
|
|
291
|
+
"Clock sync timeout (%d/%d), scheduling reconnect...",
|
|
292
|
+
self._clock_sync_failures,
|
|
293
|
+
self.CLOCK_SYNC_MAX_FAILURES,
|
|
294
|
+
)
|
|
295
|
+
if self._clock_sync_failures >= self.CLOCK_SYNC_MAX_FAILURES and self.websocket:
|
|
296
|
+
try:
|
|
297
|
+
await self.websocket.close()
|
|
298
|
+
except Exception:
|
|
299
|
+
pass
|
|
300
|
+
|
|
182
301
|
|
|
183
302
|
async def run_until_interrupt(manager: ConnectionManager) -> None:
|
|
184
303
|
stop_event = asyncio.Event()
|
|
@@ -206,4 +325,4 @@ async def run_until_interrupt(manager: ConnectionManager) -> None:
|
|
|
206
325
|
# TODO: Add cleanup logic here (e.g., close sockets, remove PID files, flush logs)
|
|
207
326
|
pass
|
|
208
327
|
await manager.stop()
|
|
209
|
-
# TODO: Add any final cleanup logic here (e.g., remove PID files, flush logs)
|
|
328
|
+
# TODO: Add any final cleanup logic here (e.g., remove PID files, flush logs)
|