{"id":43075481,"url":"https://github.com/devinslick/lojack_api","last_synced_at":"2026-04-02T13:56:32.159Z","repository":{"id":335427011,"uuid":"1145544606","full_name":"devinslick/lojack_api","owner":"devinslick","description":null,"archived":false,"fork":false,"pushed_at":"2026-03-26T13:03:12.000Z","size":159,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-03-27T00:51:22.313Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/devinslick.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-01-29T23:01:01.000Z","updated_at":"2026-03-26T04:23:09.000Z","dependencies_parsed_at":"2026-02-08T05:01:00.895Z","dependency_job_id":null,"html_url":"https://github.com/devinslick/lojack_api","commit_stats":null,"previous_names":["devinslick/lojack_api"],"tags_count":10,"template":false,"template_full_name":null,"purl":"pkg:github/devinslick/lojack_api","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/devinslick%2Flojack_api","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/devinslick%2Flojack_api/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/devinslick%2Flojack_api/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/devinslick%2Flojack_api/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/devinslick","download_url":"https://codeload.github.com/devinslick/lojack_api/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/devinslick%2Flojack_api/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31307386,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-02T12:59:32.332Z","status":"ssl_error","status_checked_at":"2026-04-02T12:54:48.875Z","response_time":89,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-01-31T14:08:27.839Z","updated_at":"2026-04-02T13:56:32.138Z","avatar_url":"https://github.com/devinslick.png","language":"Python","readme":"# lojack_api\n\nAn async Python client library for the Spireon LoJack API, designed for Home Assistant integrations.\n\n[![Tests](https://github.com/devinslick/lojack_api/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/devinslick/lojack_api/actions/workflows/test.yml)\n[![codecov](https://codecov.io/gh/devinslick/lojack_api/branch/main/graph/badge.svg?token=K97PlD4IU4)](https://codecov.io/gh/devinslick/lojack_api)\n[![PyPI - Downloads](https://img.shields.io/pypi/dm/lojack_api)](https://pypi.org/project/lojack_api/)\n\n## Features\n\n- **Async-first design** - Built with `asyncio` and `aiohttp` for non-blocking I/O\n- **No httpx dependency** - Uses `aiohttp` to avoid version conflicts with Home Assistant\n- **Spireon LoJack API** - Full support for the Spireon identity and services APIs\n- **Session management** - Automatic token refresh and session resumption support\n- **Type hints** - Full typing support with `py.typed` marker\n- **Clean device abstractions** - Device and Vehicle wrappers with convenient methods\n\n## Installation\n\n```bash\n# From the repository\npip install .\n\n# With development dependencies\npip install .[dev]\n```\n\n## Quick Start\n\n### Basic Usage\n\n```python\nimport asyncio\nfrom lojack_api import LoJackClient\n\nasync def main():\n    # Create and authenticate (uses default Spireon URLs)\n    async with await LoJackClient.create(\n        \"your_username\",\n        \"your_password\"\n    ) as client:\n        # List all devices/vehicles\n        devices = await client.list_devices()\n\n        for device in devices:\n            print(f\"Device: {device.name} ({device.id})\")\n\n            # Get current location\n            location = await device.get_location()\n            if location:\n                print(f\"  Location: {location.latitude}, {location.longitude}\")\n\nasyncio.run(main())\n```\n\n### Session Resumption (for Home Assistant)\n\nFor Home Assistant integrations, you can persist authentication across restarts:\n\n```python\nfrom lojack_api import LoJackClient, AuthArtifacts\n\n# First time - login and save auth\nasync def initial_login(username, password):\n    client = await LoJackClient.create(username, password)\n    auth_data = client.export_auth().to_dict()\n    # Save auth_data to Home Assistant storage\n    await client.close()\n    return auth_data\n\n# Later - resume without re-entering password\nasync def resume_session(auth_data, username=None, password=None):\n    auth = AuthArtifacts.from_dict(auth_data)\n    # Pass credentials for auto-refresh if token expires\n    client = await LoJackClient.from_auth(auth, username=username, password=password)\n    return client\n```\n\n### Using External aiohttp Session\n\nFor Home Assistant integrations, pass the shared session:\n\n```python\nfrom aiohttp import ClientSession\nfrom lojack_api import LoJackClient\n\nasync def setup(hass_session: ClientSession, username, password):\n    client = await LoJackClient.create(\n        username,\n        password,\n        session=hass_session  # Won't be closed when client closes\n    )\n    return client\n```\n\n### Working with Vehicles\n\nVehicles have additional properties:\n\n```python\nfrom lojack_api import Vehicle\n\nasync def vehicle_example(client):\n    devices = await client.list_devices()\n\n    for device in devices:\n        if isinstance(device, Vehicle):\n            print(f\"Vehicle: {device.name}\")\n            print(f\"  VIN: {device.vin}\")\n            print(f\"  Make: {device.make} {device.model} ({device.year})\")\n```\n\n### Requesting Fresh Location Data\n\nThe Spireon REST API may return stale location data (30-76+ minutes old) because\ndevices report periodically, not continuously. Two methods are available to\nrequest on-demand location updates:\n\n#### Method Comparison\n\n| Method | Returns | Use Case |\n|--------|---------|----------|\n| `request_location_update()` | `bool` | Fire-and-forget; scripts, simple polling |\n| `request_fresh_location()` | `datetime \\| None` | Non-blocking with baseline; Home Assistant |\n\n#### `request_location_update()` -\u003e bool\n\nSends a \"locate\" command to the device. Returns `True` if the command was\naccepted by the API. This is a fire-and-forget method - you must poll\nseparately to detect when fresh data arrives.\n\n```python\n# Simple usage - send command and poll manually\nsuccess = await device.request_location_update()\nif success:\n    await asyncio.sleep(30)  # Wait for device to respond\n    location = await device.get_location(force=True)\n```\n\n#### `request_fresh_location()` -\u003e datetime | None\n\nSends a \"locate\" command and returns the current location timestamp as a\nbaseline for comparison. This is the recommended method for Home Assistant\nintegrations because it's non-blocking and provides a reference point to\ndetect when fresh data arrives.\n\n```python\nfrom datetime import datetime, timezone\n\n# In a service call or button handler\nbaseline_ts = await device.request_fresh_location()\n\n# Later, in your DataUpdateCoordinator's _async_update_data:\nlocation = await device.get_location(force=True)\nif location and location.timestamp:\n    # Check if we received fresh data since the locate command\n    if baseline_ts and location.timestamp \u003e baseline_ts:\n        print(\"Fresh location received!\")\n    age = (datetime.now(timezone.utc) - location.timestamp).total_seconds()\n```\n\n**Returns:**\n- `datetime` - The location timestamp before the locate command was sent\n- `None` - If no prior location was available\n\n#### Location History\n\n```python\n# Get location history\nasync for location in device.get_history(limit=100):\n    print(f\"{location.timestamp}: {location.latitude}, {location.longitude}\")\n```\n\n#### Troubleshooting Script\n\nFor debugging location freshness issues:\n\n```bash\n# Show current location ages\npython scripts/poll_locations.py\n\n# Request fresh location and monitor for updates\npython scripts/poll_locations.py --locate\n\n# Poll continuously every 30 seconds\npython scripts/poll_locations.py --poll 30\n```\n\n### Geofences\n\nGeofences define circular areas that can trigger alerts when a device enters\nor exits the boundary.\n\n```python\n# List all geofences for a device\ngeofences = await device.list_geofences()\nfor geofence in geofences:\n    print(f\"{geofence.name}: {geofence.latitude}, {geofence.longitude} (r={geofence.radius}m)\")\n\n# Create a geofence\ngeofence = await device.create_geofence(\n    name=\"Home\",\n    latitude=32.8427,\n    longitude=-97.0715,\n    radius=100.0,  # meters\n    address=\"123 Main St\"\n)\n\n# Update a geofence\nawait device.update_geofence(\n    geofence.id,\n    name=\"Home Base\",\n    radius=150.0\n)\n\n# Delete a geofence\nawait device.delete_geofence(geofence.id)\n```\n\n### Updating Device Information\n\nUpdate device/vehicle metadata:\n\n```python\n# Update device name\nawait device.update(name=\"My Tracker\")\n\n# For vehicles, update additional fields\nawait vehicle.update(\n    name=\"Family Car\",\n    odometer=51000.0,\n    color=\"Blue\"\n)\n```\n\n### Maintenance Schedules (Vehicles)\n\nGet maintenance schedule information for vehicles with a VIN:\n\n```python\n# Get maintenance schedule\nschedule = await vehicle.get_maintenance_schedule()\nif schedule:\n    print(f\"Maintenance items for VIN {schedule.vin}:\")\n    for item in schedule.items:\n        print(f\"  {item.name}: {item.severity}\")\n        if item.mileage_due:\n            print(f\"    Due at: {item.mileage_due} miles\")\n        if item.action:\n            print(f\"    Action: {item.action}\")\n```\n\n### Repair Orders (Vehicles)\n\nGet repair order history for vehicles:\n\n```python\n# Get repair orders\norders = await vehicle.get_repair_orders()\nfor order in orders:\n    print(f\"Order {order.id}: {order.status}\")\n    if order.description:\n        print(f\"  Description: {order.description}\")\n    if order.total_amount:\n        print(f\"  Total: ${order.total_amount:.2f}\")\n    if order.open_date:\n        print(f\"  Opened: {order.open_date.isoformat()}\")\n```\n\n### User Information\n\nGet information about the authenticated user and accounts:\n\n```python\n# Get user info\nuser_info = await client.get_user_info()\nif user_info:\n    print(f\"User: {user_info.get('email')}\")\n\n# Get accounts\naccounts = await client.get_accounts()\nprint(f\"Found {len(accounts)} account(s)\")\n```\n\n## API Reference\n\n### LoJackClient\n\nThe main entry point for the API.\n\n```python\n# Factory methods (using default Spireon URLs)\nclient = await LoJackClient.create(username, password)\nclient = await LoJackClient.from_auth(auth_artifacts)\n\n# With custom URLs\nclient = await LoJackClient.create(\n    username,\n    password,\n    identity_url=\"https://identity.spireon.com\",\n    services_url=\"https://services.spireon.com/v0/rest\"\n)\n\n# Properties\nclient.is_authenticated  # bool\nclient.user_id           # Optional[str]\n\n# Methods\ndevices = await client.list_devices()           # List[Device | Vehicle]\ndevice = await client.get_device(device_id)     # Device | Vehicle\nlocations = await client.get_locations(device_id, limit=10)\nsuccess = await client.send_command(device_id, \"locate\")\nauth = client.export_auth()                     # AuthArtifacts\nawait client.close()\n```\n\n### Device\n\nWrapper for tracked devices.\n\n```python\n# Properties\ndevice.id            # str\ndevice.name          # Optional[str]\ndevice.info          # DeviceInfo\ndevice.last_seen     # Optional[datetime]\ndevice.cached_location  # Optional[Location]\n\n# Methods\nawait device.refresh(force=True)              # Refresh cached location from API\nlocation = await device.get_location(force=False)  # Get location (from cache or API)\nasync for loc in device.get_history(limit=100):    # Iterate location history\n    ...\n\n# Location update methods\nsuccess = await device.request_location_update()   # bool - fire-and-forget locate\nbaseline = await device.request_fresh_location()   # datetime|None - locate + baseline\n\n# Device updates\nawait device.update(name=\"New Name\")               # Update device info\n\n# Geofence management\ngeofences = await device.list_geofences()          # List[Geofence]\ngeofence = await device.get_geofence(geofence_id)  # Geofence|None\ngeofence = await device.create_geofence(name=..., latitude=..., longitude=..., radius=...)\nawait device.update_geofence(geofence_id, name=..., radius=...)\nawait device.delete_geofence(geofence_id)\n\n# Properties\ndevice.location_timestamp  # datetime|None - cached location's timestamp\n```\n\n### Vehicle (extends Device)\n\nAdditional properties and methods for vehicles.\n\n```python\n# Properties\nvehicle.vin           # Optional[str]\nvehicle.make          # Optional[str]\nvehicle.model         # Optional[str]\nvehicle.year          # Optional[int]\nvehicle.license_plate # Optional[str]\nvehicle.odometer      # Optional[float]\n\n# Methods (extends Device methods)\nawait vehicle.update(name=..., make=..., model=..., year=..., vin=..., odometer=...)\n\n# Maintenance and repair methods\nschedule = await vehicle.get_maintenance_schedule()  # MaintenanceSchedule|None\norders = await vehicle.get_repair_orders()           # List[RepairOrder]\n```\n\n### Data Models\n\n```python\nfrom lojack_api import Location, DeviceInfo, VehicleInfo\n\n# Location - Core fields\nlocation.latitude   # Optional[float]\nlocation.longitude  # Optional[float]\nlocation.timestamp  # Optional[datetime]\nlocation.accuracy   # Optional[float] - GPS accuracy in METERS (for HA gps_accuracy)\nlocation.speed      # Optional[float]\nlocation.heading    # Optional[float]\nlocation.address    # Optional[str]\n\n# Note on accuracy: The API may return HDOP values or quality strings.\n# These are automatically converted to meters for Home Assistant compatibility:\n# - HDOP values (1-15) are multiplied by 5 to get approximate meters\n# - Quality strings (\"GOOD\", \"POOR\", etc.) are mapped to reasonable meter values\n# - Values \u003e 15 are assumed to already be in meters\n\n# Location - Extended telemetry (from events)\nlocation.odometer        # Optional[float] - Vehicle odometer reading\nlocation.battery_voltage # Optional[float] - Battery voltage\nlocation.engine_hours    # Optional[float] - Engine hours\nlocation.distance_driven # Optional[float] - Total distance driven\nlocation.signal_strength # Optional[float] - Signal strength (0.0 to 1.0)\nlocation.gps_fix_quality # Optional[str] - GPS quality (e.g., \"GOOD\", \"POOR\")\nlocation.event_type      # Optional[str] - Event type (e.g., \"SLEEP_ENTER\")\nlocation.event_id        # Optional[str] - Unique event identifier\n\n# Location - Raw data\nlocation.raw        # Dict[str, Any] - Original API response\n\n# Geofence\nfrom lojack_api import Geofence\n\ngeofence.id         # str - Unique identifier\ngeofence.name       # Optional[str] - Display name\ngeofence.latitude   # Optional[float] - Center latitude\ngeofence.longitude  # Optional[float] - Center longitude\ngeofence.radius     # Optional[float] - Radius in meters\ngeofence.address    # Optional[str] - Address description\ngeofence.active     # bool - Whether geofence is active\ngeofence.asset_id   # Optional[str] - Associated device ID\ngeofence.raw        # Dict[str, Any] - Original API response\n\n# Maintenance models\nfrom lojack_api import MaintenanceItem, MaintenanceSchedule\n\n# MaintenanceItem - Single service item\nitem.name           # str - Service name (e.g., \"Oil Change\")\nitem.description    # Optional[str] - Detailed description\nitem.severity       # Optional[str] - Severity level (\"NORMAL\", \"WARNING\", \"CRITICAL\")\nitem.mileage_due    # Optional[float] - Mileage at which service is due\nitem.months_due     # Optional[int] - Months until service is due\nitem.action         # Optional[str] - Recommended action\nitem.raw            # Dict[str, Any] - Original API response\n\n# MaintenanceSchedule - Collection of maintenance items\nschedule.vin        # str - Vehicle VIN\nschedule.items      # List[MaintenanceItem] - Scheduled services\nschedule.raw        # Dict[str, Any] - Original API response\n\n# RepairOrder - Service/repair record\nfrom lojack_api import RepairOrder\n\norder.id            # str - Unique repair order identifier\norder.vin           # Optional[str] - Vehicle VIN\norder.asset_id      # Optional[str] - Associated asset ID\norder.status        # Optional[str] - Order status (\"OPEN\", \"CLOSED\")\norder.open_date     # Optional[datetime] - When order was opened\norder.close_date    # Optional[datetime] - When order was closed\norder.description   # Optional[str] - Description of repair\norder.total_amount  # Optional[float] - Total cost\norder.raw           # Dict[str, Any] - Original API response\n```\n\n### Exceptions\n\n```python\nfrom lojack_api import (\n    LoJackError,           # Base exception\n    AuthenticationError,   # 401 errors, invalid credentials\n    AuthorizationError,    # 403 errors, permission denied\n    ApiError,              # Other API errors (has status_code)\n    ConnectionError,       # Network connectivity issues\n    TimeoutError,          # Request timeouts\n    DeviceNotFoundError,   # Device not found (has device_id)\n    CommandError,          # Command failed (has command, device_id)\n)\n```\n\n### Spireon API Details\n\nThe library uses the Spireon LoJack API:\n\n- **Identity Service**: `https://identity.spireon.com` - For authentication\n- **Services API**: `https://services.spireon.com/v0/rest` - For device/asset management\n\nAuthentication uses HTTP Basic Auth with the following headers:\n- `X-Nspire-Apptoken` - Application token\n- `X-Nspire-Correlationid` - Unique request ID\n- `X-Nspire-Usertoken` - User token (after authentication)\n\n## Development\n\n```bash\n# Install dev dependencies\npip install .[dev]\n\n# Run tests\npytest\n\n# Run tests with coverage\npytest --cov=lojack_api\n\n# Type checking\nmypy lojack_api\n\n# Linting\n# Preferred: ruff for quick fixes\nruff check .\n\n# Use flake8 for strict style checks (reports shown in CI)\n# Match ruff's line length setting\nflake8 lojack_api/ tests/ --count --show-source --statistics --max-line-length=100\n```\n\n## License\n\nMIT License - see [LICENSE](LICENSE) for details.\n\n## Contributing\n\nContributions are welcome! This library is designed to be vendored into Home Assistant integrations to avoid dependency conflicts.\n\n## Credits\n\nThis library was inspired by the original [lojack-clients](https://github.com/scorgn/lojack-clients) package and uses the Spireon LoJack API endpoints.\n","funding_links":[],"categories":[],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdevinslick%2Flojack_api","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdevinslick%2Flojack_api","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdevinslick%2Flojack_api/lists"}