Metadata-Version: 2.4
Name: airpinpoint
Version: 0.1.0
Summary: Official Python SDK for the AirPinpoint asset tracking API
Author-email: AirPinpoint <support@airpinpoint.com>
License-Expression: MIT
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx>=0.25.0
Requires-Dist: pydantic>=2.0.0
Provides-Extra: dev
Requires-Dist: mypy>=1.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: respx>=0.20; extra == 'dev'
Requires-Dist: ruff>=0.1; extra == 'dev'
Description-Content-Type: text/markdown

# AirPinpoint Python SDK

Official Python SDK for the [AirPinpoint](https://airpinpoint.com) asset tracking API. Track Apple AirTags and Find My compatible devices with type-safe, production-grade Python.

## Installation

```bash
pip install airpinpoint
```

Requires Python 3.9+.

## Quick Start

```python
from airpinpoint import AirPinpoint

# Reads AIRPINPOINT_API_KEY from environment, or pass it directly
client = AirPinpoint(api_key="your-api-key")

# List all trackables
page = client.trackables.list()
for trackable in page.data:
    print(f"{trackable.name}: {trackable.enabled}")
```

## Authentication

Get your API key from the [AirPinpoint Dashboard](https://airpinpoint.com/dashboard/settings) under API Keys.

```python
# Option 1: Pass directly
client = AirPinpoint(api_key="your-api-key")

# Option 2: Environment variable
# export AIRPINPOINT_API_KEY=your-api-key
client = AirPinpoint()
```

## Usage

### Trackables

```python
# List all trackables
page = client.trackables.list()

# Get a single trackable
trackable = client.trackables.retrieve("trackable-id")

# Get current location
location = client.trackables.current_location("trackable-id")
print(f"Lat: {location.latitude}, Lng: {location.longitude}")

# Get location history
from datetime import datetime
history = client.trackables.location_history(
    "trackable-id",
    start_time=datetime(2026, 1, 1),
    end_time=datetime(2026, 4, 1),
    limit=100,
)
for loc in history.data:
    print(f"{loc.timestamp}: ({loc.latitude}, {loc.longitude})")

# Battery info
battery = client.trackables.battery("trackable-id")
print(f"Battery: {battery.battery_percentage}%")

# Reset battery tracking
client.trackables.reset_battery("trackable-id", battery_months=12)
```

### Auto-Pagination

All list methods return paginated results. Iterate directly to auto-paginate:

```python
# Auto-paginate through all trackables
for trackable in client.trackables.list():
    print(trackable.name)

# Or access the first page manually
page = client.trackables.list(limit=10)
print(page.data)       # list of items
print(page.has_more)   # True if more pages exist
```

### Geofences

```python
# List geofences
geofences = client.geofences.list()

# Create a geofence
geofence = client.geofences.create(
    name="Office",
    latitude=37.7749,
    longitude=-122.4194,
    radius=500,
    trackable_id=["trackable-id-1", "trackable-id-2"],
    webhook_enabled=True,
    webhook_secret="your-secret",
)

# Update (only sends fields you provide)
from airpinpoint import NOT_GIVEN
updated = client.geofences.update(
    "geofence-id",
    name="New Office",
    radius=1000,
)

# Delete
client.geofences.delete("geofence-id")

# Test webhook
result = client.geofences.test_webhook("geofence-id", event_type="entry")
print(result.success)

# List webhook deliveries
deliveries = client.geofences.webhooks.list("geofence-id")
for d in deliveries.data:
    print(f"{d.event_type}: {'OK' if d.successful else 'FAILED'}")

# Get delivery detail
detail = client.geofences.webhooks.retrieve("geofence-id", "delivery-id")
print(detail.payload)
```

### Share Links

```python
link = client.share_links.create(trackable_id="trackable-id", hours=24)
print(link.share_url)
```

### Account & Usage

```python
user = client.account.retrieve()
print(f"Logged in as: {user.email}")

usage = client.usage.retrieve(start_date="2026-01-01", end_date="2026-04-01")
total = client.usage.total()
```

## Async Client

Every method has an async equivalent:

```python
import asyncio
from airpinpoint import AsyncAirPinpoint

async def main():
    async with AsyncAirPinpoint(api_key="your-api-key") as client:
        # All the same methods, just awaited
        trackables = await client.trackables.list()
        for t in trackables.data:
            print(t.name)

        # Async auto-pagination
        async for trackable in await client.trackables.list():
            print(trackable.name)

        location = await client.trackables.current_location("trackable-id")
        print(f"{location.latitude}, {location.longitude}")

asyncio.run(main())
```

## Webhook Verification

Verify incoming webhooks from AirPinpoint:

```python
from airpinpoint import Webhook, WebhookSignatureError

try:
    event = Webhook.construct_event(
        payload=request.body,                            # raw request body
        signature=request.headers["X-AirPinpoint-Signature"],
        secret="your-webhook-secret",
    )
    print(f"Event: {event.event}")  # "geofence.entry" or "geofence.exit"
    print(f"Beacon: {event.beacon}")
    print(f"Location: {event.location}")
except WebhookSignatureError as e:
    print(f"Invalid signature: {e}")
```

## Utility Functions

Location-aware helper functions for distance calculation, path analysis, geofencing, and data formatting.

```python
from airpinpoint import utils

# Distance and bearing between two locations
d = utils.distance(location_a, location_b)         # meters
b = utils.bearing(location_a, location_b)           # degrees [0, 360)

# Geofence checks
inside = utils.is_inside_geofence(location, geofence)
dist = utils.distance_to_geofence(location, geofence)

# Polygon containment (ray-casting)
inside = utils.is_inside_polygon(location, polygon_vertices)

# Speed between timestamped locations
s = utils.speed(loc1, loc2)                         # m/s

# Path analysis
stops = utils.detect_stops(locations, radius_m=50, min_duration_ms=300_000)
trips = utils.detect_trips(locations)
dwell = utils.dwell_time(locations, geofence)       # milliseconds
simplified = utils.simplify_path(locations, tolerance_m=10)
clusters = utils.cluster_locations(locations, radius_m=100)

# GeoJSON export
geojson = utils.to_geojson(locations)               # FeatureCollection
point = utils.to_geojson_point(location)            # Feature<Point>
line = utils.to_geojson_line_string(locations)       # Feature<LineString>

# Google Encoded Polyline
encoded = utils.encode_polyline(locations)
decoded = utils.decode_polyline(encoded)             # list of (lat, lng)

# Filter noisy data
clean = utils.filter_by_accuracy(locations, max_accuracy_m=100)
clean = utils.filter_by_speed(locations, max_speed_mps=100)
clean = utils.filter_stale(locations, max_age_ms=3_600_000)
clean = utils.deduplicate(locations, distance_threshold_m=1, time_threshold_ms=1000)

# Battery estimation
days = utils.estimate_battery_days_remaining(battery_info)
status = utils.battery_health_status(battery_info)  # "good" | "low" | "critical" | "replace"
```

All utility functions accept duck-typed objects. Anything with `.latitude` and `.longitude` attributes works, including SDK types like `LocationBase` and `GeofenceBase`.

## Error Handling

```python
from airpinpoint import (
    AirPinpoint,
    AirPinpointError,
    AuthenticationError,
    NotFoundError,
    RateLimitError,
    ValidationError,
)

client = AirPinpoint(api_key="your-api-key")

try:
    trackable = client.trackables.retrieve("invalid-id")
except NotFoundError as e:
    print(f"Not found: {e.message}")
except AuthenticationError:
    print("Invalid API key")
except RateLimitError:
    print("Too many requests, slow down")
except ValidationError as e:
    print(f"Bad request: {e.message}")
except AirPinpointError as e:
    print(f"SDK error: {e}")
```

Error hierarchy:

```
AirPinpointError
  APIError (status_code, message, body, headers)
    AuthenticationError (401)
    NotFoundError (404)
    ValidationError (400)
    RateLimitError (429)
    InternalError (500+)
  APIConnectionError
  WebhookSignatureError
```

## Configuration

```python
import httpx

client = AirPinpoint(
    api_key="your-api-key",
    base_url="https://api.airpinpoint.com/v1",  # default
    timeout=httpx.Timeout(connect=5, read=30, write=30, pool=30),
    max_retries=2,                                # retries on 429/5xx
    default_headers={"X-Custom": "value"},
)
```

## Context Manager

Both clients support context managers for clean resource management:

```python
# Sync
with AirPinpoint(api_key="...") as client:
    trackables = client.trackables.list()

# Async
async with AsyncAirPinpoint(api_key="...") as client:
    trackables = await client.trackables.list()
```
