portacode 1.3.26__py3-none-any.whl → 1.3.27__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.
- portacode/_version.py +2 -2
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +131 -13
- portacode/connection/handlers/base.py +70 -12
- portacode/connection/handlers/file_handlers.py +5 -10
- portacode/connection/handlers/project_state/handlers.py +9 -6
- portacode/connection/handlers/registry.py +15 -4
- portacode/static/js/test-ntp-clock.html +63 -0
- portacode/static/js/utils/ntp-clock.js +141 -0
- portacode/utils/NTP_ARCHITECTURE.md +136 -0
- portacode/utils/__init__.py +1 -0
- portacode/utils/ntp_clock.py +151 -0
- {portacode-1.3.26.dist-info → portacode-1.3.27.dist-info}/METADATA +2 -1
- {portacode-1.3.26.dist-info → portacode-1.3.27.dist-info}/RECORD +17 -12
- {portacode-1.3.26.dist-info → portacode-1.3.27.dist-info}/WHEEL +0 -0
- {portacode-1.3.26.dist-info → portacode-1.3.27.dist-info}/entry_points.txt +0 -0
- {portacode-1.3.26.dist-info → portacode-1.3.27.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.3.26.dist-info → portacode-1.3.27.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.3.
|
|
32
|
-
__version_tuple__ = version_tuple = (1, 3,
|
|
31
|
+
__version__ = version = '1.3.27'
|
|
32
|
+
__version_tuple__ = version_tuple = (1, 3, 27)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -1,10 +1,38 @@
|
|
|
1
1
|
# WebSocket Communication Protocol
|
|
2
2
|
|
|
3
|
-
This document outlines the WebSocket communication protocol
|
|
3
|
+
This document outlines the WebSocket communication protocol used in Portacode. The protocol involves three main participants: client sessions, the Portacode server, and devices.
|
|
4
|
+
|
|
5
|
+
## Architecture Overview
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─────────────┐ ┌──────────────────┐ ┌─────────────────────────┐
|
|
9
|
+
│ Client │ │ Portacode │ │ Device │
|
|
10
|
+
│ Session │◄────────►│ Server │◄────────►│ (Portacode CLI or │
|
|
11
|
+
│ │ │ │ │ Python package) │
|
|
12
|
+
└─────────────┘ └──────────────────┘ └─────────────────────────┘
|
|
13
|
+
│ │ │
|
|
14
|
+
│ │ │
|
|
15
|
+
Client-Side Acts as middleman Device-Side
|
|
16
|
+
Protocol - Routes messages Protocol
|
|
17
|
+
- Manages sessions
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The Portacode server acts as a **routing middleman** between client sessions and devices. It manages routing fields that are included in messages to specify routing destinations but are removed or transformed before reaching the final recipient:
|
|
21
|
+
|
|
22
|
+
**Routing Fields Behavior:**
|
|
23
|
+
|
|
24
|
+
- **`device_id`** (Client → Server): Client includes this to specify which device to route to. Server uses it for routing, then **removes it** before forwarding to the device (the device knows the message is for them). Server **adds it** when routing device responses back to clients (so clients know which device the message came from).
|
|
25
|
+
|
|
26
|
+
- **`client_sessions`** (Device → Server): Device includes this to specify which client session(s) to route to. Server uses it for routing, then **removes it** before forwarding to clients (clients just receive the message without seeing routing metadata).
|
|
27
|
+
|
|
28
|
+
- **`source_client_session`** (Server → Device): Server **adds this** when forwarding client commands to devices (so device knows which client sent the command and can target responses back). Clients never include this field.
|
|
29
|
+
|
|
30
|
+
This document describes the complete protocol for communicating with devices through the server, guiding app developers on how to get their client sessions to communicate with devices.
|
|
4
31
|
|
|
5
32
|
## Table of Contents
|
|
6
33
|
|
|
7
|
-
- [Raw Message Format](#raw-message-format)
|
|
34
|
+
- [Raw Message Format On Device Side](#raw-message-format-on-device-side)
|
|
35
|
+
- [Raw Message Format On Client Side](#raw-message-format-on-client-side)
|
|
8
36
|
- [Actions](#actions)
|
|
9
37
|
- [Terminal Actions](#terminal-actions)
|
|
10
38
|
- [`terminal_start`](#terminal_start)
|
|
@@ -82,11 +110,11 @@ This document outlines the WebSocket communication protocol between the Portacod
|
|
|
82
110
|
- [`device_status`](#device_status)
|
|
83
111
|
- [`devices`](#devices)
|
|
84
112
|
|
|
85
|
-
## Raw Message Format
|
|
113
|
+
## Raw Message Format On Device Side
|
|
86
114
|
|
|
87
|
-
|
|
115
|
+
Communication between the server and devices uses a [multiplexer](./multiplex.py) that wraps every message in a JSON object with a `channel` and a `payload`. This allows for multiple virtual communication channels over a single WebSocket connection.
|
|
88
116
|
|
|
89
|
-
**
|
|
117
|
+
**Device-Side Message Structure:**
|
|
90
118
|
|
|
91
119
|
```json
|
|
92
120
|
{
|
|
@@ -97,9 +125,99 @@ All communication over the WebSocket is managed by a [multiplexer](./multiplex.p
|
|
|
97
125
|
}
|
|
98
126
|
```
|
|
99
127
|
|
|
100
|
-
|
|
128
|
+
**Field Descriptions:**
|
|
129
|
+
|
|
130
|
+
* `channel` (string|integer, mandatory): Identifies the virtual channel the message is for. When sending control commands to the device, they should be sent to channel 0 and when the device responds to such control commands or sends system events, they will also be sent on the zero channel. When a terminal session is created in the device, it is assigned a uuid, the uuid becomes the channel for communicating to that specific terminal.
|
|
101
131
|
* `payload` (object, mandatory): The content of the message, which will be either an [Action](#actions) or an [Event](#events) object.
|
|
102
132
|
|
|
133
|
+
**Channel Types:**
|
|
134
|
+
- **Channel 0** (control channel): Used for system commands, terminal management, file operations, and project state management
|
|
135
|
+
- **Channel UUID** (terminal channel): Used for terminal I/O to a specific terminal session
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Raw Message Format On Client Side
|
|
140
|
+
|
|
141
|
+
Client sessions communicate with the server using a unified message format with the same field names as the device protocol, plus routing information.
|
|
142
|
+
|
|
143
|
+
**Client-Side Message Structure (Client → Server):**
|
|
144
|
+
|
|
145
|
+
```json
|
|
146
|
+
{
|
|
147
|
+
"device_id": <number>,
|
|
148
|
+
"channel": <number|string>,
|
|
149
|
+
"payload": {
|
|
150
|
+
"cmd": "<command_name>",
|
|
151
|
+
...command-specific fields
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Field Descriptions:**
|
|
157
|
+
|
|
158
|
+
* `device_id` (number, mandatory): Routing field - specifies which device to send the message to. The server validates that the client has access to this device before forwarding.
|
|
159
|
+
* `channel` (number|string, mandatory): Same as device protocol - the target channel (0 for control, UUID for terminal). Uses the same field name for consistency.
|
|
160
|
+
* `payload` (object, mandatory): Same as device protocol - the command payload. Uses the same field name for consistency.
|
|
161
|
+
|
|
162
|
+
**Server Transformation (Client → Device):**
|
|
163
|
+
|
|
164
|
+
When the server receives a client message, it:
|
|
165
|
+
1. Validates client has access to the specified `device_id`
|
|
166
|
+
2. **Removes** `device_id` from the message (device doesn't need to be told "this is for you")
|
|
167
|
+
3. **Adds** `source_client_session` to the payload (so device knows which client to respond to)
|
|
168
|
+
4. Forwards to device: `{channel, payload: {...payload, source_client_session}}`
|
|
169
|
+
|
|
170
|
+
**Server Transformation (Device → Client):**
|
|
171
|
+
|
|
172
|
+
When the server receives a device response, it:
|
|
173
|
+
1. **Adds** `device_id` to the message (so client knows which device it came from, based on authenticated device connection)
|
|
174
|
+
2. **Removes** `client_sessions` routing metadata (clients don't need to see routing info)
|
|
175
|
+
3. Routes to appropriate client session(s)
|
|
176
|
+
|
|
177
|
+
**Server Response Format (Server → Client):**
|
|
178
|
+
|
|
179
|
+
```json
|
|
180
|
+
{
|
|
181
|
+
"event": "<event_name>",
|
|
182
|
+
"device_id": <number>,
|
|
183
|
+
...event-specific fields
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
**Field Descriptions:**
|
|
188
|
+
|
|
189
|
+
* `event` (string, mandatory): The name of the event being sent.
|
|
190
|
+
* `device_id` (number, mandatory): Authenticated field - identifies which device the event came from (added by server based on authenticated device connection).
|
|
191
|
+
* Additional fields depend on the specific event type.
|
|
192
|
+
|
|
193
|
+
**Example Client Message:**
|
|
194
|
+
```json
|
|
195
|
+
{
|
|
196
|
+
"device_id": 42,
|
|
197
|
+
"channel": 0,
|
|
198
|
+
"payload": {
|
|
199
|
+
"cmd": "terminal_start",
|
|
200
|
+
"shell": "bash",
|
|
201
|
+
"cwd": "/home/user/project"
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**Example Server Response:**
|
|
207
|
+
```json
|
|
208
|
+
{
|
|
209
|
+
"event": "terminal_started",
|
|
210
|
+
"device_id": 42,
|
|
211
|
+
"terminal_id": "uuid-1234-5678",
|
|
212
|
+
"channel": "uuid-1234-5678",
|
|
213
|
+
"pid": 12345
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**Note:** The server acts as a translator between the client-side and device-side protocols:
|
|
218
|
+
- When a client sends a command, the server transforms it from the client format to the device format
|
|
219
|
+
- When a device sends an event, the server adds the `device_id` and routes it to the appropriate client sessions
|
|
220
|
+
|
|
103
221
|
---
|
|
104
222
|
|
|
105
223
|
## Actions
|
|
@@ -110,18 +228,18 @@ Actions are messages sent from the server to the device, placed within the `payl
|
|
|
110
228
|
|
|
111
229
|
```json
|
|
112
230
|
{
|
|
113
|
-
"
|
|
114
|
-
"
|
|
115
|
-
|
|
116
|
-
"...": "..."
|
|
117
|
-
},
|
|
231
|
+
"cmd": "<command_name>",
|
|
232
|
+
"arg1": "value1",
|
|
233
|
+
"arg2": "value2",
|
|
118
234
|
"source_client_session": "channel.abc123"
|
|
119
235
|
}
|
|
120
236
|
```
|
|
121
237
|
|
|
122
|
-
|
|
123
|
-
|
|
238
|
+
**Field Descriptions:**
|
|
239
|
+
|
|
240
|
+
* `cmd` (string, mandatory): The name of the action to be executed (e.g., `terminal_start`, `file_read`, `system_info`).
|
|
124
241
|
* `source_client_session` (string, mandatory): The channel name of the client session that originated this command. This field is automatically added by the server and allows devices to identify which specific client sent the command.
|
|
242
|
+
* Additional fields depend on the specific command (see individual command documentation below).
|
|
125
243
|
|
|
126
244
|
**Note**: Actions do not require targeting information - responses are automatically routed using the client session management system.
|
|
127
245
|
|
|
@@ -4,6 +4,7 @@ import asyncio
|
|
|
4
4
|
import logging
|
|
5
5
|
from abc import ABC, abstractmethod
|
|
6
6
|
from typing import Any, Dict, Optional, TYPE_CHECKING
|
|
7
|
+
from portacode.utils.ntp_clock import ntp_clock
|
|
7
8
|
|
|
8
9
|
if TYPE_CHECKING:
|
|
9
10
|
from ..multiplex import Channel
|
|
@@ -42,35 +43,45 @@ class BaseHandler(ABC):
|
|
|
42
43
|
|
|
43
44
|
async def send_response(self, payload: Dict[str, Any], reply_channel: Optional[str] = None, project_id: str = None) -> None:
|
|
44
45
|
"""Send a response back to the gateway with client session awareness.
|
|
45
|
-
|
|
46
|
+
|
|
46
47
|
Args:
|
|
47
48
|
payload: Response payload
|
|
48
49
|
reply_channel: Optional reply channel for backward compatibility
|
|
49
50
|
project_id: Optional project filter for targeting specific sessions
|
|
50
51
|
"""
|
|
52
|
+
# Add device_send timestamp if trace present
|
|
53
|
+
if "trace" in payload and "request_id" in payload:
|
|
54
|
+
device_send_time = ntp_clock.now_ms()
|
|
55
|
+
if device_send_time is not None:
|
|
56
|
+
payload["trace"]["device_send"] = device_send_time
|
|
57
|
+
# Update ping to show total time from client_send
|
|
58
|
+
if "client_send" in payload["trace"]:
|
|
59
|
+
payload["trace"]["ping"] = device_send_time - payload["trace"]["client_send"]
|
|
60
|
+
logger.info(f"📤 Device sending traced response: {payload['request_id']}")
|
|
61
|
+
|
|
51
62
|
# Get client session manager from context
|
|
52
63
|
client_session_manager = self.context.get("client_session_manager")
|
|
53
|
-
|
|
64
|
+
|
|
54
65
|
if client_session_manager and client_session_manager.has_interested_clients():
|
|
55
66
|
# Get target sessions
|
|
56
67
|
target_sessions = client_session_manager.get_target_sessions(project_id)
|
|
57
68
|
if not target_sessions:
|
|
58
69
|
logger.debug("handler: No target sessions found, skipping response send")
|
|
59
70
|
return
|
|
60
|
-
|
|
71
|
+
|
|
61
72
|
# Add session targeting information
|
|
62
73
|
enhanced_payload = dict(payload)
|
|
63
74
|
enhanced_payload["client_sessions"] = target_sessions
|
|
64
|
-
|
|
75
|
+
|
|
65
76
|
# Add backward compatibility reply_channel (first session if not provided)
|
|
66
77
|
if not reply_channel:
|
|
67
78
|
reply_channel = client_session_manager.get_reply_channel_for_compatibility()
|
|
68
79
|
if reply_channel:
|
|
69
80
|
enhanced_payload["reply_channel"] = reply_channel
|
|
70
|
-
|
|
71
|
-
logger.debug("handler: Sending response to %d client sessions: %s",
|
|
81
|
+
|
|
82
|
+
logger.debug("handler: Sending response to %d client sessions: %s",
|
|
72
83
|
len(target_sessions), target_sessions)
|
|
73
|
-
|
|
84
|
+
|
|
74
85
|
await self.control_channel.send(enhanced_payload)
|
|
75
86
|
else:
|
|
76
87
|
# Fallback to original behavior if no client session manager or no clients
|
|
@@ -107,18 +118,43 @@ class AsyncHandler(BaseHandler):
|
|
|
107
118
|
|
|
108
119
|
async def handle(self, message: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
|
|
109
120
|
"""Handle the command by executing it and sending the response."""
|
|
110
|
-
logger.info("handler: Processing command %s with reply_channel=%s",
|
|
121
|
+
logger.info("handler: Processing command %s with reply_channel=%s",
|
|
111
122
|
self.command_name, reply_channel)
|
|
112
|
-
|
|
123
|
+
|
|
124
|
+
# Add handler_dispatch timestamp if trace present
|
|
125
|
+
if "trace" in message and "request_id" in message:
|
|
126
|
+
handler_dispatch_time = ntp_clock.now_ms()
|
|
127
|
+
if handler_dispatch_time is not None:
|
|
128
|
+
message["trace"]["handler_dispatch"] = handler_dispatch_time
|
|
129
|
+
# Update ping to show total time from client_send
|
|
130
|
+
if "client_send" in message["trace"]:
|
|
131
|
+
message["trace"]["ping"] = handler_dispatch_time - message["trace"]["client_send"]
|
|
132
|
+
logger.info(f"🔧 Handler dispatching: {message['request_id']} ({self.command_name})")
|
|
133
|
+
|
|
113
134
|
try:
|
|
114
135
|
response = await self.execute(message)
|
|
115
136
|
logger.info("handler: Command %s executed successfully", self.command_name)
|
|
116
|
-
|
|
137
|
+
|
|
117
138
|
# Handle cases where execute() sends responses directly and returns None
|
|
118
139
|
if response is not None:
|
|
140
|
+
# Automatically copy request_id if present in the incoming message
|
|
141
|
+
if "request_id" in message and "request_id" not in response:
|
|
142
|
+
response["request_id"] = message["request_id"]
|
|
143
|
+
|
|
144
|
+
# Pass through trace from request to response (add to existing trace, don't create new one)
|
|
145
|
+
if "trace" in message and "request_id" in message:
|
|
146
|
+
response["trace"] = dict(message["trace"])
|
|
147
|
+
handler_complete_time = ntp_clock.now_ms()
|
|
148
|
+
if handler_complete_time is not None:
|
|
149
|
+
response["trace"]["handler_complete"] = handler_complete_time
|
|
150
|
+
# Update ping to show total time from client_send
|
|
151
|
+
if "client_send" in response["trace"]:
|
|
152
|
+
response["trace"]["ping"] = handler_complete_time - response["trace"]["client_send"]
|
|
153
|
+
logger.info(f"✅ Handler completed: {message['request_id']} ({self.command_name})")
|
|
154
|
+
|
|
119
155
|
# Extract project_id from response for session targeting
|
|
120
156
|
project_id = response.get("project_id")
|
|
121
|
-
logger.info("handler: %s response project_id=%s, response=%s",
|
|
157
|
+
logger.info("handler: %s response project_id=%s, response=%s",
|
|
122
158
|
self.command_name, project_id, response)
|
|
123
159
|
await self.send_response(response, reply_channel, project_id)
|
|
124
160
|
else:
|
|
@@ -147,10 +183,32 @@ class SyncHandler(BaseHandler):
|
|
|
147
183
|
|
|
148
184
|
async def handle(self, message: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
|
|
149
185
|
"""Handle the command by executing it in an executor and sending the response."""
|
|
186
|
+
# Add handler_dispatch timestamp if trace present
|
|
187
|
+
if "trace" in message and "request_id" in message:
|
|
188
|
+
handler_dispatch_time = ntp_clock.now_ms()
|
|
189
|
+
if handler_dispatch_time is not None:
|
|
190
|
+
message["trace"]["handler_dispatch"] = handler_dispatch_time
|
|
191
|
+
# Update ping to show total time from client_send
|
|
192
|
+
if "client_send" in message["trace"]:
|
|
193
|
+
message["trace"]["ping"] = handler_dispatch_time - message["trace"]["client_send"]
|
|
194
|
+
logger.info(f"🔧 Handler dispatching: {message['request_id']} ({self.command_name})")
|
|
195
|
+
|
|
150
196
|
try:
|
|
151
197
|
loop = asyncio.get_running_loop()
|
|
152
198
|
response = await loop.run_in_executor(None, self.execute, message)
|
|
153
|
-
|
|
199
|
+
|
|
200
|
+
# Automatically copy request_id if present in the incoming message
|
|
201
|
+
if "request_id" in message and "request_id" not in response:
|
|
202
|
+
response["request_id"] = message["request_id"]
|
|
203
|
+
|
|
204
|
+
# Pass through trace from request to response (add to existing trace, don't create new one)
|
|
205
|
+
if "trace" in message and "request_id" in message:
|
|
206
|
+
response["trace"] = dict(message["trace"])
|
|
207
|
+
handler_complete_time = ntp_clock.now_ms()
|
|
208
|
+
if handler_complete_time is not None:
|
|
209
|
+
response["trace"]["handler_complete"] = handler_complete_time
|
|
210
|
+
logger.info(f"✅ Handler completed: {message['request_id']} ({self.command_name})")
|
|
211
|
+
|
|
154
212
|
# Extract project_id from response for session targeting
|
|
155
213
|
project_id = response.get("project_id")
|
|
156
214
|
await self.send_response(response, reply_channel, project_id)
|
|
@@ -382,22 +382,18 @@ class ContentRequestHandler(AsyncHandler):
|
|
|
382
382
|
async def execute(self, message: Dict[str, Any]) -> None:
|
|
383
383
|
"""Return content by hash if available, chunked for large content."""
|
|
384
384
|
content_hash = message.get("content_hash")
|
|
385
|
-
request_id = message.get("request_id")
|
|
386
385
|
source_client_session = message.get("source_client_session")
|
|
387
|
-
|
|
386
|
+
|
|
388
387
|
if not content_hash:
|
|
389
388
|
raise ValueError("content_hash parameter is required")
|
|
390
|
-
|
|
391
|
-
raise ValueError("request_id parameter is required")
|
|
392
|
-
|
|
389
|
+
|
|
393
390
|
# Check if content is in cache
|
|
394
391
|
content = _content_cache.get(content_hash)
|
|
395
|
-
|
|
392
|
+
|
|
396
393
|
if content is not None:
|
|
397
|
-
# Create base response
|
|
394
|
+
# Create base response (request_id will be added automatically by base class)
|
|
398
395
|
base_response = {
|
|
399
396
|
"event": "content_response",
|
|
400
|
-
"request_id": request_id,
|
|
401
397
|
"content_hash": content_hash,
|
|
402
398
|
"success": True,
|
|
403
399
|
}
|
|
@@ -411,10 +407,9 @@ class ContentRequestHandler(AsyncHandler):
|
|
|
411
407
|
|
|
412
408
|
logger.info(f"Sent content response in {len(responses)} chunk(s) for hash: {content_hash[:16]}...")
|
|
413
409
|
else:
|
|
414
|
-
# Content not found in cache
|
|
410
|
+
# Content not found in cache (request_id will be added automatically by base class)
|
|
415
411
|
response = {
|
|
416
412
|
"event": "content_response",
|
|
417
|
-
"request_id": request_id,
|
|
418
413
|
"content_hash": content_hash,
|
|
419
414
|
"content": None,
|
|
420
415
|
"success": False,
|
|
@@ -683,9 +683,8 @@ class ProjectStateDiffContentHandler(AsyncHandler):
|
|
|
683
683
|
from_hash = message.get("from_hash")
|
|
684
684
|
to_hash = message.get("to_hash")
|
|
685
685
|
content_type = message.get("content_type") # 'original', 'modified', 'html_diff'
|
|
686
|
-
request_id = message.get("request_id")
|
|
687
686
|
source_client_session = message.get("source_client_session")
|
|
688
|
-
|
|
687
|
+
|
|
689
688
|
# Validate required fields
|
|
690
689
|
if not server_project_id:
|
|
691
690
|
raise ValueError("project_id is required")
|
|
@@ -697,8 +696,6 @@ class ProjectStateDiffContentHandler(AsyncHandler):
|
|
|
697
696
|
raise ValueError("to_ref is required")
|
|
698
697
|
if not content_type:
|
|
699
698
|
raise ValueError("content_type is required")
|
|
700
|
-
if not request_id:
|
|
701
|
-
raise ValueError("request_id is required")
|
|
702
699
|
if not source_client_session:
|
|
703
700
|
raise ValueError("source_client_session is required")
|
|
704
701
|
|
|
@@ -816,9 +813,12 @@ class ProjectStateDiffContentHandler(AsyncHandler):
|
|
|
816
813
|
"from_ref": from_ref,
|
|
817
814
|
"to_ref": to_ref,
|
|
818
815
|
"content_type": content_type,
|
|
819
|
-
"request_id": request_id,
|
|
820
816
|
"success": success
|
|
821
817
|
}
|
|
818
|
+
|
|
819
|
+
# Add request_id if present in original message
|
|
820
|
+
if "request_id" in message:
|
|
821
|
+
base_response["request_id"] = message["request_id"]
|
|
822
822
|
|
|
823
823
|
if from_hash:
|
|
824
824
|
base_response["from_hash"] = from_hash
|
|
@@ -850,9 +850,12 @@ class ProjectStateDiffContentHandler(AsyncHandler):
|
|
|
850
850
|
"from_ref": from_ref,
|
|
851
851
|
"to_ref": to_ref,
|
|
852
852
|
"content_type": content_type,
|
|
853
|
-
"request_id": request_id,
|
|
854
853
|
"success": False,
|
|
855
854
|
"error": str(e),
|
|
856
855
|
"chunked": False
|
|
857
856
|
}
|
|
857
|
+
|
|
858
|
+
# Add request_id if present in original message
|
|
859
|
+
if "request_id" in message:
|
|
860
|
+
error_response["request_id"] = message["request_id"]
|
|
858
861
|
await self.send_response(error_response, project_id=server_project_id)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
from typing import Dict, Type, Any, Optional, List, TYPE_CHECKING
|
|
5
|
+
from portacode.utils.ntp_clock import ntp_clock
|
|
5
6
|
|
|
6
7
|
if TYPE_CHECKING:
|
|
7
8
|
from ..multiplex import Channel
|
|
@@ -72,22 +73,32 @@ class CommandRegistry:
|
|
|
72
73
|
|
|
73
74
|
async def dispatch(self, command_name: str, message: Dict[str, Any], reply_channel: Optional[str] = None) -> bool:
|
|
74
75
|
"""Dispatch a command to its handler.
|
|
75
|
-
|
|
76
|
+
|
|
76
77
|
Args:
|
|
77
78
|
command_name: The command name
|
|
78
79
|
message: The command message
|
|
79
80
|
reply_channel: Optional reply channel
|
|
80
|
-
|
|
81
|
+
|
|
81
82
|
Returns:
|
|
82
83
|
True if handler was found and executed, False otherwise
|
|
83
84
|
"""
|
|
84
85
|
logger.info("registry: Dispatching command '%s' with reply_channel=%s", command_name, reply_channel)
|
|
85
|
-
|
|
86
|
+
|
|
87
|
+
# Add device_receive timestamp if trace present
|
|
88
|
+
if "trace" in message and "request_id" in message:
|
|
89
|
+
device_receive_time = ntp_clock.now_ms()
|
|
90
|
+
if device_receive_time is not None:
|
|
91
|
+
message["trace"]["device_receive"] = device_receive_time
|
|
92
|
+
# Update ping to show total time from client_send
|
|
93
|
+
if "client_send" in message["trace"]:
|
|
94
|
+
message["trace"]["ping"] = device_receive_time - message["trace"]["client_send"]
|
|
95
|
+
logger.info(f"📨 Device received traced message: {message['request_id']}")
|
|
96
|
+
|
|
86
97
|
handler = self.get_handler(command_name)
|
|
87
98
|
if handler is None:
|
|
88
99
|
logger.warning("registry: No handler found for command: %s", command_name)
|
|
89
100
|
return False
|
|
90
|
-
|
|
101
|
+
|
|
91
102
|
try:
|
|
92
103
|
await handler.handle(message, reply_channel)
|
|
93
104
|
logger.info("registry: Successfully dispatched command '%s'", command_name)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>NTP Clock Test</title>
|
|
5
|
+
<style>
|
|
6
|
+
body {
|
|
7
|
+
font-family: monospace;
|
|
8
|
+
padding: 20px;
|
|
9
|
+
background: #1e1e1e;
|
|
10
|
+
color: #d4d4d4;
|
|
11
|
+
}
|
|
12
|
+
h1 {
|
|
13
|
+
color: #4ec9b0;
|
|
14
|
+
}
|
|
15
|
+
#status {
|
|
16
|
+
background: #252526;
|
|
17
|
+
padding: 15px;
|
|
18
|
+
border-radius: 5px;
|
|
19
|
+
border: 1px solid #3e3e42;
|
|
20
|
+
}
|
|
21
|
+
.synced {
|
|
22
|
+
color: #4ec9b0;
|
|
23
|
+
}
|
|
24
|
+
.not-synced {
|
|
25
|
+
color: #f48771;
|
|
26
|
+
}
|
|
27
|
+
.label {
|
|
28
|
+
color: #9cdcfe;
|
|
29
|
+
font-weight: bold;
|
|
30
|
+
}
|
|
31
|
+
</style>
|
|
32
|
+
</head>
|
|
33
|
+
<body>
|
|
34
|
+
<h1>NTP Clock Test - time.cloudflare.com</h1>
|
|
35
|
+
<div id="status"></div>
|
|
36
|
+
<script type="module">
|
|
37
|
+
import ntpClock from './utils/ntp-clock.js';
|
|
38
|
+
|
|
39
|
+
function updateDisplay() {
|
|
40
|
+
const status = ntpClock.getStatus();
|
|
41
|
+
const syncClass = status.isSynced ? 'synced' : 'not-synced';
|
|
42
|
+
|
|
43
|
+
document.getElementById('status').innerHTML = `
|
|
44
|
+
<p><span class="label">Sync Status:</span> <span class="${syncClass}">${status.isSynced ? '✅ SYNCED' : '❌ NOT SYNCED'}</span></p>
|
|
45
|
+
<p><span class="label">NTP Server:</span> ${status.server}</p>
|
|
46
|
+
<p><span class="label">NTP Time:</span> ${ntpClock.nowISO() || 'null (not synced)'}</p>
|
|
47
|
+
<p><span class="label">Local Time:</span> ${new Date().toISOString()}</p>
|
|
48
|
+
<p><span class="label">Offset:</span> ${status.offset !== null ? status.offset.toFixed(2) + 'ms' : 'null'}</p>
|
|
49
|
+
<p><span class="label">Last Sync:</span> ${status.lastSync || 'Never'}</p>
|
|
50
|
+
<p><span class="label">Time Since Sync:</span> ${status.timeSinceSync ? (status.timeSinceSync / 1000).toFixed(0) + 's' : 'N/A'}</p>
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Update display every 100ms
|
|
55
|
+
setInterval(updateDisplay, 100);
|
|
56
|
+
|
|
57
|
+
// Log status to console every 5 seconds
|
|
58
|
+
setInterval(() => {
|
|
59
|
+
console.log('NTP Clock Status:', ntpClock.getStatus());
|
|
60
|
+
}, 5000);
|
|
61
|
+
</script>
|
|
62
|
+
</body>
|
|
63
|
+
</html>
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NTP Clock - Synchronized time source for distributed tracing
|
|
3
|
+
*
|
|
4
|
+
* Provides NTP-synchronized timestamps for accurate distributed tracing.
|
|
5
|
+
* Uses HTTP-based time API since browsers cannot make UDP NTP requests.
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT: All entities (client, server, device) MUST sync to time.cloudflare.com
|
|
8
|
+
* If sync fails, timestamps will be null to indicate sync failure.
|
|
9
|
+
*/
|
|
10
|
+
class NTPClock {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.ntpServer = 'time.cloudflare.com'; // Hardcoded - no fallback
|
|
13
|
+
this.offset = null; // Offset from local clock to NTP time (milliseconds), null if not synced
|
|
14
|
+
this.lastSync = null;
|
|
15
|
+
this.syncInterval = 5 * 60 * 1000; // Re-sync every 5 minutes
|
|
16
|
+
this._syncInProgress = false;
|
|
17
|
+
this._syncAttempts = 0;
|
|
18
|
+
this._maxSyncAttempts = 3;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse Cloudflare trace response to extract timestamp
|
|
23
|
+
*/
|
|
24
|
+
_parseCloudflareTime(text) {
|
|
25
|
+
const tsMatch = text.match(/ts=([\d.]+)/);
|
|
26
|
+
if (tsMatch) {
|
|
27
|
+
return parseFloat(tsMatch[1]) * 1000; // Convert to milliseconds
|
|
28
|
+
}
|
|
29
|
+
throw new Error('Failed to parse Cloudflare timestamp');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Synchronize with Cloudflare NTP via HTTP
|
|
34
|
+
*/
|
|
35
|
+
async sync() {
|
|
36
|
+
if (this._syncInProgress) {
|
|
37
|
+
console.log('NTP sync already in progress, skipping');
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this._syncInProgress = true;
|
|
42
|
+
this._syncAttempts++;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
// Capture local time BEFORE the fetch to avoid timing drift
|
|
46
|
+
const localTimeBeforeFetch = Date.now();
|
|
47
|
+
const t0 = performance.now();
|
|
48
|
+
const response = await fetch('https://cloudflare.com/cdn-cgi/trace');
|
|
49
|
+
const t1 = performance.now();
|
|
50
|
+
|
|
51
|
+
const text = await response.text();
|
|
52
|
+
const serverTime = this._parseCloudflareTime(text);
|
|
53
|
+
|
|
54
|
+
const latency = (t1 - t0) / 2; // Estimate one-way latency
|
|
55
|
+
|
|
56
|
+
// Calculate offset: server generated timestamp at local time (localTimeBeforeFetch + latency)
|
|
57
|
+
// So offset = serverTime - (localTimeBeforeFetch + latency)
|
|
58
|
+
this.offset = serverTime - (localTimeBeforeFetch + latency);
|
|
59
|
+
this.lastSync = Date.now();
|
|
60
|
+
|
|
61
|
+
console.log(
|
|
62
|
+
`✅ NTP sync successful: offset=${this.offset.toFixed(2)}ms, ` +
|
|
63
|
+
`latency=${latency.toFixed(2)}ms, server=${this.ntpServer}`
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
this._syncAttempts = 0; // Reset on success
|
|
67
|
+
return true;
|
|
68
|
+
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.warn(`❌ NTP sync failed (attempt ${this._syncAttempts}/${this._maxSyncAttempts}):`, error);
|
|
71
|
+
|
|
72
|
+
// If all attempts fail, set offset to null to indicate sync failure
|
|
73
|
+
if (this._syncAttempts >= this._maxSyncAttempts) {
|
|
74
|
+
this.offset = null;
|
|
75
|
+
this.lastSync = null;
|
|
76
|
+
console.error(`⚠️ NTP sync failed after ${this._maxSyncAttempts} attempts. Timestamps will be null.`);
|
|
77
|
+
this._syncAttempts = 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return false;
|
|
81
|
+
} finally {
|
|
82
|
+
this._syncInProgress = false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get current NTP-synchronized timestamp in milliseconds since epoch
|
|
88
|
+
* Returns null if not synced
|
|
89
|
+
*/
|
|
90
|
+
now() {
|
|
91
|
+
if (this.offset === null) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return Date.now() + this.offset;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get current NTP-synchronized timestamp in ISO format
|
|
99
|
+
* Returns null if not synced
|
|
100
|
+
*/
|
|
101
|
+
nowISO() {
|
|
102
|
+
const ts = this.now();
|
|
103
|
+
if (ts === null) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return new Date(ts).toISOString();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get sync status for debugging
|
|
111
|
+
*/
|
|
112
|
+
getStatus() {
|
|
113
|
+
return {
|
|
114
|
+
server: this.ntpServer,
|
|
115
|
+
offset: this.offset,
|
|
116
|
+
lastSync: this.lastSync ? new Date(this.lastSync).toISOString() : null,
|
|
117
|
+
timeSinceSync: this.lastSync ? Date.now() - this.lastSync : null,
|
|
118
|
+
isSynced: this.offset !== null
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Start automatic periodic synchronization
|
|
124
|
+
*/
|
|
125
|
+
startAutoSync() {
|
|
126
|
+
// Initial sync
|
|
127
|
+
this.sync();
|
|
128
|
+
|
|
129
|
+
// Periodic re-sync
|
|
130
|
+
setInterval(() => {
|
|
131
|
+
console.log('🔄 Starting periodic NTP sync...');
|
|
132
|
+
this.sync();
|
|
133
|
+
}, this.syncInterval);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Global instance - auto-starts sync
|
|
138
|
+
const ntpClock = new NTPClock();
|
|
139
|
+
ntpClock.startAutoSync();
|
|
140
|
+
|
|
141
|
+
export default ntpClock;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# NTP Clock Architecture
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
All entities (client, server, device) synchronize to **time.cloudflare.com** for distributed tracing.
|
|
6
|
+
|
|
7
|
+
## Architecture: Single Package for Everything
|
|
8
|
+
|
|
9
|
+
All NTP clock implementations (Python and JavaScript) are in the **portacode package** to ensure DRY principles.
|
|
10
|
+
|
|
11
|
+
## Python Implementation
|
|
12
|
+
|
|
13
|
+
**Location:** `portacode/utils/ntp_clock.py` (in portacode package)
|
|
14
|
+
|
|
15
|
+
### Import Path
|
|
16
|
+
```python
|
|
17
|
+
from portacode.utils.ntp_clock import ntp_clock
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Usage Locations
|
|
21
|
+
1. **Django Server Consumers** (`server/portacode_django/dashboard/consumers.py`)
|
|
22
|
+
2. **Device Base Handlers** (`portacode/connection/handlers/base.py`)
|
|
23
|
+
3. **Device Client** (`server/portacode_django/data/services/device_client.py`)
|
|
24
|
+
4. **Any Python code with portacode installed**
|
|
25
|
+
|
|
26
|
+
### Dependencies
|
|
27
|
+
- `setup.py`: Added `ntplib>=0.4.0` to `install_requires`
|
|
28
|
+
- `server/portacode_django/requirements.txt`: Added `portacode>=1.3.26`
|
|
29
|
+
|
|
30
|
+
### API
|
|
31
|
+
```python
|
|
32
|
+
# Get NTP-synchronized timestamp (None if not synced)
|
|
33
|
+
ntp_clock.now_ms() # milliseconds
|
|
34
|
+
ntp_clock.now() # seconds
|
|
35
|
+
ntp_clock.now_iso() # ISO format
|
|
36
|
+
|
|
37
|
+
# Check sync status
|
|
38
|
+
status = ntp_clock.get_status()
|
|
39
|
+
# {
|
|
40
|
+
# 'server': 'time.cloudflare.com',
|
|
41
|
+
# 'offset_ms': 6.04,
|
|
42
|
+
# 'last_sync': '2025-10-05T04:37:12.768445+00:00',
|
|
43
|
+
# 'is_synced': True
|
|
44
|
+
# }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## JavaScript Implementation
|
|
48
|
+
|
|
49
|
+
**Location:** `portacode/static/js/utils/ntp-clock.js` (in portacode package)
|
|
50
|
+
|
|
51
|
+
### Django Setup
|
|
52
|
+
|
|
53
|
+
Django will serve static files from the portacode package automatically after `collectstatic`:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
# Django settings.py - no changes needed, just ensure:
|
|
57
|
+
INSTALLED_APPS = [
|
|
58
|
+
# ... other apps
|
|
59
|
+
'portacode', # Add portacode as an installed app (optional, for admin integration)
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
# Static files will be collected from portacode package
|
|
63
|
+
STATIC_URL = '/static/'
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
After installing portacode (`pip install portacode` or `pip install -e .`), run:
|
|
67
|
+
```bash
|
|
68
|
+
python manage.py collectstatic
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
This will copy `portacode/static/js/utils/ntp-clock.js` to Django's static files directory.
|
|
72
|
+
|
|
73
|
+
### Import Path (in Django templates/JS)
|
|
74
|
+
```javascript
|
|
75
|
+
import ntpClock from '/static/js/utils/ntp-clock.js';
|
|
76
|
+
// or relative to your JS file:
|
|
77
|
+
import ntpClock from './utils/ntp-clock.js';
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Usage Locations
|
|
81
|
+
1. **Dashboard WebSocket** (`websocket-service.js`)
|
|
82
|
+
2. **Project WebSocket** (`websocket-service-project.js`)
|
|
83
|
+
|
|
84
|
+
### API
|
|
85
|
+
```javascript
|
|
86
|
+
// Get NTP-synchronized timestamp (null if not synced)
|
|
87
|
+
ntpClock.now() // milliseconds
|
|
88
|
+
ntpClock.nowISO() // ISO format
|
|
89
|
+
|
|
90
|
+
// Check sync status
|
|
91
|
+
const status = ntpClock.getStatus();
|
|
92
|
+
// {
|
|
93
|
+
// server: 'time.cloudflare.com',
|
|
94
|
+
// offset: 6.04,
|
|
95
|
+
// lastSync: '2025-10-05T04:37:12.768445+00:00',
|
|
96
|
+
// isSynced: true
|
|
97
|
+
// }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Design Principles
|
|
101
|
+
|
|
102
|
+
1. **DRY (Don't Repeat Yourself)**
|
|
103
|
+
- **Python:** Single implementation in portacode package (`portacode/utils/ntp_clock.py`)
|
|
104
|
+
- **JavaScript:** Single implementation in portacode package (`portacode/static/js/utils/ntp-clock.js`)
|
|
105
|
+
- Both served from the same package, no duplication across repos
|
|
106
|
+
|
|
107
|
+
2. **No Fallback Servers**
|
|
108
|
+
- All entities MUST sync to time.cloudflare.com
|
|
109
|
+
- If sync fails, timestamps are None/null
|
|
110
|
+
- Ensures all timestamps are comparable
|
|
111
|
+
|
|
112
|
+
3. **Auto-Sync**
|
|
113
|
+
- Re-syncs every 5 minutes automatically
|
|
114
|
+
- Initial sync on import/load
|
|
115
|
+
- Max 3 retry attempts before marking as failed
|
|
116
|
+
|
|
117
|
+
4. **Thread-Safe (Python)**
|
|
118
|
+
- Uses threading.Lock for concurrent access
|
|
119
|
+
- Background daemon thread for periodic sync
|
|
120
|
+
|
|
121
|
+
## Testing
|
|
122
|
+
|
|
123
|
+
### Python
|
|
124
|
+
```bash
|
|
125
|
+
python tools/test_python_ntp_clock.py
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### JavaScript
|
|
129
|
+
The test file is included in the package at `portacode/static/js/test-ntp-clock.html`.
|
|
130
|
+
|
|
131
|
+
After Django collectstatic, open: `/static/js/test-ntp-clock.html` in browser
|
|
132
|
+
|
|
133
|
+
Or run directly from package:
|
|
134
|
+
```bash
|
|
135
|
+
python -c "import portacode, os; print(os.path.join(os.path.dirname(portacode.__file__), 'static/js/test-ntp-clock.html'))"
|
|
136
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Portacode utility modules."""
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NTP Clock - Synchronized time source for distributed tracing
|
|
3
|
+
|
|
4
|
+
Provides NTP-synchronized timestamps for accurate distributed tracing.
|
|
5
|
+
Thread-safe implementation with automatic periodic synchronization.
|
|
6
|
+
|
|
7
|
+
IMPORTANT: All entities (client, server, device) MUST sync to time.cloudflare.com
|
|
8
|
+
If sync fails, timestamps will be None to indicate sync failure.
|
|
9
|
+
"""
|
|
10
|
+
import ntplib
|
|
11
|
+
import time
|
|
12
|
+
import threading
|
|
13
|
+
import logging
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NTPClock:
|
|
21
|
+
"""Thread-safe NTP-synchronized clock."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, ntp_server: str = 'time.cloudflare.com'):
|
|
24
|
+
"""Initialize NTP clock.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
ntp_server: NTP server hostname (default: time.cloudflare.com, hardcoded, no fallback)
|
|
28
|
+
"""
|
|
29
|
+
self.ntp_server = ntp_server
|
|
30
|
+
self.offset: Optional[float] = None # Offset from local clock to NTP time (seconds), None if not synced
|
|
31
|
+
self.last_sync: Optional[float] = None
|
|
32
|
+
self.sync_interval = 300 # Re-sync every 5 minutes
|
|
33
|
+
self._lock = threading.Lock()
|
|
34
|
+
self._sync_in_progress = False
|
|
35
|
+
self._client = ntplib.NTPClient()
|
|
36
|
+
self._sync_attempts = 0
|
|
37
|
+
self._max_sync_attempts = 3
|
|
38
|
+
|
|
39
|
+
def sync(self) -> bool:
|
|
40
|
+
"""Synchronize with NTP server.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
True if sync successful, False otherwise
|
|
44
|
+
"""
|
|
45
|
+
if self._sync_in_progress:
|
|
46
|
+
logger.debug("NTP sync already in progress, skipping")
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
self._sync_in_progress = True
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
self._sync_attempts += 1
|
|
53
|
+
response = self._client.request(self.ntp_server, version=3, timeout=2)
|
|
54
|
+
|
|
55
|
+
with self._lock:
|
|
56
|
+
# Offset is difference between NTP time and local time
|
|
57
|
+
self.offset = response.offset
|
|
58
|
+
self.last_sync = time.time()
|
|
59
|
+
|
|
60
|
+
logger.info(
|
|
61
|
+
f"✅ NTP sync successful: offset={self.offset*1000:.2f}ms, "
|
|
62
|
+
f"latency={response.delay*1000:.2f}ms, server={self.ntp_server}"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
self._sync_attempts = 0 # Reset on success
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
except Exception as e:
|
|
69
|
+
logger.warning(f"❌ NTP sync failed (attempt {self._sync_attempts}/{self._max_sync_attempts}): {e}")
|
|
70
|
+
|
|
71
|
+
# If all attempts fail, set offset to None to indicate sync failure
|
|
72
|
+
if self._sync_attempts >= self._max_sync_attempts:
|
|
73
|
+
with self._lock:
|
|
74
|
+
self.offset = None
|
|
75
|
+
self.last_sync = None
|
|
76
|
+
logger.error(f"⚠️ NTP sync failed after {self._max_sync_attempts} attempts. Timestamps will be None.")
|
|
77
|
+
self._sync_attempts = 0
|
|
78
|
+
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
finally:
|
|
82
|
+
self._sync_in_progress = False
|
|
83
|
+
|
|
84
|
+
def now(self) -> Optional[float]:
|
|
85
|
+
"""Get current NTP-synchronized timestamp (seconds since epoch).
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Timestamp in seconds (Unix epoch) or None if not synced
|
|
89
|
+
"""
|
|
90
|
+
with self._lock:
|
|
91
|
+
if self.offset is None:
|
|
92
|
+
return None
|
|
93
|
+
return time.time() + self.offset
|
|
94
|
+
|
|
95
|
+
def now_ms(self) -> Optional[int]:
|
|
96
|
+
"""Get current NTP-synchronized timestamp in milliseconds.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Timestamp in milliseconds (Unix epoch) or None if not synced
|
|
100
|
+
"""
|
|
101
|
+
ts = self.now()
|
|
102
|
+
if ts is None:
|
|
103
|
+
return None
|
|
104
|
+
return int(ts * 1000)
|
|
105
|
+
|
|
106
|
+
def now_iso(self) -> Optional[str]:
|
|
107
|
+
"""Get current NTP-synchronized timestamp in ISO format.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
ISO 8601 formatted timestamp with UTC timezone or None if not synced
|
|
111
|
+
"""
|
|
112
|
+
ts = self.now()
|
|
113
|
+
if ts is None:
|
|
114
|
+
return None
|
|
115
|
+
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
116
|
+
return dt.isoformat()
|
|
117
|
+
|
|
118
|
+
def get_status(self) -> dict:
|
|
119
|
+
"""Get sync status for debugging.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Dictionary with sync status information
|
|
123
|
+
"""
|
|
124
|
+
with self._lock:
|
|
125
|
+
return {
|
|
126
|
+
'server': self.ntp_server,
|
|
127
|
+
'offset_ms': self.offset * 1000 if self.offset is not None else None,
|
|
128
|
+
'last_sync': datetime.fromtimestamp(self.last_sync, tz=timezone.utc).isoformat() if self.last_sync else None,
|
|
129
|
+
'time_since_sync_sec': time.time() - self.last_sync if self.last_sync else None,
|
|
130
|
+
'is_synced': self.offset is not None
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
def start_auto_sync(self):
|
|
134
|
+
"""Start automatic periodic synchronization in background thread."""
|
|
135
|
+
# Initial sync
|
|
136
|
+
self.sync()
|
|
137
|
+
|
|
138
|
+
def _sync_loop():
|
|
139
|
+
while True:
|
|
140
|
+
time.sleep(self.sync_interval)
|
|
141
|
+
logger.info("🔄 Starting periodic NTP sync...")
|
|
142
|
+
self.sync()
|
|
143
|
+
|
|
144
|
+
thread = threading.Thread(target=_sync_loop, daemon=True, name='ntp-sync')
|
|
145
|
+
thread.start()
|
|
146
|
+
logger.info(f"Started NTP auto-sync thread (interval: {self.sync_interval}s, server: {self.ntp_server})")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# Global instance - auto-starts sync on import
|
|
150
|
+
ntp_clock = NTPClock()
|
|
151
|
+
ntp_clock.start_auto_sync()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: portacode
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.27
|
|
4
4
|
Summary: Portacode CLI client and SDK
|
|
5
5
|
Home-page: https://github.com/portacode/portacode
|
|
6
6
|
Author: Meena Erian
|
|
@@ -23,6 +23,7 @@ Requires-Dist: GitPython>=3.1.45
|
|
|
23
23
|
Requires-Dist: watchdog>=3.0
|
|
24
24
|
Requires-Dist: diff-match-patch>=20230430
|
|
25
25
|
Requires-Dist: Pygments>=2.14.0
|
|
26
|
+
Requires-Dist: ntplib>=0.4.0
|
|
26
27
|
Provides-Extra: dev
|
|
27
28
|
Requires-Dist: black; extra == "dev"
|
|
28
29
|
Requires-Dist: flake8; extra == "dev"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
portacode/README.md,sha256=4dKtpvR8LNgZPVz37GmkQCMWIr_u25Ao63iW56s7Ke4,775
|
|
2
2
|
portacode/__init__.py,sha256=oB3sV1wXr-um-RXio73UG8E5Xx6cF2ZVJveqjNmC-vQ,1086
|
|
3
3
|
portacode/__main__.py,sha256=jmHTGC1hzmo9iKJLv-SSYe9BSIbPPZ2IOpecI03PlTs,296
|
|
4
|
-
portacode/_version.py,sha256=
|
|
4
|
+
portacode/_version.py,sha256=lfm8QKOCYrPf3hyLkHj9R9SFlWO01BBk81sZeN5NiXw,706
|
|
5
5
|
portacode/cli.py,sha256=eDqcZMVFHKzqqWxedhhx8ylu5WMVCLqeJQkbPR7RcJE,16333
|
|
6
6
|
portacode/data.py,sha256=5-s291bv8J354myaHm1Y7CQZTZyRzMU3TGe5U4hb-FA,1591
|
|
7
7
|
portacode/keypair.py,sha256=PAcOYqlVLOoZTPYi6LvLjfsY6BkrWbLOhSZLb8r5sHs,3635
|
|
@@ -13,14 +13,14 @@ portacode/connection/client.py,sha256=P5ubshMFbyV8PkGZxU2o_70gIYEy3Pv_R5blmF2ylW
|
|
|
13
13
|
portacode/connection/multiplex.py,sha256=L-TxqJ_ZEbfNEfu1cwxgJ5vUdyRzZjsMy2Kx1diiZys,5237
|
|
14
14
|
portacode/connection/terminal.py,sha256=euST6O_3hm9qsBk52xTXORTKfKqsMYnTHuZjvlitYSE,42307
|
|
15
15
|
portacode/connection/handlers/README.md,sha256=HsLZG1QK1JNm67HsgL6WoDg9nxzKXxwkc5fJPFJdX5g,12169
|
|
16
|
-
portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=
|
|
16
|
+
portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=U-d58S-X2r5T6QAu-6NOzCIJg71FIj_vmOdUGCWFIhw,68211
|
|
17
17
|
portacode/connection/handlers/__init__.py,sha256=4nv3Z4TGYjWcauKPWsbL_FbrTXApI94V7j6oiU1Vv-o,2144
|
|
18
|
-
portacode/connection/handlers/base.py,sha256=
|
|
18
|
+
portacode/connection/handlers/base.py,sha256=oENFb-Fcfzwk99Qx8gJQriEMiwSxwygwjOiuCH36hM4,10231
|
|
19
19
|
portacode/connection/handlers/chunked_content.py,sha256=h6hXRmxSeOgnIxoU8CkmvEf2Odv-ajPrpHIe_W3GKcA,9251
|
|
20
|
-
portacode/connection/handlers/file_handlers.py,sha256=
|
|
20
|
+
portacode/connection/handlers/file_handlers.py,sha256=1Thp8cqbtJAY96QfO1huZwbWhQcaL9zh28wtNNjsmJk,15218
|
|
21
21
|
portacode/connection/handlers/project_aware_file_handlers.py,sha256=n0M2WmBNWPwzigdIkyZiAsePUQGXVqYSsDyOxm-Nsok,9253
|
|
22
22
|
portacode/connection/handlers/project_state_handlers.py,sha256=v6ZefGW9i7n1aZLq2jOGumJIjYb6aHlPI4m1jkYewm8,1686
|
|
23
|
-
portacode/connection/handlers/registry.py,sha256=
|
|
23
|
+
portacode/connection/handlers/registry.py,sha256=pFO-pYAeRXu_Jzb8YdLqHgeyUP-PaGZq0_C37unV3F4,6174
|
|
24
24
|
portacode/connection/handlers/session.py,sha256=O7TMI5cRziOiXEBWCfBshkMpEthhjvKqGL0hhNOG1wU,26716
|
|
25
25
|
portacode/connection/handlers/system_handlers.py,sha256=65V5ctT0dIBc-oWG91e62MbdvU0z6x6JCTQuIqCWmZ0,5242
|
|
26
26
|
portacode/connection/handlers/tab_factory.py,sha256=VBZnwtxgeNJCsfBzUjkFWAAGBdijvai4MS2dXnhFY8U,18000
|
|
@@ -29,11 +29,16 @@ portacode/connection/handlers/project_state/README.md,sha256=trdd4ig6ungmwH5SpbS
|
|
|
29
29
|
portacode/connection/handlers/project_state/__init__.py,sha256=5ucIqk6Iclqg6bKkL8r_wVs5Tlt6B9J7yQH6yQUt7gc,2541
|
|
30
30
|
portacode/connection/handlers/project_state/file_system_watcher.py,sha256=w-93ioUZZKZxzPFr8djJnGhWjMVFVdDsmo0fVAukoKk,10150
|
|
31
31
|
portacode/connection/handlers/project_state/git_manager.py,sha256=fA0RbWCblpJep13L4MdqnEP4sE1qWc7Y66vrIo_SWps,76575
|
|
32
|
-
portacode/connection/handlers/project_state/handlers.py,sha256=
|
|
32
|
+
portacode/connection/handlers/project_state/handlers.py,sha256=03RYNeWfX_Ym9Lx4VdA6iwLSWFdjRtjWI5T1buBg4Mc,37941
|
|
33
33
|
portacode/connection/handlers/project_state/manager.py,sha256=03mN0H9TqVa_ohD5U5-5ZywDGj0s8-y1IxGdb07dZn8,57636
|
|
34
34
|
portacode/connection/handlers/project_state/models.py,sha256=EZTKvxHKs8QlQUbzI0u2IqfzfRRXZixUIDBwTGCJATI,4313
|
|
35
35
|
portacode/connection/handlers/project_state/utils.py,sha256=LsbQr9TH9Bz30FqikmtTxco4PlB_n0kUIuPKQ6Fb_mo,1665
|
|
36
|
-
portacode-
|
|
36
|
+
portacode/static/js/test-ntp-clock.html,sha256=bUow9sifIuLNPqKvuPbpQozmEE6RhdCI4Plib3CqUmw,2130
|
|
37
|
+
portacode/static/js/utils/ntp-clock.js,sha256=KMeHGT-IlUSlxVRZZ899z25dQCJh6EJbgXjlh8dD8vA,4495
|
|
38
|
+
portacode/utils/NTP_ARCHITECTURE.md,sha256=WkESTbz5SNAgdmDKk3DrHMhtYOPji_Kt3_a9arWdRig,3894
|
|
39
|
+
portacode/utils/__init__.py,sha256=NgBlWTuNJESfIYJzP_3adI1yJQJR0XJLRpSdVNaBAN0,33
|
|
40
|
+
portacode/utils/ntp_clock.py,sha256=6QJOVZr9VQuxIyJt9KNG4dR-nZ3bKNyipMxjqDWP89Y,5152
|
|
41
|
+
portacode-1.3.27.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
|
|
37
42
|
test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
|
|
38
43
|
test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
|
|
39
44
|
test_modules/test_device_online.py,sha256=yiSyVaMwKAugqIX_ZIxmLXiOlmA_8IRXiUp12YmpB98,1653
|
|
@@ -58,8 +63,8 @@ testing_framework/core/playwright_manager.py,sha256=9kGXJtRpRNEhaSlV7XVXvx4UQSHS
|
|
|
58
63
|
testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
|
|
59
64
|
testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
|
|
60
65
|
testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
|
|
61
|
-
portacode-1.3.
|
|
62
|
-
portacode-1.3.
|
|
63
|
-
portacode-1.3.
|
|
64
|
-
portacode-1.3.
|
|
65
|
-
portacode-1.3.
|
|
66
|
+
portacode-1.3.27.dist-info/METADATA,sha256=3achcETltaOKmWGJ6gje69ghCufW6F3KtizLCtGjEf0,6989
|
|
67
|
+
portacode-1.3.27.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
68
|
+
portacode-1.3.27.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
|
|
69
|
+
portacode-1.3.27.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
|
|
70
|
+
portacode-1.3.27.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|