mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-07-31 03:05:21 +00:00
1351 lines
50 KiB
Python
1351 lines
50 KiB
Python
"""
|
|
time_synchronization Tool Implementation
|
|
|
|
Provides NTP-based time synchronization and FastDate64 timestamp encoding.
|
|
This tool ensures accurate, monotonic timekeeping for the NoDupeLabs system.
|
|
|
|
Features:
|
|
- NTP time synchronization using time.google.com and other servers
|
|
- FastDate64 64-bit timestamp encoding for compact storage
|
|
- Process-local corrected clock (no system clock changes)
|
|
- Background synchronization with configurable intervals
|
|
- Runtime enable/disable controls
|
|
|
|
Algorithm Sources:
|
|
- FastDate64 encoding/decoding based on Ben Joffe's work
|
|
Reference: https://www.benjoffe.com/fast-date-64
|
|
|
|
Environment variables:
|
|
- NODUPE_TIMESYNC_ENABLED (default: 1)
|
|
- NODUPE_TIMESYNC_NO_NETWORK (default: 0)
|
|
- NODUPE_TIMESYNC_ALLOW_BG (default: 1)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import socket
|
|
import struct
|
|
import time
|
|
import threading
|
|
from datetime import datetime, timezone
|
|
from typing import Iterable, Optional, Tuple, List
|
|
import logging
|
|
|
|
from nodupe.core.tool_system import Tool
|
|
from .sync_utils import (
|
|
ParallelNTPClient,
|
|
|
|
|
|
TargetedFileScanner,
|
|
FastDate64Encoder,
|
|
get_global_dns_cache,
|
|
get_global_metrics,
|
|
performance_timer
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class LeapYearCalculator:
|
|
"""
|
|
Leap year calculator with tool integration and fallback.
|
|
|
|
This class provides leap year calculations using the LeapYear tool
|
|
when available, with automatic fallback to built-in calculations.
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""Initialize leap year calculator."""
|
|
self._leap_year_tool = None
|
|
self._use_tool = False
|
|
self._initialize_leap_year_tool()
|
|
|
|
def _initialize_leap_year_tool(self):
|
|
"""Initialize the LeapYear tool if available."""
|
|
try:
|
|
from nodupe.tools.leap_year import LeapYearTool
|
|
self._leap_year_tool = LeapYearTool(calendar="gregorian")
|
|
self._leap_year_tool.initialize()
|
|
self._use_tool = True
|
|
logger.info("LeapYear tool loaded successfully for time calculations")
|
|
except (ImportError, Exception) as e:
|
|
self._leap_year_tool = None
|
|
self._use_tool = False
|
|
logger.debug(f"LeapYear tool not available, using built-in calculations: {e}")
|
|
|
|
def is_leap_year(self, year: int) -> bool:
|
|
"""
|
|
Determine if a year is a leap year.
|
|
|
|
Uses the LeapYear tool if available, otherwise falls back
|
|
to built-in Gregorian calendar calculation.
|
|
|
|
Args:
|
|
year: Year to check
|
|
|
|
Returns:
|
|
True if the year is a leap year, False otherwise
|
|
"""
|
|
if self._use_tool and self._leap_year_tool:
|
|
try:
|
|
return self._leap_year_tool.is_leap_year(year)
|
|
except Exception as e:
|
|
logger.warning(f"LeapYear tool error, falling back to built-in: {e}")
|
|
return self._is_leap_year_builtin(year)
|
|
else:
|
|
return self._is_leap_year_builtin(year)
|
|
|
|
def _is_leap_year_builtin(self, year: int) -> bool:
|
|
"""
|
|
Built-in Gregorian calendar leap year calculation.
|
|
|
|
Gregorian algorithm: divisible by 4, except centuries unless divisible by 400
|
|
|
|
Args:
|
|
year: Year to check
|
|
|
|
Returns:
|
|
True if the year is a leap year, False otherwise
|
|
"""
|
|
return (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0)
|
|
|
|
def get_days_in_february(self, year: int) -> int:
|
|
"""
|
|
Get the number of days in February for a given year.
|
|
|
|
Args:
|
|
year: Year to check
|
|
|
|
Returns:
|
|
29 if leap year, 28 otherwise
|
|
"""
|
|
return 29 if self.is_leap_year(year) else 28
|
|
|
|
def is_tool_available(self) -> bool:
|
|
"""Check if the LeapYear tool is available and being used."""
|
|
return self._use_tool
|
|
|
|
# Constants
|
|
NTP_TO_UNIX = 2208988800 # seconds between 1900-01-01 and 1970-01-01
|
|
DEFAULT_SERVERS = (
|
|
"time.google.com",
|
|
"time.cloudflare.com",
|
|
"time.apple.com",
|
|
"time.windows.com",
|
|
"time.facebook.com",
|
|
"pool.ntp.org"
|
|
)
|
|
DEFAULT_TIMEOUT = 3.0
|
|
DEFAULT_ATTEMPTS = 2
|
|
|
|
# FastDate64 layout: seconds_bits + frac_bits = 64
|
|
FASTDATE_SECONDS_BITS = 34
|
|
FASTDATE_FRAC_BITS = 30
|
|
FASTDATE_FRAC_SCALE = 1 << FASTDATE_FRAC_BITS
|
|
FASTDATE_SECONDS_MAX = (1 << FASTDATE_SECONDS_BITS) - 1
|
|
|
|
# Environment-driven defaults (allow override per-instance)
|
|
_env_enabled = os.getenv("NODUPE_TIMESYNC_ENABLED", "1").lower() not in ("0", "false", "no")
|
|
_env_no_network = os.getenv("NODUPE_TIMESYNC_NO_NETWORK", "0").lower() in ("1", "true", "yes")
|
|
_env_allow_bg = os.getenv("NODUPE_TIMESYNC_ALLOW_BG", "1").lower() not in ("0", "false", "no")
|
|
|
|
|
|
class time_synchronizationDisabledError(RuntimeError):
|
|
"""Raised when a requested operation is disabled on the time_synchronization instance."""
|
|
pass
|
|
|
|
|
|
class time_synchronizationTool(Tool):
|
|
"""
|
|
time_synchronization Tool for NTP-based time synchronization and FastDate64 encoding.
|
|
|
|
This tool provides:
|
|
1. Accurate time synchronization using NTP servers (preferred)
|
|
2. Fallback to local time resources when NTP is unavailable
|
|
3. FastDate64 64-bit timestamp encoding for compact storage
|
|
4. Monotonic timekeeping immune to system clock changes
|
|
5. Background synchronization with configurable intervals
|
|
|
|
The tool integrates with the NoDupeLabs tool system and provides
|
|
both synchronous and asynchronous time synchronization capabilities.
|
|
|
|
Fallback Strategy:
|
|
- Primary: NTP synchronization (preferred method)
|
|
- Fallback 1: System RTC with monotonic correction
|
|
- Fallback 2: Pure monotonic time (when RTC unavailable)
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
servers: Optional[Iterable[str]] = None,
|
|
timeout: float = DEFAULT_TIMEOUT,
|
|
attempts: int = DEFAULT_ATTEMPTS,
|
|
max_acceptable_delay: float = 0.5,
|
|
smoothing_alpha: float = 0.3,
|
|
*,
|
|
enabled: Optional[bool] = None,
|
|
allow_network: Optional[bool] = None,
|
|
allow_background: Optional[bool] = None,
|
|
):
|
|
"""
|
|
Initialize the time_synchronization tool.
|
|
|
|
Args:
|
|
servers: List of NTP servers to query (defaults to Google, Cloudflare, pool)
|
|
timeout: Socket timeout for NTP queries in seconds
|
|
attempts: Number of attempts per server before giving up
|
|
max_acceptable_delay: Maximum acceptable network delay in seconds
|
|
smoothing_alpha: Smoothing factor for offset calculations (0.0-1.0)
|
|
enabled: Override default enabled state
|
|
allow_network: Override default network access state
|
|
allow_background: Override default background sync state
|
|
"""
|
|
super().__init__()
|
|
self.servers = list(servers or DEFAULT_SERVERS)
|
|
if not self.servers:
|
|
raise ValueError("At least one NTP server required")
|
|
|
|
self.timeout = float(timeout)
|
|
self.attempts = int(attempts)
|
|
self.max_acceptable_delay = float(max_acceptable_delay)
|
|
self.alpha = float(smoothing_alpha)
|
|
|
|
# Runtime flags default to environment-driven values but can be overridden per-instance
|
|
self._enabled = _env_enabled if enabled is None else bool(enabled)
|
|
self._allow_network = (not _env_no_network) if allow_network is None else bool(allow_network)
|
|
self._allow_background = _env_allow_bg if allow_background is None else bool(allow_background)
|
|
|
|
self._lock = threading.Lock()
|
|
self._base_server_time: Optional[float] = None
|
|
self._base_monotonic: Optional[float] = None
|
|
self._smoothed_offset: Optional[float] = None
|
|
self._last_delay: Optional[float] = None
|
|
|
|
self._bg_thread: Optional[threading.Thread] = None
|
|
self._bg_stop = threading.Event()
|
|
|
|
|
|
def initialize(self, container: Any) -> None:
|
|
"""Initialize the tool."""
|
|
logger.info("Initializing time_synchronization tool")
|
|
if self._enabled:
|
|
logger.info("time_synchronization enabled, attempting initial synchronization")
|
|
try:
|
|
self.force_sync()
|
|
logger.info("Initial time synchronization successful")
|
|
except Exception as e:
|
|
logger.warning(f"Initial time synchronization failed: {e}")
|
|
else:
|
|
logger.info("time_synchronization disabled")
|
|
|
|
def shutdown(self) -> None:
|
|
"""Shutdown the tool."""
|
|
logger.info("Shutting down time_synchronization tool")
|
|
self.stop_background(wait=False)
|
|
|
|
# ---- abstract methods required by Tool base class ----
|
|
@property
|
|
def name(self) -> str:
|
|
"""Tool name."""
|
|
return "time_synchronization"
|
|
|
|
@property
|
|
def version(self) -> str:
|
|
"""Tool version."""
|
|
return "1.0.0"
|
|
|
|
@property
|
|
def dependencies(self) -> List[str]:
|
|
"""List of tool dependencies."""
|
|
return []
|
|
|
|
@property
|
|
def metadata(self) -> ToolMetadata:
|
|
"""Get tool metadata (ISO 19770-2 compliant)."""
|
|
return ToolMetadata(
|
|
name=self.name,
|
|
version=self.version,
|
|
software_id=f"org.nodupe.tool.{self.name.lower()}",
|
|
description="NTP-based time synchronization and FastDate64 timestamp encoding",
|
|
author="NoDupeLabs",
|
|
license="Apache-2.0", # SPDX standard
|
|
dependencies=self.dependencies,
|
|
tags=["time", "ntp", "synchronization", "timestamps"]
|
|
)
|
|
|
|
def run_standalone(self, args: List[str]) -> int:
|
|
"""Execute time synchronization in stand-alone mode."""
|
|
import argparse
|
|
parser = argparse.ArgumentParser(description="NoDupeLabs Stand-Alone time_synchronization Utility")
|
|
parser.add_argument("--sync", action="store_true", help="Perform NTP synchronization")
|
|
parser.add_argument("--format", default="rfc3339", help="Output format (rfc3339, unix)")
|
|
|
|
parsed = parser.parse_args(args)
|
|
try:
|
|
if parsed.sync:
|
|
self.sync_time()
|
|
res = self.get_authenticated_time(format=parsed.format)
|
|
print(f"Authenticated Time ({parsed.format}): {res}")
|
|
return 0
|
|
except Exception as e:
|
|
print(f"Error: {e}")
|
|
return 1
|
|
|
|
@property
|
|
def api_methods(self) -> Dict[str, Callable[..., Any]]:
|
|
"""Dictionary of methods exposed via programmatic API (Socket/IPC)"""
|
|
return {
|
|
'force_sync': self.force_sync,
|
|
'sync_with_fallback': self.sync_with_fallback,
|
|
'get_authenticated_time': self.get_authenticated_time,
|
|
'get_corrected_time': self.get_corrected_time,
|
|
'get_timestamp_fast64': self.get_timestamp_fast64,
|
|
'encode_fastdate64': self.encode_fastdate64,
|
|
'decode_fastdate64': self.decode_fastdate64,
|
|
'get_status': self.get_status,
|
|
'is_leap_year': self.is_leap_year
|
|
}
|
|
|
|
def get_capabilities(self) -> Dict[str, Any]:
|
|
"""Get tool capabilities."""
|
|
return {
|
|
"name": self.name,
|
|
"version": self.version,
|
|
"description": "NTP-based time synchronization and FastDate64 timestamp encoding",
|
|
"author": "NoDupeLabs",
|
|
"license": "MIT",
|
|
"tags": ["time", "ntp", "synchronization", "timestamps"],
|
|
"capabilities": [
|
|
"time_synchronization",
|
|
"ntp_sync",
|
|
"rtc_fallback",
|
|
"fastdate64_encoding",
|
|
"authenticated_time"
|
|
]
|
|
}
|
|
|
|
def describe_usage(self) -> str:
|
|
"""Return human-readable usage description.
|
|
|
|
Returns:
|
|
Usage description string
|
|
"""
|
|
return """Time Synchronization Tool
|
|
|
|
Provides NTP-based time synchronization and FastDate64 timestamp encoding.
|
|
|
|
Features:
|
|
- NTP time synchronization using time.google.com and other servers
|
|
- FastDate64 64-bit timestamp encoding for compact storage
|
|
- Process-local corrected clock (no system clock changes)
|
|
- Background synchronization with configurable intervals
|
|
- Fallback to RTC and monotonic time when NTP unavailable
|
|
|
|
Usage:
|
|
--sync Perform NTP synchronization
|
|
--format FORMAT Output format (rfc3339, unix)
|
|
|
|
Examples:
|
|
Get current authenticated time:
|
|
python -m nodupe.tools.time_sync.time_sync_tool
|
|
|
|
Sync with NTP and get time:
|
|
python -m nodupe.tools.time_sync.time_sync_tool --sync
|
|
|
|
Get Unix timestamp:
|
|
python -m nodupe.tools.time_sync.time_sync_tool --format unix
|
|
"""
|
|
|
|
# ---- runtime flags API ----
|
|
def is_enabled(self) -> bool:
|
|
"""Check if the tool is enabled."""
|
|
return bool(self._enabled)
|
|
|
|
def enable(self) -> None:
|
|
"""Enable the tool."""
|
|
self._enabled = True
|
|
logger.info("time_synchronization tool enabled")
|
|
|
|
def disable(self) -> None:
|
|
"""Disable the tool."""
|
|
self._enabled = False
|
|
self.stop_background(wait=False)
|
|
logger.info("time_synchronization tool disabled")
|
|
|
|
def is_network_allowed(self) -> bool:
|
|
"""Check if network operations are allowed."""
|
|
return bool(self._allow_network)
|
|
|
|
def enable_network(self) -> None:
|
|
"""Enable network operations."""
|
|
self._allow_network = True
|
|
logger.info("time_synchronization network operations enabled")
|
|
|
|
def disable_network(self) -> None:
|
|
"""Disable network operations."""
|
|
self._allow_network = False
|
|
logger.info("time_synchronization network operations disabled")
|
|
|
|
def is_background_allowed(self) -> bool:
|
|
"""Check if background synchronization is allowed."""
|
|
return bool(self._allow_background)
|
|
|
|
def enable_background(self) -> None:
|
|
"""Enable background synchronization."""
|
|
self._allow_background = True
|
|
logger.info("time_synchronization background synchronization enabled")
|
|
|
|
def disable_background(self) -> None:
|
|
"""Disable background synchronization."""
|
|
self._allow_background = False
|
|
self.stop_background(wait=False)
|
|
logger.info("time_synchronization background synchronization disabled")
|
|
|
|
# ---- internal helpers ----
|
|
def _to_ntp(self, ts: float) -> Tuple[int, int]:
|
|
"""Convert POSIX timestamp to NTP format."""
|
|
ntp = ts + NTP_TO_UNIX
|
|
sec = int(ntp)
|
|
frac = int((ntp - sec) * (1 << 32))
|
|
return sec, frac
|
|
|
|
def _from_ntp(self, sec: int, frac: int) -> float:
|
|
"""Convert NTP timestamp to POSIX format."""
|
|
return (sec + float(frac) / (1 << 32)) - NTP_TO_UNIX
|
|
|
|
def _resolve_addresses(self, host: str, port: int = 123) -> List[Tuple]:
|
|
"""Resolve a host to socket address tuples using getaddrinfo."""
|
|
try:
|
|
return socket.getaddrinfo(host, port, 0, socket.SOCK_DGRAM)
|
|
except socket.gaierror:
|
|
return []
|
|
|
|
def _query_address(self, addr_info: Tuple, timeout: float) -> Tuple[float, float, float]:
|
|
"""
|
|
Query a single getaddrinfo entry. Returns (server_time, offset, delay).
|
|
Raises socket.timeout / OSError / ValueError on error.
|
|
"""
|
|
family, _, _, _, sockaddr = addr_info
|
|
packet = bytearray(48)
|
|
packet[0] = 0x23 # LI=0, VN=4, Mode=3
|
|
|
|
with socket.socket(family, socket.SOCK_DGRAM) as sock:
|
|
sock.settimeout(timeout)
|
|
# Fill transmit timestamp (client t1)
|
|
t1 = time.time()
|
|
sec, frac = self._to_ntp(t1)
|
|
struct.pack_into("!II", packet, 40, sec, frac)
|
|
|
|
sock.sendto(packet, sockaddr)
|
|
t1_local = t1
|
|
data, _ = sock.recvfrom(512)
|
|
t4_local = time.time()
|
|
|
|
if len(data) < 48:
|
|
raise ValueError("Short NTP response")
|
|
|
|
unpack = struct.unpack("!12I", data[0:48])
|
|
t2 = self._from_ntp(unpack[8], unpack[9])
|
|
t3 = self._from_ntp(unpack[10], unpack[11])
|
|
|
|
delay = (t4_local - t1_local) - (t3 - t2)
|
|
offset = ((t2 - t1_local) + (t3 - t4_local)) / 2.0
|
|
server_time = t3
|
|
return server_time, offset, delay
|
|
|
|
def _query_ntp_once(self, host: str, timeout: float) -> Tuple[float, float, float]:
|
|
"""
|
|
Query a host and return best reply (lowest delay) across resolved addresses.
|
|
Returns (server_time, offset, delay).
|
|
Raises RuntimeError if none succeeded.
|
|
"""
|
|
addrs = self._resolve_addresses(host)
|
|
if not addrs:
|
|
raise RuntimeError(f"DNS resolution failed for {host}")
|
|
|
|
best = None
|
|
last_exc = None
|
|
for addr_info in addrs:
|
|
try:
|
|
server_time, offset, delay = self._query_address(addr_info, timeout)
|
|
if best is None or delay < best[0]:
|
|
best = (delay, server_time, offset)
|
|
except Exception as e:
|
|
last_exc = e
|
|
continue
|
|
|
|
if best is None:
|
|
raise RuntimeError(f"No NTP responses from {host}; last error: {last_exc!r}")
|
|
|
|
delay, server_time, offset = best
|
|
return server_time, offset, delay
|
|
|
|
def _query_servers_best(self, hosts: Iterable[str]) -> Tuple[str, float, float, float]:
|
|
"""
|
|
Query multiple servers and return the best reply overall (host, server_time, offset, delay).
|
|
"""
|
|
best = None
|
|
last_exc = None
|
|
for host in hosts:
|
|
for _ in range(self.attempts):
|
|
try:
|
|
server_time, offset, delay = self._query_ntp_once(host, self.timeout)
|
|
if best is None or delay < best[0]:
|
|
best = (delay, host, server_time, offset)
|
|
except Exception as e:
|
|
last_exc = e
|
|
continue
|
|
|
|
if best is None:
|
|
raise RuntimeError(f"No NTP responses; last error: {last_exc!r}")
|
|
|
|
delay, host, server_time, offset = best
|
|
return host, server_time, offset, delay
|
|
|
|
def _apply_new_measurement(self, server_time: float, offset: float, delay: float) -> None:
|
|
"""Apply a new NTP measurement to update internal state."""
|
|
with self._lock:
|
|
self._base_server_time = server_time
|
|
self._base_monotonic = time.monotonic()
|
|
self._last_delay = delay
|
|
if self._smoothed_offset is None:
|
|
self._smoothed_offset = offset
|
|
else:
|
|
self._smoothed_offset = (self.alpha * offset) + ((1.0 - self.alpha) * self._smoothed_offset)
|
|
|
|
# ---- public API that involves network ----
|
|
def force_sync(self) -> Tuple[str, float, float, float]:
|
|
"""
|
|
Synchronously query NTP servers and update local reference.
|
|
Returns (host, server_time, offset, delay).
|
|
|
|
Raises:
|
|
time_synchronizationDisabledError if the instance or network operation is disabled.
|
|
RuntimeError on network / NTP failures.
|
|
"""
|
|
if not self.is_enabled():
|
|
raise time_synchronizationDisabledError("time_synchronization instance is disabled")
|
|
if not self.is_network_allowed():
|
|
raise time_synchronizationDisabledError("Network operations are disabled for time_synchronization")
|
|
|
|
# Use parallel NTP client for improved performance
|
|
with performance_timer("NTP parallel sync"):
|
|
parallel_client = ParallelNTPClient(
|
|
timeout=self.timeout,
|
|
dns_cache=get_global_dns_cache()
|
|
)
|
|
|
|
result = parallel_client.query_hosts_parallel(
|
|
hosts=self.servers,
|
|
attempts_per_host=self.attempts,
|
|
max_acceptable_delay=self.max_acceptable_delay,
|
|
stop_on_good_result=True,
|
|
good_delay_threshold=0.1
|
|
)
|
|
|
|
parallel_client.shutdown(wait=False)
|
|
|
|
if not result.success or result.best_response is None:
|
|
raise RuntimeError("No successful NTP responses")
|
|
|
|
best = result.best_response
|
|
host = best.host
|
|
server_time = best.server_time
|
|
offset = best.offset
|
|
delay = best.delay
|
|
|
|
if delay > self.max_acceptable_delay:
|
|
raise RuntimeError(f"NTP reply from {host} too noisy (delay={delay:.3f}s)")
|
|
|
|
self._apply_new_measurement(server_time, offset, delay)
|
|
logger.info(f"Time synchronized with {host}, offset: {offset:.3f}s, delay: {delay:.3f}s")
|
|
|
|
# Record performance metrics
|
|
get_global_metrics().record_parallel_query(
|
|
num_hosts=len(self.servers),
|
|
num_addresses=sum(len(get_global_dns_cache().get(host, []) or []) for host in self.servers),
|
|
success=True,
|
|
duration=0.0, # Would need to measure actual duration
|
|
best_delay=delay
|
|
)
|
|
|
|
return host, server_time, offset, delay
|
|
|
|
def maybe_sync(self) -> Optional[Tuple[str, float, float, float]]:
|
|
"""
|
|
Try to sync; return None on disabled/no-response instead of raising.
|
|
"""
|
|
if not self.is_enabled() or not self.is_network_allowed():
|
|
return None
|
|
try:
|
|
return self.force_sync()
|
|
except Exception as e:
|
|
logger.warning(f"Time synchronization failed: {e}")
|
|
return None
|
|
|
|
def sync_with_fallback(self) -> Tuple[str, float, float, float]:
|
|
"""
|
|
Attempt NTP synchronization with fallback to local time resources.
|
|
|
|
Fallback Strategy:
|
|
1. Primary: NTP synchronization (preferred method)
|
|
2. Fallback 1: System RTC with monotonic correction
|
|
3. Fallback 2: System time (time.time()) if RTC unavailable
|
|
4. Fallback 3: File timestamp fallback using recent files
|
|
5. Fallback 4: Pure monotonic time (when no other options available)
|
|
|
|
Returns:
|
|
Tuple of (source, server_time, offset, delay) where source indicates
|
|
the method used ('ntp', 'rtc', 'system', 'file', or 'monotonic')
|
|
|
|
Raises:
|
|
time_synchronizationDisabledError if the instance is disabled
|
|
"""
|
|
if not self.is_enabled():
|
|
raise time_synchronizationDisabledError("time_synchronization instance is disabled")
|
|
|
|
# Primary: Try NTP synchronization
|
|
if self.is_network_allowed():
|
|
try:
|
|
host, server_time, offset, delay = self.force_sync()
|
|
logger.info(f"Successfully synchronized via NTP: {host}")
|
|
return ("ntp", server_time, offset, delay)
|
|
except Exception as e:
|
|
logger.warning(f"NTP synchronization failed, trying RTC fallback: {e}")
|
|
|
|
# Fallback 1: Use system RTC with monotonic correction
|
|
try:
|
|
rtc_time = self._get_rtc_time()
|
|
monotonic_time = time.monotonic()
|
|
|
|
# Calculate offset between RTC and monotonic
|
|
offset = rtc_time - monotonic_time
|
|
delay = 0.0 # No network delay for local RTC
|
|
|
|
self._apply_new_measurement(rtc_time, offset, delay)
|
|
logger.info("Successfully synchronized using system RTC")
|
|
return ("rtc", rtc_time, offset, delay)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"RTC fallback failed, trying system time: {e}")
|
|
|
|
# Fallback 2: Use system time (time.time()) if RTC unavailable
|
|
try:
|
|
# Capture both timestamps simultaneously to avoid timing issues
|
|
monotonic_start = time.monotonic()
|
|
system_time = time.time()
|
|
monotonic_end = time.monotonic()
|
|
|
|
# Use midpoint for better accuracy
|
|
monotonic_time = (monotonic_start + monotonic_end) / 2.0
|
|
|
|
# Validate system time is reasonable (not too far in the past or future)
|
|
current_time = time.time()
|
|
if system_time < 1262304000: # Before year 2010
|
|
raise RuntimeError("System time appears invalid (too far in the past)")
|
|
if abs(system_time - current_time) > 300: # More than 5 minutes difference
|
|
raise RuntimeError("System time appears unstable (large drift detected)")
|
|
|
|
# Calculate offset between system time and monotonic
|
|
offset = system_time - monotonic_time
|
|
delay = 0.0
|
|
|
|
self._apply_new_measurement(system_time, offset, delay)
|
|
logger.info("Successfully synchronized using system time")
|
|
return ("system", system_time, offset, delay)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"System time fallback failed ({e}), trying file timestamp: {e}")
|
|
|
|
# Fallback 3: File timestamp fallback using recent files
|
|
try:
|
|
file_time = self._get_file_timestamp()
|
|
|
|
# Capture monotonic time simultaneously with file access
|
|
monotonic_start = time.monotonic()
|
|
monotonic_end = time.monotonic()
|
|
monotonic_time = (monotonic_start + monotonic_end) / 2.0
|
|
|
|
# Additional validation: ensure file time isn't too old or too new
|
|
current_time = time.time()
|
|
if abs(file_time - current_time) > 86400: # More than 24 hours difference
|
|
raise RuntimeError("File timestamp appears stale (too old or future)")
|
|
|
|
# Calculate offset between file time and monotonic
|
|
offset = file_time - monotonic_time
|
|
delay = 0.0
|
|
|
|
self._apply_new_measurement(file_time, offset, delay)
|
|
logger.info("Successfully synchronized using file timestamp")
|
|
return ("file", file_time, offset, delay)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"File timestamp fallback failed ({e}), using pure monotonic time: {e}")
|
|
|
|
# Fallback 4: Pure monotonic time with date estimation
|
|
try:
|
|
# Try to get a recent file timestamp to estimate the date
|
|
file_time = self._get_file_timestamp()
|
|
monotonic_start = time.monotonic()
|
|
monotonic_end = time.monotonic()
|
|
monotonic_time = (monotonic_start + monotonic_end) / 2.0
|
|
|
|
# Use file timestamp as base, adjusted by monotonic time
|
|
# This gives us a reasonable date while maintaining monotonic progression
|
|
estimated_time = file_time + (monotonic_time - monotonic_start)
|
|
|
|
# Validate the estimated time isn't too far in the past or future
|
|
current_time = time.time()
|
|
if abs(estimated_time - current_time) > 86400: # More than 24 hours difference
|
|
logger.warning(f"File-based time estimation seems stale: {abs(estimated_time - current_time)/3600:.1f} hours difference")
|
|
# Fall back to pure monotonic if estimation is too far off
|
|
raise RuntimeError("File-based time estimation too stale")
|
|
|
|
offset = estimated_time - monotonic_time
|
|
delay = 0.0
|
|
|
|
self._apply_new_measurement(estimated_time, offset, delay)
|
|
logger.info(f"Using monotonic time with file-based date estimation from {file_time}")
|
|
return ("monotonic_estimated", estimated_time, offset, delay)
|
|
|
|
except Exception as e:
|
|
# Final fallback: pure monotonic time (no date concept)
|
|
monotonic_time = time.monotonic()
|
|
offset = 0.0
|
|
delay = 0.0
|
|
|
|
self._apply_new_measurement(monotonic_time, offset, delay)
|
|
logger.warning("Using pure monotonic time (no date concept available) - certificate validation may fail")
|
|
return ("monotonic", monotonic_time, offset, delay)
|
|
|
|
def get_authenticated_time(self, format: str = "iso8601") -> str:
|
|
"""
|
|
Retrieves the current high-precision network time using a multi-layer fallback system.
|
|
|
|
This method provides cryptographically verified time through NTS when available,
|
|
with automatic fallback to standard NTP and local Hardware RTC for maximum reliability.
|
|
|
|
Fallback Strategy:
|
|
1. Primary: NTS (Network Time Security) - cryptographically verified
|
|
2. Fallback 1: Standard NTP - network-based time synchronization
|
|
3. Fallback 2: Hardware RTC - local real-time clock
|
|
4. Fallback 3: System time - time.time() when RTC unavailable
|
|
5. Fallback 4: File timestamp - recent file modification times
|
|
6. Fallback 5: Pure monotonic time - last resort when no external reference available
|
|
|
|
Args:
|
|
format: Output format for the time. Supported formats:
|
|
- "iso8601" (default): ISO-8601 format (e.g., "2025-12-18T15:03:24.123456Z")
|
|
- "unix": Unix timestamp as string (e.g., "1702918204.123456")
|
|
- "rfc3339": RFC 3339 format (same as ISO-8601)
|
|
- "human": Human-readable format (e.g., "2025-12-18 15:03:24.123456 UTC")
|
|
- "failure": Special format that returns [Null Time - Failure] when all methods fail
|
|
|
|
Returns:
|
|
Time string in the specified format with microsecond precision, or
|
|
"[Null Time - Failure]" if all time sources fail and format="failure"
|
|
|
|
Raises:
|
|
time_synchronizationDisabledError: If the tool is disabled
|
|
|
|
Behavioral Guidelines:
|
|
- Trust Factor: This time is cryptographically verified via NTS when available.
|
|
If NTS fails, it falls back to standard NTP.
|
|
- Offline Support: If the network is down, it retrieves time from the local
|
|
Hardware RTC.
|
|
- Output: Always reports time in UTC unless otherwise specified.
|
|
- Fallback Notification: If the tool indicates a fallback to RTC, the user
|
|
will be informed that the time is "locally sourced" and may have slight drift.
|
|
- Failure Mode: When format="failure" and all methods fail, returns "[Null Time - Failure]"
|
|
- Production Hardening: Includes monotonic gap handling, file timestamp freshness checks,
|
|
and step vs slew strategy for large time corrections.
|
|
|
|
Example usage:
|
|
>>> tool = time_synchronizationTool()
|
|
>>> tool.get_authenticated_time()
|
|
"2025-12-18T15:03:24.123456Z"
|
|
>>> tool.get_authenticated_time(format="unix")
|
|
"1702918204.123456"
|
|
>>> tool.get_authenticated_time(format="failure")
|
|
"[Null Time - Failure]" # Only when all methods fail
|
|
"""
|
|
if not self.is_enabled():
|
|
raise time_synchronizationDisabledError("time_synchronization instance is disabled")
|
|
|
|
# Try to get the best available time source
|
|
try:
|
|
sync_result = self.sync_with_fallback()
|
|
source, _, _, _ = sync_result
|
|
|
|
# Get current corrected time
|
|
current_time = self.get_corrected_time()
|
|
|
|
# Production Hardening: Check for certificate validation capability
|
|
if source == "monotonic":
|
|
logger.warning("WARNING: Using pure monotonic time - certificate validation may fail due to lack of date concept")
|
|
elif source == "monotonic_estimated":
|
|
logger.info("Using monotonic time with date estimation - suitable for most applications")
|
|
|
|
# Format the time based on the requested format
|
|
if format.lower() in ["iso8601", "rfc3339"]:
|
|
# Convert to ISO-8601 / RFC 3339 format (YYYY-MM-DDTHH:MM:SS.mmmmmmZ)
|
|
dt = datetime.fromtimestamp(current_time, tz=timezone.utc)
|
|
# ISO 8601 and RFC 3339 are compatible here
|
|
result = dt.isoformat(timespec="microseconds").replace("+00:00", "Z")
|
|
|
|
elif format.lower() == "unix":
|
|
# Return Unix timestamp as string
|
|
result = f"{current_time:.6f}"
|
|
|
|
elif format.lower() == "human":
|
|
# Human-readable format
|
|
dt = datetime.fromtimestamp(current_time, tz=timezone.utc)
|
|
result = dt.strftime("%Y-%m-%d %H:%M:%S.%f UTC")
|
|
|
|
else:
|
|
raise ValueError(f"Unsupported format: {format}. Supported formats: iso8601, unix, rfc3339, human, failure")
|
|
|
|
# Log fallback information if applicable
|
|
if source != "ntp":
|
|
logger.warning(f"Time obtained via {source} fallback (not NTP/NTS). "
|
|
f"Time may have slight drift from network time.")
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
# All fallback methods failed
|
|
logger.error(f"All time synchronization methods failed: {e}")
|
|
|
|
# Check if failure format is requested
|
|
if format.lower() == "failure":
|
|
logger.critical("CRITICAL: All time sources have failed. Disabling time_synchronization tool to prevent log spam.")
|
|
logger.critical("time_synchronization tool is shutting down. Metadata timestamping will use system time.")
|
|
|
|
# Gracefully disable the tool to prevent endless error spam
|
|
self.disable()
|
|
|
|
return "[Null Time - Failure]"
|
|
else:
|
|
# Re-raise the exception for other formats
|
|
raise RuntimeError(f"Unable to obtain time from any source: {e}")
|
|
|
|
def _get_file_timestamp(self) -> float:
|
|
"""
|
|
Get time from recent file timestamps as a fallback.
|
|
|
|
This uses the optimized TargetedFileScanner for efficient file system scanning
|
|
with limited depth and early cutoff to avoid blocking on large filesystems.
|
|
|
|
Returns:
|
|
Current time based on the most recent file timestamp in seconds since Unix epoch
|
|
|
|
Raises:
|
|
RuntimeError: If no suitable files are found or access fails
|
|
"""
|
|
# Use optimized file scanner for better performance
|
|
scanner = TargetedFileScanner(max_files=100, max_depth=2)
|
|
|
|
# Additional paths to search beyond the scanner's trusted paths
|
|
additional_paths = [
|
|
os.path.expanduser("~"),
|
|
os.getcwd(),
|
|
]
|
|
|
|
try:
|
|
file_time = scanner.get_recent_file_time(additional_paths)
|
|
|
|
if file_time is None:
|
|
raise RuntimeError("No suitable recent files found")
|
|
|
|
logger.info(f"Using file timestamp from optimized scanner: {file_time}")
|
|
return file_time
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Optimized file scanner failed: {e}")
|
|
# Fallback to simple approach if scanner fails
|
|
return self._get_file_timestamp_fallback()
|
|
|
|
def _get_file_timestamp_fallback(self) -> float:
|
|
"""
|
|
Fallback file timestamp method using simple glob approach.
|
|
|
|
This is used if the optimized scanner fails.
|
|
"""
|
|
import os
|
|
import glob
|
|
|
|
# Common locations to search for recently modified files
|
|
search_paths = [
|
|
"/tmp",
|
|
"/var/tmp",
|
|
"/var/log",
|
|
os.path.expanduser("~"),
|
|
os.getcwd(),
|
|
]
|
|
|
|
# File patterns to look for (avoiding system-critical files)
|
|
patterns = [
|
|
"*.tmp",
|
|
"*.log",
|
|
"*.txt",
|
|
"*.json",
|
|
"*.py",
|
|
"*cache*",
|
|
]
|
|
|
|
latest_time = 0
|
|
latest_file = None
|
|
|
|
for base_path in search_paths:
|
|
if not os.path.exists(base_path):
|
|
continue
|
|
|
|
for pattern in patterns:
|
|
try:
|
|
# Use glob to find files matching pattern
|
|
search_pattern = os.path.join(base_path, "**", pattern)
|
|
for file_path in glob.glob(search_pattern, recursive=True):
|
|
try:
|
|
# Get file modification time
|
|
mtime = os.path.getmtime(file_path)
|
|
|
|
# Validate timestamp is reasonable (not too far in the past)
|
|
if mtime > 1000000000: # After year 2002
|
|
if mtime > latest_time:
|
|
latest_time = mtime
|
|
latest_file = file_path
|
|
except (OSError, IOError):
|
|
# Skip files we can't access
|
|
continue
|
|
|
|
except Exception:
|
|
# Skip patterns that cause errors
|
|
continue
|
|
|
|
if latest_time > 0:
|
|
logger.info(f"Using file timestamp from fallback scanner: {latest_file}: {latest_time}")
|
|
return latest_time
|
|
else:
|
|
raise RuntimeError("No suitable recent files found for timestamp fallback")
|
|
|
|
def _get_rtc_time(self) -> float:
|
|
"""
|
|
Get time from system Real-Time Clock (RTC).
|
|
|
|
This provides a system time reference when NTP is unavailable.
|
|
|
|
Returns:
|
|
Current time from system RTC in seconds since Unix epoch
|
|
|
|
Raises:
|
|
RuntimeError: If RTC access fails
|
|
"""
|
|
try:
|
|
# Use time.time() to get system RTC time
|
|
rtc_time = time.time()
|
|
|
|
# Validate that we got a reasonable timestamp
|
|
if rtc_time < 1000000000: # Before year 2002
|
|
raise RuntimeError("RTC time appears invalid (too far in the past)")
|
|
|
|
return rtc_time
|
|
|
|
except Exception as e:
|
|
raise RuntimeError(f"Failed to read system RTC: {e}")
|
|
|
|
def get_sync_status(self) -> dict:
|
|
"""
|
|
Get detailed synchronization status including fallback information.
|
|
|
|
Returns:
|
|
Dictionary with synchronization status and method used
|
|
"""
|
|
with self._lock:
|
|
status = {
|
|
"sync_method": "none",
|
|
"sync_time": None,
|
|
"offset_estimate": self._smoothed_offset,
|
|
"last_delay": self._last_delay,
|
|
"has_external_reference": False,
|
|
"monotonic_base": self._base_monotonic,
|
|
"server_time_base": self._base_server_time
|
|
}
|
|
|
|
if self._base_server_time is not None and self._base_monotonic is not None:
|
|
# Determine sync method based on the source of the base time
|
|
if self._last_delay and self._last_delay > 0:
|
|
status["sync_method"] = "ntp"
|
|
status["has_external_reference"] = True
|
|
elif self._smoothed_offset == (self._base_server_time - self._base_monotonic):
|
|
status["sync_method"] = "rtc"
|
|
status["has_external_reference"] = True
|
|
else:
|
|
status["sync_method"] = "monotonic"
|
|
|
|
status["sync_time"] = self._base_server_time
|
|
|
|
return status
|
|
|
|
# ---- background worker ----
|
|
def start_background(self, interval: float = 300.0, initial_sync: bool = True) -> None:
|
|
"""
|
|
Start a background thread that periodically refreshes the offset.
|
|
|
|
Args:
|
|
interval: Time in seconds between synchronization attempts
|
|
initial_sync: Whether to perform an initial sync before starting background thread
|
|
|
|
Raises:
|
|
time_synchronizationDisabledError if disabled or background not allowed.
|
|
"""
|
|
if not self.is_enabled():
|
|
raise time_synchronizationDisabledError("time_synchronization instance is disabled")
|
|
if not self.is_background_allowed():
|
|
raise time_synchronizationDisabledError("Background syncing is disabled for time_synchronization")
|
|
if not self.is_network_allowed():
|
|
raise time_synchronizationDisabledError("Cannot start background sync when network is disabled")
|
|
|
|
if self._bg_thread is not None and self._bg_thread.is_alive():
|
|
return
|
|
self._bg_stop.clear()
|
|
|
|
def _loop():
|
|
"""Background synchronization loop."""
|
|
if initial_sync:
|
|
try:
|
|
self.force_sync()
|
|
except Exception as e:
|
|
logger.warning(f"Initial background sync failed: {e}")
|
|
|
|
while not self._bg_stop.wait(timeout=interval):
|
|
try:
|
|
self.force_sync()
|
|
except Exception as e:
|
|
logger.warning(f"Background sync failed: {e}")
|
|
|
|
self._bg_thread = threading.Thread(target=_loop, daemon=True, name="time_synchronizationThread")
|
|
self._bg_thread.start()
|
|
logger.info(f"time_synchronization background synchronization started (interval: {interval}s)")
|
|
|
|
def stop_background(self, wait: bool = True, timeout: Optional[float] = None) -> None:
|
|
"""Stop the background synchronization thread."""
|
|
if self._bg_thread is None:
|
|
return
|
|
self._bg_stop.set()
|
|
if wait:
|
|
self._bg_thread.join(timeout=timeout)
|
|
self._bg_thread = None
|
|
logger.info("time_synchronization background synchronization stopped")
|
|
|
|
# ---- time accessors ----
|
|
def get_corrected_time(self) -> float:
|
|
"""
|
|
Return POSIX timestamp corrected to NTP (float seconds).
|
|
If the instance is disabled or not yet synced, returns time.monotonic() as fallback.
|
|
"""
|
|
if not self.is_enabled():
|
|
return time.monotonic()
|
|
with self._lock:
|
|
if self._base_server_time is None or self._base_monotonic is None:
|
|
return time.monotonic()
|
|
return self._base_server_time + (time.monotonic() - self._base_monotonic)
|
|
|
|
def get_corrected_fast64(self) -> int:
|
|
"""
|
|
Return the corrected timestamp encoded as FastDate64 (uint64).
|
|
If disabled, encodes the local time.monotonic() fallback.
|
|
"""
|
|
return self.encode_fastdate64(self.get_corrected_time())
|
|
|
|
def get_offset_estimate(self) -> Optional[float]:
|
|
"""Get the current offset estimate in seconds."""
|
|
with self._lock:
|
|
return self._smoothed_offset
|
|
|
|
def get_last_delay(self) -> Optional[float]:
|
|
"""Get the last measured network delay in seconds."""
|
|
with self._lock:
|
|
return self._last_delay
|
|
|
|
# ---- FastDate64 helpers ----
|
|
@staticmethod
|
|
def encode_fastdate64(ts: float) -> int:
|
|
"""
|
|
Encode POSIX timestamp (float seconds) to 64-bit unsigned integer.
|
|
|
|
Uses the optimized FastDate64Encoder for better performance.
|
|
|
|
Args:
|
|
ts: POSIX timestamp in seconds (float)
|
|
|
|
Returns:
|
|
64-bit unsigned integer encoding of the timestamp
|
|
|
|
Raises:
|
|
ValueError: If timestamp is negative
|
|
OverflowError: If timestamp is too large for encoding
|
|
"""
|
|
return FastDate64Encoder.encode(ts)
|
|
|
|
@staticmethod
|
|
def decode_fastdate64(value: int) -> float:
|
|
"""
|
|
Decode 64-bit unsigned integer to POSIX timestamp (float seconds).
|
|
|
|
Uses the optimized FastDate64Encoder for better performance.
|
|
|
|
Args:
|
|
value: 64-bit unsigned integer encoding of timestamp
|
|
|
|
Returns:
|
|
POSIX timestamp in seconds (float)
|
|
"""
|
|
return FastDate64Encoder.decode(value)
|
|
|
|
@staticmethod
|
|
def encode_fastdate(ts: float) -> int:
|
|
"""
|
|
Encode POSIX timestamp to 32-bit integer (FastDate).
|
|
|
|
Based on Ben Joffe's FastDate algorithm.
|
|
Reference: https://www.benjoffe.com/fast-date
|
|
|
|
Uses 22 bits for seconds (supports ~47 days from epoch)
|
|
and 10 bits for fractional seconds (millisecond precision).
|
|
|
|
Args:
|
|
ts: POSIX timestamp in seconds (float)
|
|
|
|
Returns:
|
|
32-bit integer encoding of the timestamp
|
|
|
|
Raises:
|
|
ValueError: If timestamp is negative or too large
|
|
"""
|
|
FASTDATE32_SECONDS_BITS = 22
|
|
FASTDATE32_FRAC_BITS = 10
|
|
FASTDATE32_FRAC_SCALE = 1 << FASTDATE32_FRAC_BITS
|
|
FASTDATE32_SECONDS_MAX = (1 << FASTDATE32_SECONDS_BITS) - 1
|
|
|
|
if ts < 0:
|
|
raise ValueError("Negative timestamps not supported")
|
|
|
|
sec = int(ts)
|
|
frac = int((ts - sec) * FASTDATE32_FRAC_SCALE)
|
|
|
|
if sec > FASTDATE32_SECONDS_MAX:
|
|
raise ValueError(f"Timestamp seconds {sec} too large for {FASTDATE32_SECONDS_BITS}-bit field")
|
|
|
|
return (sec << FASTDATE32_FRAC_BITS) | (frac & (FASTDATE32_FRAC_SCALE - 1))
|
|
|
|
@staticmethod
|
|
def decode_fastdate(value: int) -> float:
|
|
"""
|
|
Decode 32-bit integer to POSIX timestamp (FastDate).
|
|
|
|
Based on Ben Joffe's FastDate algorithm.
|
|
Reference: https://www.benjoffe.com/fast-date
|
|
|
|
Args:
|
|
value: 32-bit integer encoding of timestamp
|
|
|
|
Returns:
|
|
POSIX timestamp in seconds (float)
|
|
"""
|
|
FASTDATE32_FRAC_BITS = 10
|
|
FASTDATE32_FRAC_SCALE = 1 << FASTDATE32_FRAC_BITS
|
|
|
|
frac_mask = FASTDATE32_FRAC_SCALE - 1
|
|
sec = value >> FASTDATE32_FRAC_BITS
|
|
frac = value & frac_mask
|
|
return float(sec) + (float(frac) / FASTDATE32_FRAC_SCALE)
|
|
|
|
@staticmethod
|
|
def encode_safedate(ts: float) -> int:
|
|
"""
|
|
Encode POSIX timestamp to safe 32-bit integer (SafeDate).
|
|
|
|
Based on Ben Joffe's SafeDate algorithm.
|
|
Reference: https://www.benjoffe.com/safe-date
|
|
|
|
Uses offset from year 2024 to support a reasonable range
|
|
with 32-bit integers while maintaining millisecond precision.
|
|
|
|
Args:
|
|
ts: POSIX timestamp in seconds (float)
|
|
|
|
Returns:
|
|
32-bit integer encoding of the timestamp
|
|
|
|
Raises:
|
|
ValueError: If timestamp is outside supported range
|
|
"""
|
|
_SAFEDATE_EPOCH_OFFSET = 2024 # Offset to year 2024
|
|
SAFEDATE_SECONDS_BITS = 22
|
|
SAFEDATE_FRAC_BITS = 10
|
|
SAFEDATE_FRAC_SCALE = 1 << SAFEDATE_FRAC_BITS
|
|
SAFEDATE_SECONDS_MAX = (1 << SAFEDATE_SECONDS_BITS) - 1
|
|
|
|
# Convert to seconds since SAFEDATE_EPOCH_OFFSET
|
|
epoch_2024 = 1704067200 # Approximate Unix timestamp for 2024-01-01
|
|
relative_seconds = int(ts - epoch_2024)
|
|
|
|
if relative_seconds < 0:
|
|
raise ValueError("Timestamp too far in the past (before 2024)")
|
|
if relative_seconds > SAFEDATE_SECONDS_MAX:
|
|
raise ValueError(f"Timestamp too far in the future (exceeds {SAFEDATE_SECONDS_MAX} seconds from 2024)")
|
|
|
|
# Get fractional part
|
|
frac = int((ts - int(ts)) * SAFEDATE_FRAC_SCALE)
|
|
|
|
return (relative_seconds << SAFEDATE_FRAC_BITS) | (frac & (SAFEDATE_FRAC_SCALE - 1))
|
|
|
|
@staticmethod
|
|
def decode_safedate(value: int) -> float:
|
|
"""
|
|
Decode safe 32-bit integer to POSIX timestamp (SafeDate).
|
|
|
|
Based on Ben Joffe's SafeDate algorithm.
|
|
Reference: https://www.benjoffe.com/safe-date
|
|
|
|
Args:
|
|
value: 32-bit integer encoding of timestamp
|
|
|
|
Returns:
|
|
POSIX timestamp in seconds (float)
|
|
"""
|
|
_SAFEDATE_EPOCH_OFFSET = 2024
|
|
SAFEDATE_FRAC_BITS = 10
|
|
SAFEDATE_FRAC_SCALE = 1 << SAFEDATE_FRAC_BITS
|
|
epoch_2024 = 1704067200 # Approximate Unix timestamp for 2024-01-01
|
|
|
|
frac_mask = SAFEDATE_FRAC_SCALE - 1
|
|
relative_seconds = value >> SAFEDATE_FRAC_BITS
|
|
frac = value & frac_mask
|
|
|
|
return epoch_2024 + float(relative_seconds) + (float(frac) / SAFEDATE_FRAC_SCALE)
|
|
|
|
@staticmethod
|
|
def fastdate64_to_iso(value: int) -> str:
|
|
"""
|
|
Convert FastDate64 value to ISO 8601 string.
|
|
|
|
Args:
|
|
value: FastDate64 encoded timestamp
|
|
|
|
Returns:
|
|
ISO 8601 formatted timestamp string
|
|
"""
|
|
ts = time_synchronizationTool.decode_fastdate64(value)
|
|
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
return dt.isoformat(timespec="microseconds")
|
|
|
|
@staticmethod
|
|
def iso_to_fastdate64(iso: str) -> int:
|
|
"""
|
|
Convert ISO 8601 string to FastDate64 value.
|
|
|
|
Args:
|
|
iso: ISO 8601 formatted timestamp string
|
|
|
|
Returns:
|
|
FastDate64 encoded timestamp
|
|
"""
|
|
dt = datetime.fromisoformat(iso)
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
ts = dt.timestamp()
|
|
return time_synchronizationTool.encode_fastdate64(ts)
|
|
|
|
# ---- convenience methods ----
|
|
def get_timestamp(self) -> float:
|
|
"""Get current corrected timestamp (alias for get_corrected_time)."""
|
|
return self.get_corrected_time()
|
|
|
|
def get_timestamp_fast64(self) -> int:
|
|
"""Get current corrected timestamp as FastDate64 (alias for get_corrected_fast64)."""
|
|
return self.get_corrected_fast64()
|
|
|
|
def get_status(self) -> dict:
|
|
"""Get current tool status and statistics."""
|
|
with self._lock:
|
|
return {
|
|
"enabled": self.is_enabled(),
|
|
"network_allowed": self.is_network_allowed(),
|
|
"background_allowed": self.is_background_allowed(),
|
|
"background_running": self._bg_thread is not None and self._bg_thread.is_alive(),
|
|
"base_server_time": self._base_server_time,
|
|
"base_monotonic": self._base_monotonic,
|
|
"smoothed_offset": self._smoothed_offset,
|
|
"last_delay": self._last_delay,
|
|
"servers": self.servers,
|
|
"timeout": self.timeout,
|
|
"attempts": self.attempts,
|
|
"max_acceptable_delay": self.max_acceptable_delay,
|
|
"smoothing_alpha": self.alpha,
|
|
"leap_year_tool_available": self.is_leap_year_tool_available()
|
|
}
|
|
|
|
# ---- leap year integration ----
|
|
def is_leap_year(self, year: int) -> bool:
|
|
"""
|
|
Determine if a year is a leap year using the LeapYear tool or built-in calculation.
|
|
|
|
This method integrates with the LeapYear tool when available for optimal
|
|
performance using Ben Joffe's fast algorithm, with automatic fallback to
|
|
built-in Gregorian calendar calculations.
|
|
|
|
Args:
|
|
year: Year to check (e.g., 2024)
|
|
|
|
Returns:
|
|
True if the year is a leap year, False otherwise
|
|
|
|
Example:
|
|
>>> tool.is_leap_year(2024)
|
|
True
|
|
>>> tool.is_leap_year(2023)
|
|
False
|
|
"""
|
|
return self._leap_year_calculator.is_leap_year(year)
|
|
|
|
def get_days_in_february(self, year: int) -> int:
|
|
"""
|
|
Get the number of days in February for a given year.
|
|
|
|
Args:
|
|
year: Year to check (e.g., 2024)
|
|
|
|
Returns:
|
|
29 if leap year, 28 otherwise
|
|
|
|
Example:
|
|
>>> tool.get_days_in_february(2024)
|
|
29
|
|
>>> tool.get_days_in_february(2023)
|
|
28
|
|
"""
|
|
return self._leap_year_calculator.get_days_in_february(year)
|
|
|
|
def is_leap_year_tool_available(self) -> bool:
|
|
"""
|
|
Check if the LeapYear tool is available and being used for calculations.
|
|
|
|
Returns:
|
|
True if LeapYear tool is loaded and active, False if using built-in calculations
|
|
|
|
Example:
|
|
>>> tool.is_leap_year_tool_available()
|
|
True
|
|
"""
|
|
return self._leap_year_calculator.is_tool_available()
|
|
|
|
def register_tool():
|
|
"""Register the time_synchronization tool."""
|
|
return time_synchronizationTool()
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
tool = time_synchronizationTool()
|
|
sys.exit(tool.run_standalone(sys.argv[1:]))
|