
Tracking Pettracer Location
- 85 installs
- 3 repo stars
- Updated June 29, 2026
- tristanmanchester/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
tracking-pettracer-location is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- tracking-pettracer-location
- AI & Agent Building
- AI-coding skill
Tracking Pettracer Location by the numbers
- 85 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #5,069 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tristanmanchester/agent-skills --skill tracking-pettracer-locationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 85 |
|---|---|
| repo stars | ★ 3 |
| Last updated | June 29, 2026 |
| Repository | tristanmanchester/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Tracking PetTracer pet location
Why this exists
PetTracer exposes an (unofficial) web portal API used by their apps/website. This skill gives a reliable, low-drama workflow for:
- Current location (latest known point)
- Recent route/history (time-windowed points)
- Near-real-time updates (WebSocket push, optional)
It is designed to minimise API load (be respectful) and to produce consistent, copy/paste-friendly outputs.
Quick start
Snapshot location (recommended default)
1. Set credentials (prefer env vars, not CLI args):
export PETTRACER_USERNAME="you@example.com"
export PETTRACER_PASSWORD="••••••••"2. List devices:
python scripts/pettracer_cli.py list --format json --pretty3. Locate a pet:
- By name:
python scripts/pettracer_cli.py locate --pet "Fluffy" --format json --pretty- By id:
python scripts/pettracer_cli.py locate --device-id 12345 --format json --pretty- If the account has exactly one collar, you can omit
--pet/--device-id:
python scripts/pettracer_cli.py locate --format json --prettyGet location history (last 6 hours)
python scripts/pettracer_cli.py history --pet "Fluffy" --hours 6 --format json --prettyCore workflow the agent should follow
1) Decide: snapshot vs history vs live updates
- Snapshot: user asks “where is X right now?” → use
locate. - History: user asks “where has X been today/last hour?” → use
history. - Live: user wants continuous updates → use WebSocket (see references/websocket.md).
Default to snapshot unless the user explicitly wants a route or live tracking.
2) Authenticate safely
Preferred order: 1. Use PETTRACER_TOKEN if already available. 2. Else login with PETTRACER_USERNAME + PETTRACER_PASSWORD (or PETTRACER_EMAIL).
Never ask the user to paste tokens into chat. Ask them to set env vars or store secrets in their vault.
Optional overrides (useful for debugging / future-proofing):
PETTRACER_API_BASE(REST base; defaulthttps://portal.pettracer.com/api)PETTRACER_WS_BASE(WebSocket base; defaultwss://pt.pettracer.com/sc)
3) Identify the right device
- Fetch devices via
GET /api/map/getccs(wrapped bypettracer_cli.py list). - Match by
details.namecase-insensitively. - If multiple matches: show a disambiguation list (id + name) and ask the user which one.
- If no match: show available device names.
- If the account has exactly one collar, you can default to it.
4) Fetch location data
- Current location comes from
device.lastPos(collars) or top-levelposLat/posLong(HomeStations): posLat,posLongtimeMeasure(timestamp)acc(accuracy, metres) orhoriPrec(fallback)- History uses
POST /api/map/getccpositionswith: devId,filterTime(ms),toTime(ms)
See references/endpoints.md and references/data-model.md.
5) Present results consistently
When reporting location, include:
- Pet name + device id
- Coordinates (lat, lon)
- Last update time
- Accuracy (if present)
- How old the fix is (seconds/minutes since last fix), if possible
- Optional: a map link (Google Maps + OpenStreetMap)
Preferred JSON shape (for tool-to-tool handoff):
{
"pet": { "id": 12345, "name": "Fluffy" },
"last_fix": {
"lat": 48.137154,
"lon": 11.576124,
"time": "2026-02-25T12:34:56+00:00",
"accuracy_m": 12
},
"last_fix_age_s": 90,
"battery_mv": 4012,
"battery_percent_est": 78,
"home": false,
"links": {
"google_maps": "https://www.google.com/maps?q=48.137154,11.576124",
"openstreetmap": "https://www.openstreetmap.org/?mlat=48.137154&mlon=11.576124#map=18/48.137154/11.576124"
}
}Notes:
battery_percent_estis an estimate derived from voltage (PetTracer reports millivolts, not %).- If there’s no GPS fix, report
error=no_recent_fixand includelast_contact.
Live tracking (optional, avoid aggressive polling)
If you need frequent updates:
- Prefer WebSocket push (avoid aggressive polling).
- Only fall back to polling if WebSocket is not possible; keep polling ≥ 60s by default.
Install dependency:
pip install aiohttpThen run:
python scripts/pettracer_watch.py --pet "Fluffy"See:
- references/websocket.md
scripts/pettracer_watch.pyfor a working SockJS/STOMP implementation.
Troubleshooting playbook
No location / lastPos is missing
Common reasons:
- Collar hasn’t reported a GPS fix recently (indoors, low signal).
- Battery low / collar off.
- Subscription expired.
Action:
- Report “no recent fix” and show
lastContactif available. - Suggest switching to a higher-frequency mode (Fast/Live) in the PetTracer app/portal only if the user asks (see references/modes.md).
Auth failures (401 / invalid_auth)
- Re-login to obtain a fresh
access_token. - Confirm the login payload uses keys
login+password(notusername).
Rate limiting / service respect
- Avoid tight loops against
/map/getccs. - Prefer WebSocket for near-real-time tracking.
THE EXACT PROMPT — Location response format
Use this when the user wants a human-readable answer:
Give the pet’s latest known PetTracer location.
Include:
- Pet name + device id
- Time of last fix (and last contact if different)
- Coordinates + map link(s)
- Accuracy (metres) if present
- One-line assessment: “recent fix” vs “stale fix” (use last_fix_age_s if available; interpret in the context of the current tracking mode)PetTracer data model (fields the agent cares about)
The API returns JSON dicts. The exact shape can evolve, but these fields are consistently useful for location tracking.
Device (collar) object
Common top-level keys:
id(int): device idtype(int, optional): device type0= collar (often omitted)1= HomeStationdetails(dict):name(str): pet name (primary label for matching)bat(int): battery in mV (not %)lastContact(str): last time the device communicated (ISO-like string, often with+0000)home(bool): whether PetTracer considers the pet “home” (if present)mode(int): tracking mode id (if present)
Location fields
For collars, the latest GPS fix is typically in:
lastPos(dict)
For HomeStations, location fields may appear at the top level:
posLat,posLong
lastPos / position object
posLat(float): latitudeposLong(float): longitudetimeMeasure(str): time of fixtimeDb(str): time stored server-sideacc(int): accuracy in metres (if present)horiPrec(int): horizontal precision (some devices report this instead ofacc)sat(int): satellite countrssi(int): signal strength (format varies)fixS,fixP,flags(ints): quality/status flags
“No fix” scenarios
If lastPos is missing or has null-ish fields, report:
- “No recent GPS fix”
- include
lastContactif available
PetTracer portal API endpoints (quick reference)
These endpoints are derived from reverse engineering the PetTracer web portal and public community clients.
Treat them as unofficial and be respectful with request frequency.
Base URLs
- REST base:
https://portal.pettracer.com/api - WebSocket base (SockJS):
wss://pt.pettracer.com/sc
Authentication
Login
POST /user/login
JSON body:
{ "login": "you@example.com", "password": "••••••••" }Response includes an access_token (bearer token).
Auth header for all other requests
Authorization: Bearer <access_token>Devices
List collars (and their latest fix)
GET /map/getccs
Returns a JSON list. Each item typically includes:
iddetails.namebat(battery, millivolts)lastContactlastPos(lat/lon/time/accuracy/etc)
Get single device info (optional)
POST /map/getccinfo
JSON body:
{ "devId": 12345 }Location history
Get position records for a time window
POST /map/getccpositions
JSON body:
{ "devId": 12345, "filterTime": 1767152926491, "toTime": 1767174526491 }filterTimeandtoTimeare Unix epoch milliseconds.- Response is typically a JSON list of position objects (similar to
lastPos).
HomeStations (optional)
List HomeStations
GET /user/gethomestations
HomeStations are not required for collar tracking, but some accounts include them.
Tracking modes and “stale fix” expectations
How “fresh” a PetTracer GPS fix should be depends heavily on the collar’s mode. PetTracer may add hidden modes, and naming can vary across collar generations.
This file is best-effort guidance based on public community clients and observed portal behaviour. Treat values as heuristics, not guarantees.
Common user-facing modes
| Mode name | Mode id | Notes |
|---|---|---|
| Slow | 3 | Low power / less frequent fixes. |
| Slow+ | 7 | Variant; behaviour can differ by collar generation. |
| Normal | 2 | Default-ish mode. |
| Normal+ | 14 | Variant; often “more frequent” than Normal. |
| Fast | 1 | More frequent contact than Normal. |
| Fast+ | 8 | Variant; often “more frequent” than Fast. |
| Live | 11 | Often behaves like a search/live tracking mode. |
“Expected” age of last_fix
When deciding whether a fix is “stale”, prefer: 1. last_fix_age_s (seconds since last fix) 2. Current mode_id/mode_name (if present) 3. User context (“is the cat likely moving outdoors right now?”)
A reasonable heuristic:
- Live (11): a fix older than a couple of minutes is likely stale.
- Fast/Fast+: tens of minutes may still be expected.
- Normal/Normal+: an hour or two may be expected.
- Slow/Slow+: hours to a day may be expected.
If you need more precision, check the official portal/app for the user’s collar model and settings.
Extra/hidden modes
Some accounts/devices may show additional internal mode ids. If you see an unknown mode_id, report it as numeric and avoid guessing.
PetTracer WebSocket (SockJS + STOMP) notes
Use this only when you need near-real-time updates. For one-off “where is my pet?” requests, prefer the REST snapshot (/map/getccs) to keep things simple.
Endpoint
Base URL (SockJS):
wss://pt.pettracer.com/sc
The web portal uses a derived URL of the form:
wss://pt.pettracer.com/sc/<server_id>/<session_id>/websocket?access_token=<token>Where:
server_idis a random 3-digit string (000–999)session_idis a random 8-char[a-z0-9]stringaccess_tokenis the bearer token fromPOST /user/login
Security note
The token appears in the URL query string. Never print full URLs to logs or chat output. If you must log connection details, redact access_token.
Protocol layers
1) SockJS framing from server:
oopen framehheartbeata[...]array of string messagesc[...]close frame
2) STOMP inside the SockJS message array
Minimal connection flow
1. Connect WebSocket to the SockJS URL (includes access_token). 2. On SockJS open (o), send a STOMP CONNECT frame. 3. After STOMP CONNECTED, send SUBSCRIBE frames to:
/user/queue/messages/user/queue/portal
4. Send a SEND frame to /app/subscribe with body:
{"deviceIds":[12345,67890]}5. Handle MESSAGE frames; the body is typically JSON containing updates keyed by device id.
Implementation reference:
scripts/pettracer_watch.py
#!/usr/bin/env python3
"""pettracer_cli.py — small CLI helper for PetTracer location lookups.
Designed for automation/agents:
- Reads secrets from env vars (preferred)
- Produces stable JSON for downstream tool use
- Emits structured JSON errors when --format json
Environment variables (preferred):
- PETTRACER_TOKEN (bearer token; skips login)
- PETTRACER_USERNAME or PETTRACER_EMAIL
- PETTRACER_PASSWORD
- PETTRACER_API_BASE (optional; default: https://portal.pettracer.com/api)
Exit codes:
- 0: success
- 1: generic error
- 2: invalid arguments / missing prerequisites
- 3: device/pet not found
- 4: authentication/authorisation error
- 5: timeout
"""
from __future__ import annotations
import argparse
import json
import math
import os
import random
import socket
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
DEFAULT_API_BASE_URL = "https://portal.pettracer.com/api"
API_BASE_URL = os.getenv("PETTRACER_API_BASE", DEFAULT_API_BASE_URL).rstrip("/")
LOGIN_ENDPOINT = "/user/login"
GET_CCS_ENDPOINT = "/map/getccs"
GET_POSITIONS_ENDPOINT = "/map/getccpositions"
DEFAULT_TIMEOUT_S = 20
DEFAULT_RETRIES = 2
USER_AGENT = "pettracer-agent-skill/0.3"
class ExitCode:
OK = 0
ERROR = 1
INVALID_ARGS = 2
NOT_FOUND = 3
AUTH = 4
TIMEOUT = 5
@dataclass(frozen=True)
class PetSelection:
device_id: int
name: str
class PetTracerApiError(RuntimeError):
pass
class PetTracerAuthError(PetTracerApiError):
pass
class PetTracerNotFoundError(PetTracerApiError):
pass
# Common user-facing mode ids seen in the portal API. Values outside this map are emitted as raw ints.
MODE_NAME_BY_ID: dict[int, str] = {
3: "Slow",
7: "Slow+",
2: "Normal",
14: "Normal+",
1: "Fast",
8: "Fast+",
11: "Live", # sometimes referred to as "Search" mode
}
def _json_dumps(obj: Any, pretty: bool) -> str:
if pretty:
return json.dumps(obj, indent=2, sort_keys=True, ensure_ascii=False)
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
def _parse_dt_api(s: Optional[str]) -> Optional[datetime]:
"""Parse PetTracer timestamps.
Examples seen in the wild:
- 2026-02-25T12:34:56.310+0000
- 2026-02-25T12:34:56+0000
- 2026-02-25T12:34:56Z
"""
if not s or not isinstance(s, str):
return None
# Common patterns first
for fmt in ("%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S%z"):
try:
return datetime.strptime(s, fmt)
except ValueError:
pass
# Fallback to ISO 8601-ish parsing
try:
s2 = s.strip()
if s2.endswith("Z"):
s2 = s2[:-1] + "+00:00"
# Convert +0000 to +00:00 for fromisoformat
if len(s2) >= 5 and (s2[-5] in "+-") and s2[-2:].isdigit() and s2[-4:-2].isdigit() and s2[-3] != ":":
s2 = s2[:-2] + ":" + s2[-2:]
dt = datetime.fromisoformat(s2)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except Exception:
return None
def _iso(dt: Optional[datetime]) -> Optional[str]:
return dt.isoformat() if dt else None
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Great-circle distance in metres."""
r = 6371000.0
phi1 = math.radians(lat1)
phi2 = math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi / 2.0) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2.0) ** 2
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return r * c
def _battery_mv_to_percent(mv: Any) -> Optional[int]:
"""Estimate collar battery percent from millivolts.
This is an approximation (the official API reports mV). The piecewise mapping mirrors
public community mappings.
"""
if mv is None:
return None
try:
e = int(mv)
except (TypeError, ValueError):
return None
e = max(3000, min(e, 4150))
if e >= 4000:
t = (e - 4000) / 150 * 17 + 83
elif e >= 3900:
t = (e - 3900) / 100 * 16 + 67
elif e >= 3840:
t = (e - 3840) / 60 * 17 + 50
elif e >= 3760:
t = (e - 3760) / 80 * 16 + 34
elif e >= 3600:
t = (e - 3600) / 160 * 17 + 17
else:
t = 0
return int(round(t))
def _rssi_to_dbm(rssi: Any) -> Optional[float]:
"""Best-effort conversion of RSSI to dBm.
Some integrations treat PetTracer's RSSI as a raw 0–255 value.
If the value already looks like dBm (negative), return it unchanged.
"""
if rssi is None:
return None
try:
v = int(rssi)
except (TypeError, ValueError):
return None
if v < 0:
return float(v)
# Formula used by at least one HA integration to match the portal.
return (255 & v) / 2.0 - 130.0
def _dbm_to_percent(dbm: Optional[float]) -> Optional[int]:
if dbm is None:
return None
try:
# Formula used by at least one HA integration.
pct = 100.0 * 1.35 * (1.0 - (dbm / -130.0))
return int(round(max(0.0, min(100.0, pct))))
except Exception:
return None
def _env_first(*names: str) -> Optional[str]:
for n in names:
v = os.getenv(n)
if v:
return v
return None
def _retry_delay_s(attempt: int, *, retry_after_s: Optional[int]) -> float:
if retry_after_s is not None:
return float(max(0, min(60, retry_after_s)))
# Exponential backoff with jitter; capped to avoid long sleeps.
base = 0.5 * (2**attempt)
jitter = random.uniform(0.0, 0.25)
return float(min(10.0, base + jitter))
def _request(
method: str,
endpoint_or_url: str,
*,
token: Optional[str] = None,
json_body: Optional[Dict[str, Any]] = None,
timeout_s: int = DEFAULT_TIMEOUT_S,
retries: int = DEFAULT_RETRIES,
) -> Any:
"""Make a JSON request to the PetTracer REST API (stdlib-only).
Retries are deliberately conservative (defaults to 2) and only kick in on:
- transient HTTP errors (429, 5xx)
- network errors / timeouts
Auth errors (401/403) are not retried.
"""
url = endpoint_or_url
if endpoint_or_url.startswith("/"):
url = f"{API_BASE_URL}{endpoint_or_url}"
headers = {
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"Accept-Language": "en-GB,en-US;q=0.9,en;q=0.8",
}
if token:
headers["Authorization"] = f"Bearer {token}"
data: Optional[bytes] = None
if json_body is not None:
data = json.dumps(json_body, separators=(",", ":")).encode("utf-8")
headers["Content-Length"] = str(len(data))
req = Request(url, data=data, headers=headers, method=method.upper())
last_timeout: Optional[BaseException] = None
last_err: Optional[BaseException] = None
retries = max(0, int(retries))
for attempt in range(retries + 1):
try:
with urlopen(req, timeout=timeout_s) as resp:
raw = resp.read().decode("utf-8")
try:
return json.loads(raw)
except json.JSONDecodeError as e:
raise PetTracerApiError(f"Non-JSON response from {url}: {e}") from e
except HTTPError as e:
body = ""
try:
body = e.read().decode("utf-8")
except Exception:
pass
if e.code in (401, 403):
raise PetTracerAuthError(f"HTTP {e.code} {e.reason}: {body}".strip())
# Retry on server errors / rate limiting
if e.code in (429, 500, 502, 503, 504) and attempt < retries:
retry_after: Optional[int] = None
try:
ra = e.headers.get("Retry-After")
if ra:
retry_after = int(float(str(ra).strip()))
except Exception:
retry_after = None
time.sleep(_retry_delay_s(attempt, retry_after_s=retry_after))
continue
raise PetTracerApiError(f"HTTP {e.code} {e.reason}: {body}".strip())
except (socket.timeout, TimeoutError) as e:
last_timeout = e
if attempt < retries:
time.sleep(_retry_delay_s(attempt, retry_after_s=None))
continue
raise TimeoutError(str(e))
except URLError as e:
reason = getattr(e, "reason", None)
if isinstance(reason, (socket.timeout, TimeoutError)):
last_timeout = e
if attempt < retries:
time.sleep(_retry_delay_s(attempt, retry_after_s=None))
continue
raise TimeoutError(str(e))
last_err = e
if attempt < retries:
time.sleep(_retry_delay_s(attempt, retry_after_s=None))
continue
raise PetTracerApiError(f"Network error: {e}") from e
except Exception as e:
last_err = e
if attempt < retries:
time.sleep(_retry_delay_s(attempt, retry_after_s=None))
continue
raise
# Defensive fallback (should be unreachable)
if last_timeout is not None:
raise TimeoutError(str(last_timeout))
if last_err is not None:
raise PetTracerApiError(str(last_err))
raise PetTracerApiError("Unknown request failure")
def get_token_or_login(*, username: Optional[str], password: Optional[str], timeout_s: int, retries: int) -> str:
"""Return bearer token from env or by logging in."""
token = _env_first("PETTRACER_TOKEN")
if token:
return token
username = username or _env_first("PETTRACER_USERNAME", "PETTRACER_EMAIL")
password = password or _env_first("PETTRACER_PASSWORD")
if not username or not password:
raise PetTracerAuthError(
"Missing credentials. Set PETTRACER_TOKEN or (PETTRACER_USERNAME/PETTRACER_EMAIL + PETTRACER_PASSWORD)."
)
payload = {"login": username, "password": password}
resp = _request("POST", LOGIN_ENDPOINT, json_body=payload, timeout_s=timeout_s, retries=retries)
if not isinstance(resp, dict):
raise PetTracerAuthError("Login response was not a JSON object.")
token = resp.get("access_token") or resp.get("token") or resp.get("id_token")
if not token:
raise PetTracerAuthError("Login response did not contain an access token.")
return str(token)
def fetch_devices(*, token: str, timeout_s: int, retries: int) -> List[Dict[str, Any]]:
resp = _request("GET", GET_CCS_ENDPOINT, token=token, timeout_s=timeout_s, retries=retries)
if not isinstance(resp, list):
raise PetTracerApiError("Unexpected response from /map/getccs (expected a JSON list).")
return resp
def _device_type(device: Dict[str, Any]) -> int:
t = device.get("type")
try:
return int(t)
except Exception:
# Collars typically omit type or use 0; home stations use 1.
return 0
def _device_type_name(device_type: int) -> str:
if device_type == 1:
return "homestation"
return "collar"
def _device_name(device: Dict[str, Any]) -> str:
details = device.get("details") or {}
name = details.get("name")
if isinstance(name, str) and name.strip():
return name.strip()
dev_id = device.get("id")
if _device_type(device) == 1:
return f"HomeStation {dev_id}"
return f"Pet {dev_id}"
def select_device(
devices: List[Dict[str, Any]],
*,
device_id: Optional[int],
pet_name: Optional[str],
) -> PetSelection:
"""Choose a device.
Selection rules (in order):
1) If --device-id provided, use it.
2) If --pet provided, match by details.name (case-insensitive). If no exact match,
try a single substring match.
3) If neither is provided and there is exactly one *collar* on the account, pick it.
"""
if device_id is not None:
for d in devices:
try:
if int(d.get("id", -1)) == int(device_id):
return PetSelection(device_id=int(device_id), name=_device_name(d))
except Exception:
continue
raise PetTracerNotFoundError(
f"No device found with id={device_id}. Available ids: {[d.get('id') for d in devices]}"
)
if pet_name:
key = pet_name.casefold().strip()
exact: List[Tuple[int, str]] = []
for d in devices:
name = _device_name(d)
if name.casefold() == key:
exact.append((int(d.get("id")), name))
if len(exact) == 1:
dev_id, name = exact[0]
return PetSelection(device_id=dev_id, name=name)
if len(exact) > 1:
raise PetTracerNotFoundError(
f"Multiple devices matched name={pet_name!r}. Disambiguate by id. Matches: {exact}"
)
# Substring match (safe only if unique)
contains: List[Tuple[int, str]] = []
for d in devices:
name = _device_name(d)
if key and key in name.casefold():
contains.append((int(d.get("id")), name))
if len(contains) == 1:
dev_id, name = contains[0]
return PetSelection(device_id=dev_id, name=name)
if len(contains) > 1:
raise PetTracerNotFoundError(
f"Multiple devices partially matched name={pet_name!r}. Disambiguate by id. Matches: {contains}"
)
raise PetTracerNotFoundError(
f"No device found with name={pet_name!r}. Available: {[ _device_name(d) for d in devices ]}"
)
collars = [d for d in devices if _device_type(d) == 0]
if len(collars) == 1:
d = collars[0]
return PetSelection(device_id=int(d.get("id")), name=_device_name(d))
if len(devices) == 1:
d = devices[0]
return PetSelection(device_id=int(d.get("id")), name=_device_name(d))
raise PetTracerNotFoundError(
"Multiple devices on this account. Provide --pet or --device-id. "
f"Available: {[ (d.get('id'), _device_name(d)) for d in devices ]}"
)
def _latest_position(device: Dict[str, Any]) -> Tuple[Dict[str, Any], str]:
"""Return a position-like dict plus a source label.
Collars typically store latest fix in device['lastPos'].
Home stations may have posLat/posLong at the top level.
"""
last_pos = device.get("lastPos")
if isinstance(last_pos, dict):
lat = last_pos.get("posLat")
lon = last_pos.get("posLong")
if lat is not None and lon is not None:
return last_pos, "lastPos"
lat = device.get("posLat")
lon = device.get("posLong")
if lat is not None and lon is not None:
# Build a minimal position dict matching the collar shape.
return {
"posLat": lat,
"posLong": lon,
"timeMeasure": device.get("timeMeasure") or device.get("timeDb") or device.get("lastContact"),
"timeDb": device.get("timeDb"),
"acc": device.get("acc"),
"horiPrec": device.get("horiPrec"),
"sat": device.get("sat"),
"rssi": device.get("rssi"),
}, "top_level"
return {}, "none"
def _summarise_device(device: Dict[str, Any], *, now: datetime) -> Dict[str, Any]:
pos, pos_source = _latest_position(device)
last_contact_dt = _parse_dt_api(device.get("lastContact"))
last_fix_dt = _parse_dt_api(pos.get("timeMeasure")) or _parse_dt_api(pos.get("timeDb"))
lat = pos.get("posLat")
lon = pos.get("posLong")
# Accuracy can be acc or horiPrec (HA integration uses fallback)
accuracy = pos.get("acc") if pos.get("acc") is not None else pos.get("horiPrec")
rssi_raw = pos.get("rssi")
rssi_dbm = _rssi_to_dbm(rssi_raw)
rssi_pct = _dbm_to_percent(rssi_dbm)
mode_id = device.get("mode")
try:
mode_id_int = int(mode_id) if mode_id is not None else None
except Exception:
mode_id_int = None
out: Dict[str, Any] = {
"id": device.get("id"),
"type": _device_type(device),
"type_name": _device_type_name(_device_type(device)),
"name": _device_name(device),
"battery_mv": device.get("bat"),
"battery_percent_est": _battery_mv_to_percent(device.get("bat")),
"home": device.get("home"),
"mode_id": mode_id_int,
"mode_name": MODE_NAME_BY_ID.get(mode_id_int) if mode_id_int is not None else None,
"last_contact": _iso(last_contact_dt) or device.get("lastContact"),
"last_contact_age_s": int((now - last_contact_dt).total_seconds()) if last_contact_dt else None,
"last_fix_time": _iso(last_fix_dt) or pos.get("timeMeasure") or pos.get("timeDb"),
"last_fix_age_s": int((now - last_fix_dt).total_seconds()) if last_fix_dt else None,
"position_source": pos_source,
"last_fix": {
"lat": float(lat) if lat is not None else None,
"lon": float(lon) if lon is not None else None,
"accuracy_m": accuracy,
"sat": pos.get("sat"),
"rssi_raw": rssi_raw,
"rssi_dbm": rssi_dbm,
"rssi_percent": rssi_pct,
},
}
return out
def _emit(payload: Any, args: argparse.Namespace) -> None:
if args.format == "json":
print(_json_dumps(payload, args.pretty))
else:
# minimal human output
if isinstance(payload, dict) and "devices" in payload:
for d in payload["devices"]:
fix = d.get("last_fix", {})
print(
f"{d.get('id')}: {d.get('name')} "
f"last_fix={d.get('last_fix_time')} "
f"bat={d.get('battery_percent_est')}%/{d.get('battery_mv')}mV "
f"lat={fix.get('lat')} lon={fix.get('lon')}"
)
else:
print(payload)
def _emit_error(
args: argparse.Namespace,
*,
error_type: str,
message: str,
details: Optional[Dict[str, Any]] = None,
) -> None:
if getattr(args, "format", "json") == "json":
err: Dict[str, Any] = {"error": {"type": error_type, "message": message}}
if details:
err["error"]["details"] = details
print(_json_dumps(err, getattr(args, "pretty", False)))
else:
print(f"{error_type}: {message}", file=sys.stderr)
if details:
print(details, file=sys.stderr)
def cmd_list(args: argparse.Namespace) -> int:
token = get_token_or_login(username=args.username, password=args.password, timeout_s=args.timeout_s, retries=args.retries)
devices = fetch_devices(token=token, timeout_s=args.timeout_s, retries=args.retries)
now = datetime.now(timezone.utc)
payload = {"devices": [_summarise_device(d, now=now) for d in devices]}
_emit(payload, args)
return ExitCode.OK
def cmd_locate(args: argparse.Namespace) -> int:
token = get_token_or_login(username=args.username, password=args.password, timeout_s=args.timeout_s, retries=args.retries)
devices = fetch_devices(token=token, timeout_s=args.timeout_s, retries=args.retries)
sel = select_device(devices, device_id=args.device_id, pet_name=args.pet)
device = next(d for d in devices if int(d.get("id")) == sel.device_id)
now = datetime.now(timezone.utc)
summary = _summarise_device(device, now=now)
pos = summary.get("last_fix", {})
lat = pos.get("lat")
lon = pos.get("lon")
# Staleness heuristic (for “recent vs stale” answers). Defaults can be overridden.
stale_after_s = int(float(args.stale_after_min) * 60)
fix_age_s = summary.get("last_fix_age_s")
is_fix_stale = (fix_age_s is not None) and (int(fix_age_s) > stale_after_s)
if lat is None or lon is None:
payload = {
"pet": {"id": sel.device_id, "name": sel.name},
"device_type": summary.get("type_name"),
"error": "no_recent_fix",
"last_contact": summary.get("last_contact"),
"last_contact_age_s": summary.get("last_contact_age_s"),
"battery_mv": summary.get("battery_mv"),
"battery_percent_est": summary.get("battery_percent_est"),
"mode_id": summary.get("mode_id"),
"mode_name": summary.get("mode_name"),
"stale_after_s": stale_after_s,
}
_emit(payload, args)
return ExitCode.OK
distance_m: Optional[float] = None
inside_geofence: Optional[bool] = None
if args.home_lat is not None and args.home_lon is not None:
distance_m = _haversine_m(float(lat), float(lon), float(args.home_lat), float(args.home_lon))
if args.geofence_radius_m is not None:
inside_geofence = distance_m <= float(args.geofence_radius_m)
payload = {
"pet": {"id": sel.device_id, "name": sel.name},
"device_type": summary.get("type_name"),
"last_fix": {
"lat": float(lat),
"lon": float(lon),
"time": summary.get("last_fix_time"),
"accuracy_m": pos.get("accuracy_m"),
"sat": pos.get("sat"),
"rssi_raw": pos.get("rssi_raw"),
"rssi_dbm": pos.get("rssi_dbm"),
"rssi_percent": pos.get("rssi_percent"),
},
"last_fix_age_s": summary.get("last_fix_age_s"),
"stale_after_s": stale_after_s,
"is_fix_stale": is_fix_stale,
"last_contact": summary.get("last_contact"),
"last_contact_age_s": summary.get("last_contact_age_s"),
"battery_mv": summary.get("battery_mv"),
"battery_percent_est": summary.get("battery_percent_est"),
"home": summary.get("home"),
"mode_id": summary.get("mode_id"),
"mode_name": summary.get("mode_name"),
"distance_to_home_m": distance_m,
"inside_geofence": inside_geofence,
"position_source": summary.get("position_source"),
"links": {
"google_maps": f"https://www.google.com/maps?q={lat},{lon}",
"openstreetmap": f"https://www.openstreetmap.org/?mlat={lat}&mlon={lon}#map=18/{lat}/{lon}",
},
}
_emit(payload, args)
return ExitCode.OK
def _parse_ms(value: str) -> int:
try:
return int(value)
except ValueError as e:
raise argparse.ArgumentTypeError("must be an integer (epoch ms)") from e
def cmd_history(args: argparse.Namespace) -> int:
token = get_token_or_login(username=args.username, password=args.password, timeout_s=args.timeout_s, retries=args.retries)
devices = fetch_devices(token=token, timeout_s=args.timeout_s, retries=args.retries)
sel = select_device(devices, device_id=args.device_id, pet_name=args.pet)
# Time window
if args.from_ms is not None and args.to_ms is not None:
filter_ms = args.from_ms
to_ms = args.to_ms
else:
now = datetime.now(timezone.utc)
to_ms = int(now.timestamp() * 1000)
filter_ms = int((now - timedelta(hours=float(args.hours))).timestamp() * 1000)
body = {"devId": sel.device_id, "filterTime": filter_ms, "toTime": to_ms}
resp = _request("POST", GET_POSITIONS_ENDPOINT, token=token, json_body=body, timeout_s=args.timeout_s, retries=args.retries)
if not isinstance(resp, list):
raise PetTracerApiError("Unexpected response from /map/getccpositions (expected a JSON list).")
positions: List[Dict[str, Any]] = []
for item in resp:
if not isinstance(item, dict):
continue
t = _parse_dt_api(item.get("timeMeasure")) or _parse_dt_api(item.get("timeDb"))
lat = item.get("posLat")
lon = item.get("posLong")
accuracy = item.get("acc") if item.get("acc") is not None else item.get("horiPrec")
rssi_raw = item.get("rssi")
rssi_dbm = _rssi_to_dbm(rssi_raw)
rssi_pct = _dbm_to_percent(rssi_dbm)
positions.append(
{
"lat": float(lat) if lat is not None else None,
"lon": float(lon) if lon is not None else None,
"time": _iso(t) or item.get("timeMeasure") or item.get("timeDb"),
"accuracy_m": accuracy,
"sat": item.get("sat"),
"rssi_raw": rssi_raw,
"rssi_dbm": rssi_dbm,
"rssi_percent": rssi_pct,
}
)
if args.limit is not None:
positions = positions[: int(args.limit)]
payload = {
"pet": {"id": sel.device_id, "name": sel.name},
"window": {"from_ms": filter_ms, "to_ms": to_ms},
"count": len(positions),
"positions": positions,
}
_emit(payload, args)
return ExitCode.OK
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="pettracer_cli.py", add_help=True)
# Global options.
#
# NOTE: These are intentionally duplicated on each subcommand (via common_global) so callers can
# place flags either before or after the subcommand, e.g.:
# pettracer_cli.py list --format json --pretty
# pettracer_cli.py --format json --pretty list
p.add_argument("--timeout-s", type=int, default=DEFAULT_TIMEOUT_S, help="Request timeout in seconds.")
p.add_argument(
"--retries",
type=int,
default=int(os.getenv("PETTRACER_RETRIES", str(DEFAULT_RETRIES))),
help=f"Retries for transient failures (default: {DEFAULT_RETRIES}). Set 0 to disable.",
)
p.add_argument("--username", help="PetTracer login (prefer env var PETTRACER_USERNAME).")
p.add_argument("--password", help="PetTracer password (prefer env var PETTRACER_PASSWORD).")
p.add_argument("--format", choices=["json", "text"], default="json", help="Output format.")
p.add_argument("--pretty", action="store_true", help="Pretty-print JSON output.")
# Subcommand-shared options (duplicated here to allow flags after the command).
common_global = argparse.ArgumentParser(add_help=False)
common_global.add_argument("--timeout-s", type=int, default=DEFAULT_TIMEOUT_S)
common_global.add_argument(
"--retries",
type=int,
default=int(os.getenv("PETTRACER_RETRIES", str(DEFAULT_RETRIES))),
)
common_global.add_argument("--username")
common_global.add_argument("--password")
common_global.add_argument("--format", choices=["json", "text"], default="json")
common_global.add_argument("--pretty", action="store_true")
sub = p.add_subparsers(dest="cmd", required=True)
p_list = sub.add_parser("list", parents=[common_global], help="List devices visible to the account.")
p_list.set_defaults(func=cmd_list)
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--device-id", type=int, help="PetTracer device id.")
common.add_argument("--pet", help="Pet name (matches device.details.name).")
p_loc = sub.add_parser(
"locate",
parents=[common_global, common],
help="Get latest known location for a pet/device.",
)
p_loc.add_argument("--home-lat", type=float, help="Optional home latitude for distance/geofence.")
p_loc.add_argument("--home-lon", type=float, help="Optional home longitude for distance/geofence.")
p_loc.add_argument("--geofence-radius-m", type=float, help="Optional radius in metres to compute inside_geofence.")
p_loc.add_argument(
"--stale-after-min",
type=float,
default=15.0,
help="Consider fixes older than this as stale (default: 15 minutes).",
)
p_loc.set_defaults(func=cmd_locate)
p_hist = sub.add_parser("history", parents=[common_global, common], help="Get location history for a time window.")
p_hist.add_argument("--hours", type=float, default=6.0, help="History window in hours (default: 6).")
p_hist.add_argument("--from-ms", type=_parse_ms, help="Start time (epoch ms). Use with --to-ms.")
p_hist.add_argument("--to-ms", type=_parse_ms, help="End time (epoch ms). Use with --from-ms.")
p_hist.add_argument("--limit", type=int, help="Limit number of returned points (after API response).")
p_hist.set_defaults(func=cmd_history)
return p
def main() -> int:
p = build_parser()
args = p.parse_args()
# Basic arg validation
if args.cmd == "history" and ((args.from_ms is None) ^ (args.to_ms is None)):
_emit_error(args, error_type="invalid_args", message="--from-ms and --to-ms must be used together")
return ExitCode.INVALID_ARGS
try:
return int(args.func(args))
except PetTracerNotFoundError as e:
_emit_error(args, error_type="not_found", message=str(e))
return ExitCode.NOT_FOUND
except PetTracerAuthError as e:
_emit_error(args, error_type="auth", message=str(e))
return ExitCode.AUTH
except TimeoutError as e:
_emit_error(args, error_type="timeout", message=str(e))
return ExitCode.TIMEOUT
except PetTracerApiError as e:
_emit_error(args, error_type="api_error", message=str(e))
return ExitCode.ERROR
except KeyboardInterrupt:
_emit_error(args, error_type="interrupted", message="Interrupted")
return ExitCode.ERROR
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""pettracer_watch.py — live update stream for PetTracer (SockJS + STOMP).
This implements the same SockJS/STOMP flow used by the PetTracer web portal:
- Connects to the PetTracer SockJS WebSocket endpoint
- Speaks STOMP to subscribe to updates
- Prints newline-delimited JSON (NDJSON) messages to stdout
Requires:
pip install aiohttp
Environment variables (preferred):
- PETTRACER_TOKEN (bearer token; skips login)
- PETTRACER_USERNAME or PETTRACER_EMAIL
- PETTRACER_PASSWORD
- PETTRACER_API_BASE (optional; default: https://portal.pettracer.com/api)
- PETTRACER_WS_BASE (optional; default: wss://pt.pettracer.com/sc)
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import random
import string
import sys
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
DEFAULT_API_BASE_URL = "https://portal.pettracer.com/api"
API_BASE_URL = os.getenv("PETTRACER_API_BASE", DEFAULT_API_BASE_URL).rstrip("/")
LOGIN_ENDPOINT = "/user/login"
GET_CCS_ENDPOINT = "/map/getccs"
DEFAULT_TIMEOUT_S = 20
DEFAULT_WS_BASE = os.getenv("PETTRACER_WS_BASE", "wss://pt.pettracer.com/sc").rstrip("/")
USER_AGENT = "pettracer-agent-skill/0.3"
def _env_first(*names: str) -> Optional[str]:
for n in names:
v = os.getenv(n)
if v:
return v
return None
def _request_json(
method: str,
endpoint: str,
*,
token: Optional[str] = None,
json_body: Optional[dict] = None,
timeout_s: int = DEFAULT_TIMEOUT_S,
) -> Any:
url = f"{API_BASE_URL}{endpoint}"
headers = {
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"Accept-Language": "en-GB,en-US;q=0.9,en;q=0.8",
}
if token:
headers["Authorization"] = f"Bearer {token}"
data: Optional[bytes] = None
if json_body is not None:
data = json.dumps(json_body, separators=(",", ":")).encode("utf-8")
headers["Content-Length"] = str(len(data))
req = Request(url, data=data, headers=headers, method=method.upper())
try:
with urlopen(req, timeout=timeout_s) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw)
except HTTPError as e:
body = ""
try:
body = e.read().decode("utf-8")
except Exception:
pass
raise RuntimeError(f"HTTP {e.code} {e.reason}: {body}".strip())
except URLError as e:
raise RuntimeError(f"Network error: {e}") from e
def get_token_or_login(*, username: Optional[str], password: Optional[str], timeout_s: int) -> str:
token = _env_first("PETTRACER_TOKEN")
if token:
return token
username = username or _env_first("PETTRACER_USERNAME", "PETTRACER_EMAIL")
password = password or _env_first("PETTRACER_PASSWORD")
if not username or not password:
raise RuntimeError(
"Missing credentials. Set PETTRACER_TOKEN or (PETTRACER_USERNAME/PETTRACER_EMAIL + PETTRACER_PASSWORD)."
)
resp = _request_json("POST", LOGIN_ENDPOINT, json_body={"login": username, "password": password}, timeout_s=timeout_s)
if not isinstance(resp, dict):
raise RuntimeError("Login response was not a JSON object.")
token = resp.get("access_token") or resp.get("token") or resp.get("id_token")
if not token:
raise RuntimeError("Login response did not contain an access token.")
return str(token)
def fetch_devices(*, token: str, timeout_s: int) -> List[Dict[str, Any]]:
resp = _request_json("GET", GET_CCS_ENDPOINT, token=token, timeout_s=timeout_s)
if not isinstance(resp, list):
raise RuntimeError("Unexpected response from /map/getccs (expected a JSON list).")
return resp
def _device_name(device: Dict[str, Any]) -> str:
details = device.get("details") or {}
name = details.get("name")
if isinstance(name, str) and name.strip():
return name.strip()
return f"Pet {device.get('id')}"
def resolve_device_ids(devices: List[Dict[str, Any]], *, device_ids: List[int], pet_name: Optional[str]) -> List[int]:
if device_ids:
return device_ids
if pet_name:
key = pet_name.casefold().strip()
exact = [int(d.get("id")) for d in devices if _device_name(d).casefold() == key]
if len(exact) == 1:
return exact
if len(exact) > 1:
raise RuntimeError(f"Multiple devices matched name={pet_name!r}. Use --device-id. Matches: {exact}")
contains = [int(d.get("id")) for d in devices if key and key in _device_name(d).casefold()]
if len(contains) == 1:
return contains
if len(contains) > 1:
raise RuntimeError(f"Multiple devices partially matched name={pet_name!r}. Use --device-id. Matches: {contains}")
raise RuntimeError(f"No device found with name={pet_name!r}. Available: {[ _device_name(d) for d in devices ]}")
# If no pet selection provided, subscribe to the single collar if there is exactly one.
if len(devices) == 1:
return [int(devices[0].get("id"))]
raise RuntimeError("Must provide --device-id (one or more) or --pet")
def _rand_session_id() -> str:
return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(8))
def _rand_server_id() -> str:
return f"{random.randint(0, 999):03d}"
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _extract_location(msg: Dict[str, Any]) -> Optional[Dict[str, Any]]:
# Some messages may include lastPos dict; others might include posLat/posLong directly
last_pos = msg.get("lastPos")
if isinstance(last_pos, dict):
lat = last_pos.get("posLat")
lon = last_pos.get("posLong")
if lat is not None and lon is not None:
try:
lat_f = float(lat)
lon_f = float(lon)
except Exception:
return None
return {
"lat": lat_f,
"lon": lon_f,
"time": last_pos.get("timeMeasure") or last_pos.get("timeDb"),
"accuracy_m": last_pos.get("acc") if last_pos.get("acc") is not None else last_pos.get("horiPrec"),
}
lat = msg.get("posLat")
lon = msg.get("posLong")
if lat is not None and lon is not None:
try:
lat_f = float(lat)
lon_f = float(lon)
except Exception:
return None
return {
"lat": lat_f,
"lon": lon_f,
"time": msg.get("timeMeasure") or msg.get("timeDb") or msg.get("lastContact"),
"accuracy_m": msg.get("acc") if msg.get("acc") is not None else msg.get("horiPrec"),
}
return None
class SockJsStompClient:
def __init__(self, *, ws_base: str, token: str, device_ids: List[int], verbose: bool = False) -> None:
self.ws_base = ws_base.rstrip("/")
self.token = token
self.device_ids = [int(x) for x in device_ids]
self.verbose = verbose
self._running = True
# Lazily imported dependency
try:
import aiohttp # noqa: F401
except Exception as e:
raise RuntimeError("aiohttp is required. Install with: pip install aiohttp") from e
async def run_forever(self) -> None:
import aiohttp
backoff_s = 10
backoff_max_s = 60
while self._running:
session_id = _rand_session_id()
server_id = _rand_server_id()
url = f"{self.ws_base}/{server_id}/{session_id}/websocket?access_token={self.token}"
if self.verbose:
print(f"[pettracer_watch] connecting: {url}", file=sys.stderr)
try:
async with aiohttp.ClientSession() as session:
async with session.ws_connect(url, heartbeat=30) as ws:
if self.verbose:
print("[pettracer_watch] websocket connected", file=sys.stderr)
# Reset backoff after a successful connection.
backoff_s = 10
async for msg in ws:
if not self._running:
break
if msg.type == aiohttp.WSMsgType.TEXT:
await self._handle_sockjs_frame(ws, msg.data)
elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED):
if self.verbose:
print(f"[pettracer_watch] websocket closed/error: {msg.type}", file=sys.stderr)
break
except asyncio.CancelledError:
break
except Exception as e:
print(f"[pettracer_watch] connection error: {e}", file=sys.stderr)
if self._running:
if self.verbose:
print(f"[pettracer_watch] reconnecting in {backoff_s}s", file=sys.stderr)
await asyncio.sleep(backoff_s)
backoff_s = min(backoff_max_s, int(backoff_s * 2))
def stop(self) -> None:
self._running = False
async def _handle_sockjs_frame(self, ws, data: str) -> None:
if not data:
return
frame_type = data[0]
if frame_type == "o":
# Open frame → send STOMP CONNECT
await self._send_stomp_connect(ws)
return
if frame_type == "h":
# Heartbeat
return
if frame_type == "a":
# Array of messages
try:
messages = json.loads(data[1:])
if isinstance(messages, list):
for m in messages:
if isinstance(m, str):
await self._handle_stomp_message(ws, m)
except json.JSONDecodeError:
if self.verbose:
print(f"[pettracer_watch] failed to decode sockjs: {data[:200]}", file=sys.stderr)
return
if frame_type == "c":
# Close frame
if self.verbose:
print(f"[pettracer_watch] sockjs close: {data}", file=sys.stderr)
return
async def _send_sockjs(self, ws, message: str) -> None:
# Client → server SockJS WebSocket transport sends a JSON array of strings.
frame = json.dumps([message], separators=(",", ":"))
await ws.send_str(frame)
async def _send_stomp_connect(self, ws) -> None:
connect_frame = (
"CONNECT\n"
"accept-version:1.1,1.0\n"
"heart-beat:10000,10000\n"
"\n"
"\u0000"
)
await self._send_sockjs(ws, connect_frame)
async def _send_stomp_subscribe(self, ws) -> None:
# Subscribe to queues
sub0 = "SUBSCRIBE\nid:sub-0\ndestination:/user/queue/messages\n\n\u0000"
sub1 = "SUBSCRIBE\nid:sub-1\ndestination:/user/queue/portal\n\n\u0000"
await self._send_sockjs(ws, sub0)
await self._send_sockjs(ws, sub1)
# Subscribe to specific devices
if self.device_ids:
payload = json.dumps({"deviceIds": self.device_ids}, separators=(",", ":"))
send_frame = (
"SEND\n"
"destination:/app/subscribe\n"
f"content-length:{len(payload)}\n"
"\n"
f"{payload}"
"\u0000"
)
await self._send_sockjs(ws, send_frame)
# Start heartbeats (client side)
asyncio.create_task(self._heartbeat_sender(ws))
async def _heartbeat_sender(self, ws) -> None:
# STOMP heartbeat is just newline in a SockJS array frame
try:
while self._running and not ws.closed:
await asyncio.sleep(9)
await ws.send_str(json.dumps(["\n"], separators=(",", ":")))
except Exception:
return
async def _handle_stomp_message(self, ws, msg: str) -> None:
# STOMP heartbeats are newlines
if not msg or msg in ("\n", "\r\n"):
return
# Messages may contain multiple STOMP frames separated by NULL bytes
frames = msg.split("\u0000")
for frame in frames:
frame = frame.strip()
if not frame:
continue
if frame.startswith("CONNECTED"):
if self.verbose:
print("[pettracer_watch] STOMP CONNECTED", file=sys.stderr)
await self._send_stomp_subscribe(ws)
continue
if frame.startswith("MESSAGE"):
body_start = frame.find("\n\n")
if body_start == -1:
continue
body = frame[body_start + 2 :]
try:
payload = json.loads(body)
except json.JSONDecodeError:
payload = {"_raw": body}
if isinstance(payload, dict):
dev_id = payload.get("id")
try:
dev_id_int = int(dev_id) if dev_id is not None else None
except Exception:
dev_id_int = None
# Even though we subscribe to specific deviceIds, be defensive and filter.
if self.device_ids and dev_id_int is not None and dev_id_int not in self.device_ids:
continue
out = {
"received_at": _now_iso(),
"device_id": dev_id_int,
"location": _extract_location(payload),
"message": payload,
}
else:
out = {"received_at": _now_iso(), "message": payload}
print(json.dumps(out, separators=(",", ":"), ensure_ascii=False))
sys.stdout.flush()
continue
# Other STOMP frames are ignored (ERROR, RECEIPT, etc)
if self.verbose:
print(f"[pettracer_watch] other STOMP frame: {frame[:40]}...", file=sys.stderr)
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="pettracer_watch.py")
p.add_argument("--timeout-s", type=int, default=DEFAULT_TIMEOUT_S)
p.add_argument("--ws-base", default=DEFAULT_WS_BASE, help="SockJS WS base (default: PetTracer).")
p.add_argument("--username", help="PetTracer login (prefer env var PETTRACER_USERNAME).")
p.add_argument("--password", help="PetTracer password (prefer env var PETTRACER_PASSWORD).")
p.add_argument("--device-id", action="append", type=int, default=[], help="Device id to subscribe to (repeatable).")
p.add_argument("--pet", help="Pet name (matches device.details.name).")
p.add_argument("--verbose", action="store_true")
return p
async def _amain() -> int:
args = build_parser().parse_args()
token = get_token_or_login(username=args.username, password=args.password, timeout_s=args.timeout_s)
devices = fetch_devices(token=token, timeout_s=args.timeout_s)
device_ids = resolve_device_ids(devices, device_ids=args.device_id, pet_name=args.pet)
if args.verbose:
names = {int(d.get("id")): _device_name(d) for d in devices}
print(f"[pettracer_watch] subscribing to: {[ (i, names.get(i)) for i in device_ids ]}", file=sys.stderr)
client = SockJsStompClient(ws_base=args.ws_base, token=token, device_ids=device_ids, verbose=args.verbose)
try:
await client.run_forever()
except KeyboardInterrupt:
client.stop()
return 0
def main() -> int:
try:
return asyncio.run(_amain())
except Exception as e:
print(str(e), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
aiohttp>=3.8