portacode 1.3.25__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 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.25'
32
- __version_tuple__ = version_tuple = (1, 3, 25)
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 between the Portacode server and the connected client devices.
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
- All communication over the WebSocket is managed by 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 connection.
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
- **Raw Message Structure:**
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
- * `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 responsed to such control commands or sends system events, they will also be send 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.
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
- "command": "<command_name>",
114
- "payload": {
115
- "arg1": "value1",
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
- * `command` (string, mandatory): The name of the action to be executed (e.g., `terminal_start`).
123
- * `payload` (object, mandatory): An object containing the specific arguments for the action.
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
- if not request_id:
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.25
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"
@@ -43,7 +44,7 @@ Dynamic: summary
43
44
 
44
45
  **An AI-first, mobile-first IDE and admin tool, made with love and passion by software engineers, for software engineers.**
45
46
 
46
- Portacode transforms any computer into a remotely accessible development environment. Access your home lab from your phone, code on your desktop from anywhere, or help a colleague debug their server - all through a beautiful web interface designed for the modern developer.
47
+ Portacode transforms any device with python into a remotely accessible development environment. Access your home lab, server or even embedded system chip from your phone, code on your desktop or your smartphone from anywhere, or help a colleague debug their server - all through a beautiful web interface designed for the modern developer.
47
48
 
48
49
  ## ✨ Why Portacode?
49
50
 
@@ -86,40 +87,9 @@ Once connected, you can:
86
87
 
87
88
  ## 💡 Use Cases
88
89
 
89
- ### Remote Development
90
- ```bash
91
- # Connect your development machine
92
- portacode connect
93
-
94
- # Now code, build, and debug from anywhere - even your phone
95
- ```
96
-
97
- ### Server Administration
98
- ```bash
99
- # For a persistent connection, install system-wide first
100
- sudo pip install portacode --system
101
-
102
- # Then install as a service
103
- sudo portacode service install
104
-
105
- # Your server is now accessible 24/7 from the web dashboard
106
- ```
107
-
108
- ### Mobile Development
109
- ```bash
110
- # Connect your desktop/laptop
111
- portacode connect
112
-
113
- # Code on-the-go from your mobile device with a full IDE experience
114
- ```
115
-
116
- ### Team Collaboration
117
- ```bash
118
- # Connect shared development environments
119
- portacode connect
120
-
121
- # Enable team members to access shared resources securely
122
- ```
90
+ - **Remote Development**: Code, build, and debug from anywhere - even your phone
91
+ - **Server Administration**: 24/7 server access with persistent service installation
92
+ - **Mobile Development**: Full IDE experience on mobile devices
123
93
 
124
94
  ## 🔧 Essential Commands
125
95
 
@@ -140,19 +110,21 @@ portacode --help
140
110
 
141
111
  ### Service Management
142
112
  ```bash
143
- # For system services, install package system-wide first
113
+ # First, authenticate your device
114
+ portacode connect
115
+
116
+ # For system services, install package system-wide
144
117
  sudo pip install portacode --system
145
118
 
146
119
  # Install persistent service (auto-start on boot)
147
120
  sudo portacode service install
148
121
 
149
- # Check service status
122
+ # Check service status (use -v for verbose debugging)
150
123
  sudo portacode service status
124
+ sudo portacode service status -v
151
125
 
152
- # Stop the service
126
+ # Stop/remove the service
153
127
  sudo portacode service stop
154
-
155
- # Remove the service
156
128
  sudo portacode service uninstall
157
129
  ```
158
130
 
@@ -194,11 +166,17 @@ sudo portacode service status --verbose
194
166
 
195
167
  ### Service Installation Issues
196
168
  ```bash
169
+ # First authenticate your device
170
+ portacode connect
171
+
197
172
  # If service commands fail, ensure system-wide installation
198
173
  sudo pip install portacode --system
199
174
 
200
175
  # Then try service installation again
201
176
  sudo portacode service install
177
+
178
+ # Use verbose status to debug connection issues
179
+ sudo portacode service status -v
202
180
  ```
203
181
 
204
182
  ### Clipboard Issues (Linux)
@@ -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=MS6U6SQUT3lPD_MWBRk6h6SwVr6EqudB5Gwbav4F8Us,706
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=0KBoJzqemXbvpu8Ps7LitLwRYhJNtzcTJ5WAHMGgAuc,62509
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=C-H61CUHM2k431CG0usd7eEqklDj9pnuXHujBwhTugk,6666
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=CGMooOrfGbKx-bHA8vr8lmPG-vHw1DJlVWf6TLpfMJU,15355
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=ebi0vhR1XXSYU7mJXlQJ4MjBYaMygGYqX7ReK7vsZ7o,5558
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=nkednSbCC-0n3ZtzesaWd9_NFfxNjS4lyVNnbsYs0Zk,37823
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-1.3.25.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
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.25.dist-info/METADATA,sha256=R11jncxQlC2FC6Q71MhT59AaAxSc-krUjpJX49ZWGeE,7145
62
- portacode-1.3.25.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
63
- portacode-1.3.25.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
64
- portacode-1.3.25.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
65
- portacode-1.3.25.dist-info/RECORD,,
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,,