portacode 0.3.19.dev4__py3-none-any.whl → 1.4.11.dev1__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 +16 -3
- portacode/cli.py +143 -17
- portacode/connection/client.py +149 -10
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +824 -21
- portacode/connection/handlers/__init__.py +28 -1
- portacode/connection/handlers/base.py +78 -16
- portacode/connection/handlers/chunked_content.py +244 -0
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +902 -17
- portacode/connection/handlers/project_aware_file_handlers.py +226 -0
- portacode/connection/handlers/project_state/README.md +312 -0
- portacode/connection/handlers/project_state/__init__.py +92 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
- portacode/connection/handlers/project_state/git_manager.py +1502 -0
- portacode/connection/handlers/project_state/handlers.py +875 -0
- portacode/connection/handlers/project_state/manager.py +1331 -0
- portacode/connection/handlers/project_state/models.py +108 -0
- portacode/connection/handlers/project_state/utils.py +50 -0
- portacode/connection/handlers/project_state_handlers.py +45 -2185
- portacode/connection/handlers/proxmox_infra.py +361 -0
- portacode/connection/handlers/registry.py +15 -4
- portacode/connection/handlers/session.py +483 -32
- portacode/connection/handlers/system_handlers.py +147 -8
- portacode/connection/handlers/tab_factory.py +53 -46
- portacode/connection/handlers/terminal_handlers.py +21 -8
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/multiplex.py +60 -2
- portacode/connection/terminal.py +214 -24
- 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/logging_categories.py +140 -0
- portacode/pairing.py +103 -0
- portacode/static/js/test-ntp-clock.html +63 -0
- portacode/static/js/utils/ntp-clock.js +232 -0
- portacode/utils/NTP_ARCHITECTURE.md +136 -0
- portacode/utils/__init__.py +1 -0
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +65 -0
- portacode-1.4.11.dev1.dist-info/METADATA +298 -0
- portacode-1.4.11.dev1.dist-info/RECORD +97 -0
- {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/WHEEL +1 -1
- portacode-1.4.11.dev1.dist-info/top_level.txt +3 -0
- test_modules/README.md +296 -0
- test_modules/__init__.py +1 -0
- test_modules/test_device_online.py +44 -0
- test_modules/test_file_operations.py +743 -0
- test_modules/test_git_status_ui.py +370 -0
- test_modules/test_login_flow.py +50 -0
- test_modules/test_navigate_testing_folder.py +361 -0
- test_modules/test_play_store_screenshots.py +294 -0
- test_modules/test_terminal_buffer_performance.py +261 -0
- test_modules/test_terminal_interaction.py +80 -0
- test_modules/test_terminal_loading_race_condition.py +95 -0
- test_modules/test_terminal_start.py +56 -0
- testing_framework/.env.example +21 -0
- testing_framework/README.md +334 -0
- testing_framework/__init__.py +17 -0
- testing_framework/cli.py +326 -0
- testing_framework/core/__init__.py +1 -0
- testing_framework/core/base_test.py +336 -0
- testing_framework/core/cli_manager.py +177 -0
- testing_framework/core/hierarchical_runner.py +577 -0
- testing_framework/core/playwright_manager.py +520 -0
- testing_framework/core/runner.py +447 -0
- testing_framework/core/shared_cli_manager.py +234 -0
- testing_framework/core/test_discovery.py +112 -0
- testing_framework/requirements.txt +12 -0
- portacode-0.3.19.dev4.dist-info/METADATA +0 -241
- portacode-0.3.19.dev4.dist-info/RECORD +0 -30
- portacode-0.3.19.dev4.dist-info/top_level.txt +0 -1
- {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/entry_points.txt +0 -0
- {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info/licenses}/LICENSE +0 -0
portacode/_version.py
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
# file generated by setuptools-scm
|
|
2
2
|
# don't change, don't track in version control
|
|
3
3
|
|
|
4
|
-
__all__ = [
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
5
12
|
|
|
6
13
|
TYPE_CHECKING = False
|
|
7
14
|
if TYPE_CHECKING:
|
|
@@ -9,13 +16,19 @@ if TYPE_CHECKING:
|
|
|
9
16
|
from typing import Union
|
|
10
17
|
|
|
11
18
|
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
12
20
|
else:
|
|
13
21
|
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
14
23
|
|
|
15
24
|
version: str
|
|
16
25
|
__version__: str
|
|
17
26
|
__version_tuple__: VERSION_TUPLE
|
|
18
27
|
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
19
30
|
|
|
20
|
-
__version__ = version = '
|
|
21
|
-
__version_tuple__ = version_tuple = (
|
|
31
|
+
__version__ = version = '1.4.11.dev1'
|
|
32
|
+
__version_tuple__ = version_tuple = (1, 4, 11, 'dev1')
|
|
33
|
+
|
|
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
|
-
GATEWAY_URL = "wss://
|
|
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()
|
|
@@ -30,19 +38,57 @@ def cli() -> None:
|
|
|
30
38
|
@click.option("--gateway", "gateway", "-g", help="Gateway websocket URL (overrides env/ default)")
|
|
31
39
|
@click.option("--detach", "detach", "-d", is_flag=True, help="Run connection in background")
|
|
32
40
|
@click.option("--debug", "debug", is_flag=True, help="Enable debug logging")
|
|
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.")
|
|
33
42
|
@click.option("--non-interactive", "non_interactive", is_flag=True, envvar="PORTACODE_NON_INTERACTIVE", hidden=True,
|
|
34
43
|
help="Skip interactive prompts (used by background service)")
|
|
35
|
-
|
|
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
|
|
36
62
|
"""Connect this machine to Portacode gateway."""
|
|
37
63
|
|
|
38
64
|
# Set up debug logging if requested
|
|
39
65
|
if debug:
|
|
40
66
|
import logging
|
|
67
|
+
from .logging_categories import configure_logging_categories, parse_category_string, list_available_categories
|
|
68
|
+
|
|
69
|
+
# Handle log categories
|
|
70
|
+
if log_categories == "list":
|
|
71
|
+
click.echo(click.style("Available log categories:", fg="cyan"))
|
|
72
|
+
for cat in list_available_categories():
|
|
73
|
+
click.echo(f" • {cat}")
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
enabled_categories = set()
|
|
77
|
+
if log_categories:
|
|
78
|
+
try:
|
|
79
|
+
enabled_categories = parse_category_string(log_categories)
|
|
80
|
+
configure_logging_categories(enabled_categories)
|
|
81
|
+
click.echo(click.style(f"🔍 Debug logging enabled for categories: {', '.join(sorted(enabled_categories))}", fg="yellow"))
|
|
82
|
+
except ValueError as e:
|
|
83
|
+
click.echo(click.style(f"Error: {e}", fg="red"))
|
|
84
|
+
return
|
|
85
|
+
else:
|
|
86
|
+
click.echo(click.style("🔍 Debug logging enabled (all categories)", fg="yellow"))
|
|
87
|
+
|
|
41
88
|
logging.basicConfig(
|
|
42
89
|
level=logging.DEBUG,
|
|
43
90
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
44
91
|
)
|
|
45
|
-
click.echo(click.style("🔍 Debug logging enabled", fg="yellow"))
|
|
46
92
|
|
|
47
93
|
# 1. Ensure only a single connection per user
|
|
48
94
|
pid_file = get_pid_file()
|
|
@@ -53,17 +99,31 @@ def connect(gateway: str | None, detach: bool, debug: bool, non_interactive: boo
|
|
|
53
99
|
other_pid = None
|
|
54
100
|
|
|
55
101
|
if other_pid and is_process_running(other_pid):
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
+
)
|
|
59
109
|
)
|
|
60
|
-
)
|
|
61
|
-
if click.confirm("Terminate the existing connection?", default=False):
|
|
62
|
-
_terminate_process(other_pid)
|
|
63
110
|
pid_file.unlink(missing_ok=True)
|
|
64
111
|
else:
|
|
65
|
-
click.echo(
|
|
66
|
-
|
|
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)
|
|
67
127
|
else:
|
|
68
128
|
# Stale pidfile
|
|
69
129
|
pid_file.unlink(missing_ok=True)
|
|
@@ -71,12 +131,78 @@ def connect(gateway: str | None, detach: bool, debug: bool, non_interactive: boo
|
|
|
71
131
|
# Determine gateway URL
|
|
72
132
|
target_gateway = gateway or os.getenv(GATEWAY_ENV) or GATEWAY_URL
|
|
73
133
|
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
|
|
76
202
|
fingerprint = fingerprint_public_key(keypair.public_key_pem)
|
|
77
203
|
|
|
78
204
|
pubkey_b64 = keypair.public_key_der_b64()
|
|
79
|
-
if not non_interactive:
|
|
205
|
+
if not non_interactive and not pairing_requested:
|
|
80
206
|
# Show key generation status
|
|
81
207
|
if getattr(keypair, '_is_new', False):
|
|
82
208
|
click.echo()
|
|
@@ -94,7 +220,7 @@ def connect(gateway: str | None, detach: bool, debug: bool, non_interactive: boo
|
|
|
94
220
|
click.echo(click.style("🚀 Welcome to Portacode!", fg="bright_blue", bold=True))
|
|
95
221
|
click.echo()
|
|
96
222
|
click.echo(click.style("📱 Next steps:", fg="bright_cyan", bold=True))
|
|
97
|
-
click.echo(click.style(" 1. Visit ", fg="white") + click.style("https://
|
|
223
|
+
click.echo(click.style(" 1. Visit ", fg="white") + click.style("https://portacode.com", fg="bright_blue", underline=True))
|
|
98
224
|
click.echo(click.style(" 2. Create your free account or sign in", fg="white"))
|
|
99
225
|
click.echo(click.style(" 3. Add this device using the key below", fg="white"))
|
|
100
226
|
click.echo()
|
|
@@ -378,4 +504,4 @@ def service_status(verbose: bool) -> None: # noqa: D401
|
|
|
378
504
|
click.echo("\n--- system output ---")
|
|
379
505
|
click.echo(mgr.status_verbose())
|
|
380
506
|
except Exception as exc:
|
|
381
|
-
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,14 +8,17 @@ 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
|
|
14
16
|
|
|
15
17
|
from ..keypair import KeyPair
|
|
16
18
|
from .multiplex import Multiplexer
|
|
19
|
+
from ..logging_categories import get_categorized_logger, LogCategory
|
|
17
20
|
|
|
18
|
-
logger =
|
|
21
|
+
logger = get_categorized_logger(__name__)
|
|
19
22
|
|
|
20
23
|
|
|
21
24
|
class ConnectionManager:
|
|
@@ -24,7 +27,7 @@ class ConnectionManager:
|
|
|
24
27
|
Parameters
|
|
25
28
|
----------
|
|
26
29
|
gateway_url: str
|
|
27
|
-
WebSocket URL, e.g. ``wss://
|
|
30
|
+
WebSocket URL, e.g. ``wss://portacode.com/gateway``
|
|
28
31
|
keypair: KeyPair
|
|
29
32
|
User's public/private keypair used for authentication.
|
|
30
33
|
reconnect_delay: float
|
|
@@ -35,6 +38,12 @@ class ConnectionManager:
|
|
|
35
38
|
service manager restart.
|
|
36
39
|
"""
|
|
37
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
|
+
|
|
38
47
|
def __init__(self, gateway_url: str, keypair: KeyPair, reconnect_delay: float = 1.0, max_retries: int = None, debug: bool = False):
|
|
39
48
|
self.gateway_url = gateway_url
|
|
40
49
|
self.keypair = keypair
|
|
@@ -48,6 +57,12 @@ class ConnectionManager:
|
|
|
48
57
|
|
|
49
58
|
self.websocket: Optional[WebSocketClientProtocol] = None
|
|
50
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
|
|
51
66
|
|
|
52
67
|
async def start(self) -> None:
|
|
53
68
|
"""Start the background task that maintains the connection."""
|
|
@@ -67,9 +82,9 @@ class ConnectionManager:
|
|
|
67
82
|
try:
|
|
68
83
|
if attempt:
|
|
69
84
|
delay = min(self.reconnect_delay * 2 ** (attempt - 1), 30)
|
|
70
|
-
logger.warning("Reconnecting in %.1f s (attempt %d)…", delay, attempt)
|
|
85
|
+
logger.warning("Reconnecting in %.1f s (attempt %d)…", LogCategory.CONNECTION, delay, attempt)
|
|
71
86
|
await asyncio.sleep(delay)
|
|
72
|
-
logger.info("Connecting to gateway at %s", self.gateway_url)
|
|
87
|
+
logger.info("Connecting to gateway at %s", LogCategory.CONNECTION, self.gateway_url)
|
|
73
88
|
async with websockets.connect(self.gateway_url) as ws:
|
|
74
89
|
# Reset attempt counter after successful connection
|
|
75
90
|
attempt = 0
|
|
@@ -90,19 +105,23 @@ class ConnectionManager:
|
|
|
90
105
|
else:
|
|
91
106
|
self._terminal_manager = TerminalManager(self.mux, debug=self.debug) # noqa: pylint=attribute-defined-outside-init
|
|
92
107
|
except Exception as exc:
|
|
93
|
-
logger.warning("TerminalManager unavailable: %s", exc)
|
|
108
|
+
logger.warning("TerminalManager unavailable: %s", LogCategory.TERMINAL, exc)
|
|
94
109
|
|
|
95
|
-
|
|
96
|
-
|
|
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()
|
|
97
116
|
except (OSError, websockets.WebSocketException, asyncio.TimeoutError) as exc:
|
|
98
117
|
attempt += 1
|
|
99
|
-
logger.warning("Connection error: %s", exc)
|
|
118
|
+
logger.warning("Connection error: %s", LogCategory.CONNECTION, exc)
|
|
100
119
|
# Remove the max_retries limit - keep trying indefinitely
|
|
101
120
|
# The service manager (systemd) will handle any necessary restarts
|
|
102
121
|
except Exception as exc:
|
|
103
122
|
# For truly fatal errors (like authentication failures),
|
|
104
123
|
# log and exit cleanly so systemd can restart the service
|
|
105
|
-
logger.exception("Fatal error in connection manager: %s", exc)
|
|
124
|
+
logger.exception("Fatal error in connection manager: %s", LogCategory.CONNECTION, exc)
|
|
106
125
|
# Exit cleanly to allow systemd restart
|
|
107
126
|
sys.exit(1)
|
|
108
127
|
|
|
@@ -141,12 +160,35 @@ class ConnectionManager:
|
|
|
141
160
|
print("Press Cmd+C to close the connection.")
|
|
142
161
|
else:
|
|
143
162
|
print("Press Ctrl+C to close the connection.")
|
|
163
|
+
# Finished authentication flow.
|
|
144
164
|
|
|
145
165
|
async def _listen(self) -> None:
|
|
146
166
|
assert self.websocket is not None, "WebSocket not ready"
|
|
147
167
|
while not self._stop_event.is_set():
|
|
148
168
|
try:
|
|
149
169
|
message = await asyncio.wait_for(self.websocket.recv(), timeout=1.0)
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
data = json.loads(message)
|
|
173
|
+
except Exception:
|
|
174
|
+
data = None
|
|
175
|
+
|
|
176
|
+
if isinstance(data, dict):
|
|
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
|
|
182
|
+
if isinstance(payload, dict) and "trace" in payload and "request_id" in payload:
|
|
183
|
+
from portacode.utils.ntp_clock import ntp_clock
|
|
184
|
+
device_receive_time = ntp_clock.now_ms()
|
|
185
|
+
if device_receive_time is not None:
|
|
186
|
+
payload["trace"]["device_receive"] = device_receive_time
|
|
187
|
+
if "client_send" in payload["trace"]:
|
|
188
|
+
payload["trace"]["ping"] = device_receive_time - payload["trace"]["client_send"]
|
|
189
|
+
message = json.dumps(data)
|
|
190
|
+
logger.info(f"📥 Device received traced message: {payload['request_id']}")
|
|
191
|
+
|
|
150
192
|
if self.mux:
|
|
151
193
|
await self.mux.on_raw_message(message)
|
|
152
194
|
except asyncio.TimeoutError:
|
|
@@ -159,6 +201,103 @@ class ConnectionManager:
|
|
|
159
201
|
except Exception:
|
|
160
202
|
pass
|
|
161
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
|
+
|
|
162
301
|
|
|
163
302
|
async def run_until_interrupt(manager: ConnectionManager) -> None:
|
|
164
303
|
stop_event = asyncio.Event()
|
|
@@ -186,4 +325,4 @@ async def run_until_interrupt(manager: ConnectionManager) -> None:
|
|
|
186
325
|
# TODO: Add cleanup logic here (e.g., close sockets, remove PID files, flush logs)
|
|
187
326
|
pass
|
|
188
327
|
await manager.stop()
|
|
189
|
-
# 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)
|