portacode 1.3.32__py3-none-any.whl → 1.4.11.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of portacode might be problematic. Click here for more details.

Files changed (56) hide show
  1. portacode/_version.py +2 -2
  2. portacode/cli.py +119 -14
  3. portacode/connection/client.py +127 -8
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +301 -4
  5. portacode/connection/handlers/__init__.py +10 -1
  6. portacode/connection/handlers/diff_handlers.py +603 -0
  7. portacode/connection/handlers/file_handlers.py +674 -17
  8. portacode/connection/handlers/project_aware_file_handlers.py +11 -0
  9. portacode/connection/handlers/project_state/file_system_watcher.py +31 -61
  10. portacode/connection/handlers/project_state/git_manager.py +139 -572
  11. portacode/connection/handlers/project_state/handlers.py +28 -14
  12. portacode/connection/handlers/project_state/manager.py +226 -101
  13. portacode/connection/handlers/proxmox_infra.py +307 -0
  14. portacode/connection/handlers/session.py +465 -84
  15. portacode/connection/handlers/system_handlers.py +140 -8
  16. portacode/connection/handlers/tab_factory.py +1 -47
  17. portacode/connection/handlers/update_handler.py +61 -0
  18. portacode/connection/terminal.py +51 -10
  19. portacode/keypair.py +63 -1
  20. portacode/link_capture/__init__.py +38 -0
  21. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  22. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  23. portacode/link_capture/bin/elinks +3 -0
  24. portacode/link_capture/bin/gio-open +3 -0
  25. portacode/link_capture/bin/gnome-open +3 -0
  26. portacode/link_capture/bin/gvfs-open +3 -0
  27. portacode/link_capture/bin/kde-open +3 -0
  28. portacode/link_capture/bin/kfmclient +3 -0
  29. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  30. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  31. portacode/link_capture/bin/links +3 -0
  32. portacode/link_capture/bin/links2 +3 -0
  33. portacode/link_capture/bin/lynx +3 -0
  34. portacode/link_capture/bin/mate-open +3 -0
  35. portacode/link_capture/bin/netsurf +3 -0
  36. portacode/link_capture/bin/sensible-browser +3 -0
  37. portacode/link_capture/bin/w3m +3 -0
  38. portacode/link_capture/bin/x-www-browser +3 -0
  39. portacode/link_capture/bin/xdg-open +3 -0
  40. portacode/pairing.py +103 -0
  41. portacode/static/js/utils/ntp-clock.js +170 -79
  42. portacode/utils/diff_apply.py +456 -0
  43. portacode/utils/diff_renderer.py +371 -0
  44. portacode/utils/ntp_clock.py +45 -131
  45. {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/METADATA +71 -3
  46. portacode-1.4.11.dev0.dist-info/RECORD +97 -0
  47. test_modules/test_device_online.py +1 -1
  48. test_modules/test_login_flow.py +8 -4
  49. test_modules/test_play_store_screenshots.py +294 -0
  50. testing_framework/.env.example +4 -1
  51. testing_framework/core/playwright_manager.py +63 -9
  52. portacode-1.3.32.dist-info/RECORD +0 -70
  53. {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +0 -0
  54. {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
  55. {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/licenses/LICENSE +0 -0
  56. {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,371 @@
1
+ """Stateless utilities for rendering unified diffs as HTML."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import logging
7
+ import os
8
+ import time
9
+ from typing import Dict, List, Optional
10
+
11
+ try:
12
+ from pygments import highlight
13
+ from pygments.formatters import HtmlFormatter
14
+ from pygments.lexers import get_lexer_for_filename
15
+ from pygments.util import ClassNotFound
16
+
17
+ PYGMENTS_AVAILABLE = True
18
+ except ImportError:
19
+ PYGMENTS_AVAILABLE = False
20
+ highlight = None # type: ignore
21
+ HtmlFormatter = None # type: ignore
22
+ get_lexer_for_filename = None # type: ignore
23
+ ClassNotFound = Exception # type: ignore
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def generate_html_diff(
29
+ original_content: str,
30
+ modified_content: str,
31
+ file_path: str,
32
+ ) -> Optional[Dict[str, str]]:
33
+ """Generate unified HTML diff (minimal + full context) for two strings."""
34
+ # If pygments isn't available we fall back to the simplified renderer
35
+ if not PYGMENTS_AVAILABLE:
36
+ return _generate_simple_diff_html(original_content, modified_content, file_path)
37
+
38
+ # Basic safety limits to keep rendering responsive
39
+ max_content_size = 500000 # 500KB
40
+ max_lines = 5000
41
+ original_line_count = original_content.count("\n")
42
+ modified_line_count = modified_content.count("\n")
43
+ if (
44
+ len(original_content) > max_content_size
45
+ or len(modified_content) > max_content_size
46
+ or max(original_line_count, modified_line_count) > max_lines
47
+ ):
48
+ logger.warning("Large file detected for diff generation: %s", file_path)
49
+ return _generate_simple_diff_html(original_content, modified_content, file_path)
50
+
51
+ try:
52
+ original_lines = original_content.splitlines(keepends=True)
53
+ modified_lines = modified_content.splitlines(keepends=True)
54
+
55
+ start_time = time.time()
56
+ timeout_seconds = 5
57
+
58
+ minimal_diff_lines = list(
59
+ difflib.unified_diff(
60
+ original_lines,
61
+ modified_lines,
62
+ fromfile=f"a/{os.path.basename(file_path)}",
63
+ tofile=f"b/{os.path.basename(file_path)}",
64
+ lineterm="",
65
+ n=3,
66
+ )
67
+ )
68
+ if time.time() - start_time > timeout_seconds:
69
+ logger.warning("Diff generation timeout for %s", file_path)
70
+ return None
71
+
72
+ if len(original_lines) + len(modified_lines) < 2000:
73
+ context_span = len(original_lines) + len(modified_lines)
74
+ full_diff_lines = list(
75
+ difflib.unified_diff(
76
+ original_lines,
77
+ modified_lines,
78
+ fromfile=f"a/{os.path.basename(file_path)}",
79
+ tofile=f"b/{os.path.basename(file_path)}",
80
+ lineterm="",
81
+ n=context_span,
82
+ )
83
+ )
84
+ else:
85
+ full_diff_lines = minimal_diff_lines
86
+
87
+ parsed_minimal = parse_unified_diff_simple(minimal_diff_lines)
88
+ parsed_full = parse_unified_diff_simple(full_diff_lines)
89
+
90
+ if time.time() - start_time > timeout_seconds:
91
+ logger.warning("Diff generation timeout for %s", file_path)
92
+ return None
93
+
94
+ minimal_html = render_diff_html(parsed_minimal, file_path, "minimal")
95
+ full_html = render_diff_html(parsed_full, file_path, "full")
96
+ return {"minimal": minimal_html, "full": full_html}
97
+ except Exception as exc: # pragma: no cover - defensive
98
+ logger.error("Error generating HTML diff for %s: %s", file_path, exc)
99
+ return None
100
+
101
+
102
+ def parse_unified_diff_simple(diff_lines: List[str]) -> List[Dict]:
103
+ """Parse unified diff lines into structured rows (no intraline highlighting)."""
104
+ parsed: List[Dict] = []
105
+ old_line_num = 0
106
+ new_line_num = 0
107
+
108
+ for line in diff_lines:
109
+ if line.startswith("@@"):
110
+ import re
111
+
112
+ match = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
113
+ if match:
114
+ old_line_num = int(match.group(1)) - 1
115
+ new_line_num = int(match.group(2)) - 1
116
+ parsed.append(
117
+ {
118
+ "type": "header",
119
+ "content": line,
120
+ "old_line_num": "",
121
+ "new_line_num": "",
122
+ }
123
+ )
124
+ elif line.startswith("---") or line.startswith("+++"):
125
+ parsed.append(
126
+ {
127
+ "type": "header",
128
+ "content": line,
129
+ "old_line_num": "",
130
+ "new_line_num": "",
131
+ }
132
+ )
133
+ elif line.startswith("-"):
134
+ old_line_num += 1
135
+ parsed.append(
136
+ {
137
+ "type": "delete",
138
+ "old_line_num": old_line_num,
139
+ "new_line_num": "",
140
+ "content": line,
141
+ }
142
+ )
143
+ elif line.startswith("+"):
144
+ new_line_num += 1
145
+ parsed.append(
146
+ {
147
+ "type": "add",
148
+ "old_line_num": "",
149
+ "new_line_num": new_line_num,
150
+ "content": line,
151
+ }
152
+ )
153
+ elif line.startswith(" "):
154
+ old_line_num += 1
155
+ new_line_num += 1
156
+ parsed.append(
157
+ {
158
+ "type": "context",
159
+ "old_line_num": old_line_num,
160
+ "new_line_num": new_line_num,
161
+ "content": line,
162
+ }
163
+ )
164
+ return parsed
165
+
166
+
167
+ def render_diff_html(parsed_diff: List[Dict], file_path: str, view_mode: str) -> str:
168
+ """Convert parsed diff entries into styled HTML."""
169
+ if len(parsed_diff) > 1000:
170
+ logger.warning("Diff too large, truncating: %s (%s lines)", file_path, len(parsed_diff))
171
+ parsed_diff = parsed_diff[:1000]
172
+
173
+ lexer = _get_pygments_lexer(file_path)
174
+ highlighted_cache = {}
175
+ if lexer and PYGMENTS_AVAILABLE:
176
+ unique_lines = {
177
+ line_info["content"][1:].rstrip("\n")
178
+ for line_info in parsed_diff
179
+ if line_info.get("content") and line_info["content"][0] in "+- "
180
+ }
181
+ unique_lines = {line for line in unique_lines if line.strip()}
182
+ if unique_lines:
183
+ try:
184
+ combined = "\n".join(unique_lines)
185
+ highlighted = highlight(
186
+ combined, lexer, HtmlFormatter(nowrap=True, noclasses=False, style="monokai")
187
+ )
188
+ split_highlighted = highlighted.split("\n")
189
+ unique_list = list(unique_lines)
190
+ for idx, content in enumerate(unique_list):
191
+ if idx < len(split_highlighted):
192
+ highlighted_cache[content] = split_highlighted[idx]
193
+ except Exception as exc:
194
+ logger.debug("Error in batch syntax highlighting: %s", exc)
195
+ highlighted_cache = {}
196
+
197
+ html_parts: List[str] = []
198
+ html_parts.append(f'<div class="unified-diff-container" data-view-mode="{view_mode}">')
199
+
200
+ line_additions = sum(1 for line in parsed_diff if line["type"] == "add")
201
+ line_deletions = sum(1 for line in parsed_diff if line["type"] == "delete")
202
+
203
+ html_parts.append(
204
+ f"""
205
+ <div class="diff-stats">
206
+ <div class="diff-stats-left">
207
+ <span class="additions">+{line_additions}</span>
208
+ <span class="deletions">-{line_deletions}</span>
209
+ <span class="file-path">{os.path.basename(file_path)}</span>
210
+ </div>
211
+ <div class="diff-stats-right">
212
+ <button class="diff-toggle-btn" data-current-mode="{view_mode}">
213
+ <i class="fas fa-eye"></i>
214
+ <span class="toggle-text"></span>
215
+ </button>
216
+ </div>
217
+ </div>
218
+ """
219
+ )
220
+
221
+ html_parts.append('<div class="diff-content">')
222
+ html_parts.append('<table class="diff-table">')
223
+
224
+ for line_info in parsed_diff:
225
+ if line_info["type"] == "header":
226
+ continue
227
+
228
+ line_type = line_info["type"]
229
+ old_line_num = line_info.get("old_line_num", "")
230
+ new_line_num = line_info.get("new_line_num", "")
231
+ content = line_info.get("content", "")
232
+
233
+ final_content = _escape_html(content)
234
+ if content and content[0] in "+- ":
235
+ prefix = content[0] if content[0] in "+-" else " "
236
+ clean_content = content[1:].rstrip("\n")
237
+ if clean_content.strip():
238
+ cached = highlighted_cache.get(clean_content)
239
+ if cached:
240
+ final_content = prefix + cached
241
+ elif lexer and PYGMENTS_AVAILABLE:
242
+ try:
243
+ highlighted_line = highlight(
244
+ clean_content, lexer, HtmlFormatter(nowrap=True, noclasses=False, style="monokai")
245
+ )
246
+ final_content = prefix + highlighted_line
247
+ except Exception as exc:
248
+ logger.debug("Error applying syntax highlighting: %s", exc)
249
+ final_content = _escape_html(content)
250
+
251
+ row_class = f"diff-line diff-{line_type}"
252
+ html_parts.append(
253
+ f"""
254
+ <tr class="{row_class}">
255
+ <td class="line-num old-line-num">{old_line_num}</td>
256
+ <td class="line-num new-line-num">{new_line_num}</td>
257
+ <td class="line-content">{final_content}</td>
258
+ </tr>
259
+ """
260
+ )
261
+
262
+ html_parts.append("</table>")
263
+ html_parts.append("</div>")
264
+ html_parts.append("</div>")
265
+ return "".join(html_parts)
266
+
267
+
268
+ def render_simple_diff_html(parsed_diff: List[Dict], file_path: str) -> str:
269
+ """Generate simplified diff HTML tables (no syntax highlighting)."""
270
+ html_parts = []
271
+ html_parts.append('<div class="unified-diff-container" data-view-mode="minimal">')
272
+
273
+ line_additions = sum(1 for line in parsed_diff if line["type"] == "add")
274
+ line_deletions = sum(1 for line in parsed_diff if line["type"] == "delete")
275
+ html_parts.append(
276
+ f"""
277
+ <div class="diff-stats">
278
+ <div class="diff-stats-left">
279
+ <span class="additions">+{line_additions}</span>
280
+ <span class="deletions">-{line_deletions}</span>
281
+ <span class="file-path">{os.path.basename(file_path)} (Large file - simplified view)</span>
282
+ </div>
283
+ </div>
284
+ """
285
+ )
286
+
287
+ html_parts.append('<div class="diff-content">')
288
+ html_parts.append('<table class="diff-table">')
289
+
290
+ for line_info in parsed_diff:
291
+ if line_info["type"] == "header":
292
+ continue
293
+ row_class = f'diff-line diff-{line_info["type"]}'
294
+ html_parts.append(
295
+ f"""
296
+ <tr class="{row_class}">
297
+ <td class="line-num old-line-num">{line_info.get("old_line_num", "")}</td>
298
+ <td class="line-num new-line-num">{line_info.get("new_line_num", "")}</td>
299
+ <td class="line-content">{_escape_html(line_info.get("content", ""))}</td>
300
+ </tr>
301
+ """
302
+ )
303
+
304
+ html_parts.append("</table>")
305
+ html_parts.append("</div>")
306
+ html_parts.append("</div>")
307
+ return "".join(html_parts)
308
+
309
+
310
+ def generate_fallback_diff_html(file_path: str) -> str:
311
+ """Fallback view when diff can't be rendered."""
312
+ return f"""
313
+ <div class="unified-diff-container" data-view-mode="minimal">
314
+ <div class="diff-stats">
315
+ <div class="diff-stats-left">
316
+ <span class="file-path">{os.path.basename(file_path)} (Diff unavailable)</span>
317
+ </div>
318
+ </div>
319
+ <div class="diff-content">
320
+ <div style="padding: 2rem; text-align: center; color: var(--text-secondary);">
321
+ <i class="fas fa-exclamation-triangle" style="font-size: 2rem; margin-bottom: 1rem;"></i>
322
+ <p>Diff view unavailable for this file</p>
323
+ <p style="font-size: 0.9rem;">File may be too large or binary</p>
324
+ </div>
325
+ </div>
326
+ </div>
327
+ """
328
+
329
+
330
+ def _generate_simple_diff_html(original_content: str, modified_content: str, file_path: str) -> Dict[str, str]:
331
+ """Render simplified HTML diff for large files or when syntax highlighting is unavailable."""
332
+ diff_lines = list(
333
+ difflib.unified_diff(
334
+ original_content.splitlines(keepends=True),
335
+ modified_content.splitlines(keepends=True),
336
+ fromfile=f"a/{os.path.basename(file_path)}",
337
+ tofile=f"b/{os.path.basename(file_path)}",
338
+ lineterm="",
339
+ n=3,
340
+ )
341
+ )
342
+ parsed = parse_unified_diff_simple(diff_lines)
343
+ if len(parsed) > 500:
344
+ parsed = parsed[:500]
345
+ logger.info("Truncated large diff to 500 lines for %s", file_path)
346
+ html = render_simple_diff_html(parsed, file_path)
347
+ return {"minimal": html, "full": html}
348
+
349
+
350
+ def _get_pygments_lexer(file_path: str):
351
+ if not PYGMENTS_AVAILABLE or not get_lexer_for_filename: # type: ignore
352
+ return None
353
+ try:
354
+ return get_lexer_for_filename(file_path) # type: ignore
355
+ except ClassNotFound:
356
+ logger.debug("No Pygments lexer found for file: %s", file_path)
357
+ return None
358
+ except Exception as exc:
359
+ logger.debug("Error getting Pygments lexer for %s: %s", file_path, exc)
360
+ return None
361
+
362
+
363
+ def _escape_html(text: str) -> str:
364
+ return (
365
+ text.replace("&", "&amp;")
366
+ .replace("<", "&lt;")
367
+ .replace(">", "&gt;")
368
+ .replace('"', "&quot;")
369
+ .replace("'", "&#x27;")
370
+ )
371
+
@@ -1,151 +1,65 @@
1
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.
2
+ Simple clock offset helper that tracks the difference between the local clock and
3
+ the server time. The device obtains the server time over the existing gateway
4
+ WebSocket and updates the offset based on measured round-trip time.
9
5
  """
10
- import ntplib
11
6
  import time
12
- import threading
13
- import logging
14
7
  from datetime import datetime, timezone
15
8
  from typing import Optional
16
9
 
17
- logger = logging.getLogger(__name__)
18
-
19
10
 
20
11
  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()
12
+ """Clock helper that stores a millisecond offset from the local system clock."""
59
13
 
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
- )
14
+ def __init__(self):
15
+ self._offset_ms = 0.0
16
+ self._last_sync = None
17
+ self._last_latency_ms = None
18
+ self._smoothing_weight = 0.2
64
19
 
65
- self._sync_attempts = 0 # Reset on success
66
- return True
20
+ def now(self) -> float:
21
+ """Return the current timestamp (seconds since epoch) adjusted by the offset."""
22
+ return time.time() + (self._offset_ms / 1000.0)
67
23
 
68
- except Exception as e:
69
- logger.warning(f" NTP sync failed (attempt {self._sync_attempts}/{self._max_sync_attempts}): {e}")
24
+ def now_ms(self) -> int:
25
+ """Return the current timestamp in milliseconds adjusted by the offset."""
26
+ return int(time.time() * 1000 + self._offset_ms)
70
27
 
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()
28
+ def now_iso(self) -> str:
29
+ """Return the current ISO timestamp adjusted by the offset."""
30
+ return datetime.fromtimestamp(self.now(), tz=timezone.utc).isoformat()
117
31
 
118
32
  def get_status(self) -> dict:
119
- """Get sync status for debugging.
33
+ """Return metadata about the last synchronization."""
34
+ return {
35
+ 'server': 'gateway',
36
+ 'offset_ms': self._offset_ms,
37
+ 'last_sync': datetime.fromtimestamp(self._last_sync, tz=timezone.utc).isoformat() if self._last_sync else None,
38
+ 'last_latency_ms': self._last_latency_ms,
39
+ 'is_synced': self._last_sync is not None,
40
+ }
41
+
42
+ def update_from_server(self, server_time_ms: float, latency_ms: float) -> None:
43
+ """Update the clock offset using the server timestamp and measured latency."""
44
+ client_receive_ms = time.time() * 1000
45
+ half_latency = latency_ms / 2
46
+ estimated_server_received = client_receive_ms - half_latency
47
+ new_offset = server_time_ms - estimated_server_received
48
+ if self._last_sync is not None:
49
+ self._offset_ms += self._smoothing_weight * (new_offset - self._offset_ms)
50
+ else:
51
+ self._offset_ms = new_offset
52
+ self._last_latency_ms = latency_ms
53
+ self._last_sync = time.time()
120
54
 
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
- }
55
+ def sync(self) -> bool:
56
+ """Legacy sync stub kept for compatibility; no-op so callers continue to work."""
57
+ return False
132
58
 
133
59
  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})")
60
+ """Legacy stub that does nothing now that sync happens via the gateway."""
61
+ pass
147
62
 
148
63
 
149
- # Global instance - auto-starts sync on import
64
+ # Global instance
150
65
  ntp_clock = NTPClock()
151
- ntp_clock.start_auto_sync()