"""Timezone helper for the Reachy Mini Time Tool Space: offline compute, keyless geo-IP for local time.""" import json import logging from datetime import datetime from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from functools import lru_cache from ipaddress import ip_address from urllib.error import URLError, HTTPError from urllib.parse import quote from urllib.request import Request, urlopen import gradio as gr logger = logging.getLogger(__name__) IPWHOIS_URL = "https://ipwho.is" TIMEOUT_S = 3.0 USER_AGENT = "reachy-mini-time-tool/2.0 (+https://huggingface.co/spaces/pollen-robotics/reachy-mini-time-tool)" CLIENT_IP_HEADERS = ( "cf-connecting-ip", "x-real-ip", "x-forwarded-for", "x-client-ip", "fastly-client-ip", ) UTC = ZoneInfo("UTC") def get_time( timezone: str = "", compare_timezone: str = "", request: gr.Request | None = None, ) -> dict[str, object]: """Return the current time for an IANA timezone (empty resolves the caller's local zone), with an optional difference.""" if not isinstance(timezone, str) or not isinstance(compare_timezone, str): return {"error": "timezone and compare_timezone must be strings"} base_name = timezone.strip() if base_name: base_zone = _load_timezone(base_name) if base_zone is None: return {"error": f"unknown timezone '{base_name}'; pass an IANA name like 'Europe/Paris'"} else: base_name, base_zone = _local_timezone(request) logger.info("get_time timezone=%s compare=%s", base_name, compare_timezone.strip()) now_utc = datetime.now(UTC) base_now = now_utc.astimezone(base_zone) result = _format_time(base_now, base_name) compare_name = compare_timezone.strip() if compare_name: compare_zone = _load_timezone(compare_name) if compare_zone is None: return {"error": f"unknown timezone '{compare_name}'; pass an IANA name like 'Asia/Tokyo'"} compare_now = now_utc.astimezone(compare_zone) difference_minutes = _offset_minutes(compare_now) - _offset_minutes(base_now) result["compare"] = _format_time(compare_now, compare_name) result["time_difference_minutes"] = difference_minutes result["time_difference_hours"] = difference_minutes / 60 result["time_difference_summary"] = _difference_summary(compare_name, base_name, difference_minutes) return result def _local_timezone(request: gr.Request | None) -> tuple[str, ZoneInfo]: """Resolve the caller's local timezone by keyless geo-IP, falling back to UTC when it cannot be determined.""" caller_ip = _caller_ip(request) if caller_ip is None: logger.warning("caller public IP was not provided; local time falls back to UTC") return "UTC", UTC name = _timezone_for_ip(caller_ip) zone = _load_timezone(name) if name else None if zone is None: return "UTC", UTC return name, zone @lru_cache(maxsize=1024) def _timezone_for_ip(caller_ip: str) -> str | None: """Look up an IANA timezone for a public IP via keyless ipwho.is, cached per IP to stay under the shared quota.""" url = f"{IPWHOIS_URL}/{quote(caller_ip, safe='.:')}?fields=success,timezone" try: request = Request(url, headers={"Accept": "application/json", "User-Agent": USER_AGENT}) with urlopen(request, timeout=TIMEOUT_S) as response: payload = json.loads(response.read().decode("utf-8")) except (HTTPError, URLError, TimeoutError, ValueError) as exc: logger.warning("geo-IP timezone lookup failed for %s: %s", caller_ip, exc) return None if not payload.get("success", True): logger.warning("geo-IP lookup unsuccessful for %s: %s", caller_ip, payload.get("message")) return None timezone = payload.get("timezone") if not isinstance(timezone, dict): return None return str(timezone.get("id", "")).strip() or None def _caller_ip(request: gr.Request | None) -> str | None: if request is None: return None headers = getattr(request, "headers", {}) or {} for header_name in CLIENT_IP_HEADERS: header_value = headers.get(header_name) if isinstance(header_value, str): public_ip = _first_public_ip(header_value) if public_ip is not None: return public_ip host = getattr(getattr(request, "client", None), "host", None) return _first_public_ip(host) if isinstance(host, str) else None def _first_public_ip(value: str) -> str | None: for raw_candidate in value.split(","): candidate = raw_candidate.strip() if candidate.count(":") == 1: candidate = candidate.rsplit(":", maxsplit=1)[0] try: parsed_ip = ip_address(candidate) except ValueError: continue if not ( parsed_ip.is_private or parsed_ip.is_loopback or parsed_ip.is_link_local or parsed_ip.is_multicast or parsed_ip.is_unspecified ): return str(parsed_ip) return None def _load_timezone(name: str) -> ZoneInfo | None: try: return ZoneInfo(name) except (ZoneInfoNotFoundError, ValueError): return None def _format_time(now: datetime, name: str) -> dict[str, object]: return { "iso": now.isoformat(timespec="seconds"), "date": now.strftime("%Y-%m-%d"), "time": now.strftime("%H:%M"), "weekday": now.strftime("%A"), "timezone": name, "utc_offset_minutes": _offset_minutes(now), "summary": now.strftime("%A, %d %B %Y, %H:%M"), } def _offset_minutes(now: datetime) -> int: offset = now.utcoffset() return int(offset.total_seconds() // 60) if offset else 0 def _difference_summary(compared_name: str, base_name: str, difference_minutes: int) -> str: if difference_minutes == 0: return f"{compared_name} has the same current UTC offset as {base_name}." direction = "ahead of" if difference_minutes > 0 else "behind" hours, remaining_minutes = divmod(abs(difference_minutes), 60) duration = f"{hours} hour{'s' if hours != 1 else ''}" if remaining_minutes: duration = f"{duration} {remaining_minutes} minute{'s' if remaining_minutes != 1 else ''}" return f"{compared_name} is {duration} {direction} {base_name}."