pycitizen 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pycitizen-0.1.0/.github/workflows/ci.yml +22 -0
- pycitizen-0.1.0/.github/workflows/publish.yml +47 -0
- pycitizen-0.1.0/.gitignore +17 -0
- pycitizen-0.1.0/LICENSE +21 -0
- pycitizen-0.1.0/PKG-INFO +193 -0
- pycitizen-0.1.0/README.md +165 -0
- pycitizen-0.1.0/SDK_REPORT.md +182 -0
- pycitizen-0.1.0/examples/README.md +21 -0
- pycitizen-0.1.0/examples/feed_watcher.py +46 -0
- pycitizen-0.1.0/examples/map_layers.py +57 -0
- pycitizen-0.1.0/examples/misc_public.py +50 -0
- pycitizen-0.1.0/examples/nearby_incidents.py +90 -0
- pycitizen-0.1.0/examples/news_and_trends.py +64 -0
- pycitizen-0.1.0/pyproject.toml +59 -0
- pycitizen-0.1.0/research/CITIZEN_API_REPORT.md +489 -0
- pycitizen-0.1.0/research/apktool.log +116 -0
- pycitizen-0.1.0/research/jadx.log +3 -0
- pycitizen-0.1.0/src/pycitizen/__init__.py +130 -0
- pycitizen-0.1.0/src/pycitizen/client.py +794 -0
- pycitizen-0.1.0/src/pycitizen/const.py +81 -0
- pycitizen-0.1.0/src/pycitizen/exceptions.py +53 -0
- pycitizen-0.1.0/src/pycitizen/feed.py +222 -0
- pycitizen-0.1.0/src/pycitizen/models.py +948 -0
- pycitizen-0.1.0/src/pycitizen/py.typed +0 -0
- pycitizen-0.1.0/src/pycitizen/ratelimit.py +103 -0
- pycitizen-0.1.0/src/pycitizen/tiles.py +366 -0
- pycitizen-0.1.0/tests/__init__.py +0 -0
- pycitizen-0.1.0/tests/conftest.py +220 -0
- pycitizen-0.1.0/tests/fixtures/incidents_tile.pbf +0 -0
- pycitizen-0.1.0/tests/test_client.py +328 -0
- pycitizen-0.1.0/tests/test_extended.py +240 -0
- pycitizen-0.1.0/tests/test_feed.py +174 -0
- pycitizen-0.1.0/tests/test_models.py +187 -0
- pycitizen-0.1.0/tests/test_ratelimit.py +81 -0
- pycitizen-0.1.0/tests/test_tiles.py +208 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
check:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- run: pip install -e ".[dev]"
|
|
20
|
+
- run: ruff check src tests
|
|
21
|
+
- run: mypy
|
|
22
|
+
- run: pytest -x -q
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
test:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: actions/setup-python@v5
|
|
13
|
+
with:
|
|
14
|
+
python-version: "3.12"
|
|
15
|
+
- run: pip install -e ".[dev]"
|
|
16
|
+
- run: ruff check src tests
|
|
17
|
+
- run: mypy
|
|
18
|
+
- run: pytest -x -q
|
|
19
|
+
|
|
20
|
+
build:
|
|
21
|
+
needs: test
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v4
|
|
25
|
+
- uses: actions/setup-python@v5
|
|
26
|
+
with:
|
|
27
|
+
python-version: "3.12"
|
|
28
|
+
- run: pip install build && python -m build
|
|
29
|
+
- uses: actions/upload-artifact@v4
|
|
30
|
+
with:
|
|
31
|
+
name: dist
|
|
32
|
+
path: dist/
|
|
33
|
+
|
|
34
|
+
publish-pypi:
|
|
35
|
+
needs: build
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
environment:
|
|
38
|
+
name: pypi
|
|
39
|
+
url: https://pypi.org/p/pycitizen
|
|
40
|
+
permissions:
|
|
41
|
+
id-token: write
|
|
42
|
+
steps:
|
|
43
|
+
- uses: actions/download-artifact@v4
|
|
44
|
+
with:
|
|
45
|
+
name: dist
|
|
46
|
+
path: dist/
|
|
47
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.egg-info/
|
|
4
|
+
.venv/
|
|
5
|
+
venv/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
.DS_Store
|
|
11
|
+
|
|
12
|
+
# Reverse-engineering artifacts (large; reproducible from the APK)
|
|
13
|
+
research/jadx-out/
|
|
14
|
+
research/apktool-out/
|
|
15
|
+
research/xapk/
|
|
16
|
+
research/*.xapk
|
|
17
|
+
*.apk
|
pycitizen-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 pycitizen contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pycitizen-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pycitizen
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python SDK for Citizen's public incident API
|
|
5
|
+
Project-URL: Repository, https://github.com/BookCatKid/pycitizen
|
|
6
|
+
Author: pycitizen contributors
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: api,async,citizen,crime,incidents,safety
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: aiohttp>=3.9
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: build>=1.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# pycitizen
|
|
30
|
+
|
|
31
|
+
[](https://github.com/BookCatKid/pycitizen/actions/workflows/ci.yml)
|
|
32
|
+
[](https://pypi.org/project/pycitizen/)
|
|
33
|
+
[](https://pypi.org/project/pycitizen/)
|
|
34
|
+
[](https://github.com/BookCatKid/pycitizen/blob/main/LICENSE)
|
|
35
|
+
[](https://mypy-lang.org/)
|
|
36
|
+
|
|
37
|
+
Async-first Python SDK for [Citizen](https://citizen.com)'s **public, unauthenticated** incident API — geographic incident discovery via vector tiles, incident details, batch retrieval, related incidents, news feeds, and read-only chat history.
|
|
38
|
+
|
|
39
|
+
Reverse-engineered from Citizen Android `0.1308.0` (`sp0n.citizen`, build 1140). Every endpoint implemented here was verified reachable without an access token.
|
|
40
|
+
|
|
41
|
+
> **Status: experimental.** Unofficial SDK, not affiliated with Citizen. The endpoints are undocumented and may change or become restricted at any time. Use politely — keep request rates low, cache aggressively, and honor the rate limiter defaults.
|
|
42
|
+
|
|
43
|
+
## Features
|
|
44
|
+
|
|
45
|
+
- **Geographic discovery** — fetch every incident in a bounding box with one request per covering Mapbox vector tile (`/v1/tile/incidents/{x}/{y}/{z}.pbf`), decoded by a built-in dependency-free MVT parser.
|
|
46
|
+
- **Incident details** — v1/v2/v3 detail endpoints unified into one `Incident` model; batch retrieval for up to 50 IDs per request.
|
|
47
|
+
- **News** — curated news feed and news briefings per service area.
|
|
48
|
+
- **Auxiliary** — service-area lookup, safety status, reverse geocoding, place search, incident content/map sources, read-only chat history, neighborhood trends (raw).
|
|
49
|
+
- **`IncidentFeed`** — polling tracker with deduplication, field-level change detection, lifecycle-transition history, and graceful stale/removed handling. Designed to wrap in a `DataUpdateCoordinator` (e.g. Home Assistant).
|
|
50
|
+
- **Async-first** — `aiohttp`, injectable sessions, token-bucket rate limiting, bounded concurrency, retries with exponential backoff honoring `Retry-After`.
|
|
51
|
+
- **Fully typed** — `py.typed`, dataclass models, enum severity/lifecycle values matching the app's own DTOs.
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install pycitizen
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Requires Python 3.11+. Only runtime dependency is `aiohttp`.
|
|
60
|
+
|
|
61
|
+
## Quick start
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import asyncio
|
|
65
|
+
from pycitizen import CitizenClient
|
|
66
|
+
|
|
67
|
+
# (west, south, east, north) — e.g. Manhattan
|
|
68
|
+
BBOX = (-74.02, 40.70, -73.93, 40.80)
|
|
69
|
+
|
|
70
|
+
async def main() -> None:
|
|
71
|
+
async with CitizenClient() as client:
|
|
72
|
+
# 1. Discover every incident marker in the bbox via vector tiles
|
|
73
|
+
markers = await client.get_incident_markers(BBOX)
|
|
74
|
+
for m in markers:
|
|
75
|
+
print(m.incident_id, m.title, m.severity.value, m.timestamp)
|
|
76
|
+
|
|
77
|
+
# 2. Hydrate full details (batch or single)
|
|
78
|
+
incidents = await client.get_incidents([m.incident_id for m in markers])
|
|
79
|
+
for inc in incidents:
|
|
80
|
+
print(inc.title, inc.location, inc.lifecycle_state.value)
|
|
81
|
+
|
|
82
|
+
asyncio.run(main())
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Tracking a feed
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from pycitizen import CitizenClient, IncidentFeed, FeedState
|
|
89
|
+
|
|
90
|
+
async with CitizenClient() as client:
|
|
91
|
+
feed = IncidentFeed(client, BBOX, expire_after=900)
|
|
92
|
+
|
|
93
|
+
while True:
|
|
94
|
+
diff = await feed.update()
|
|
95
|
+
for t in diff.added:
|
|
96
|
+
print("NEW:", t.marker.title)
|
|
97
|
+
for t in diff.updated:
|
|
98
|
+
print("UPDATED:", t.marker.title, "->", t.marker.lifecycle_state.value)
|
|
99
|
+
for t in diff.removed:
|
|
100
|
+
print("GONE:", t.marker.title)
|
|
101
|
+
await asyncio.sleep(60)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`TrackedIncident` retains `first_seen`, `last_seen`, and a `lifecycle_history` of `(timestamp, LifecycleState)` transitions (`reported → verified → developing → resolved/inactive`).
|
|
105
|
+
|
|
106
|
+
## API surface
|
|
107
|
+
|
|
108
|
+
| Method | Endpoint | Notes |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `health()` | `GET /healthz` | reachability check |
|
|
111
|
+
| `get_variable_settings()` | `GET /v1/variable_settings_anonymous` | remote config flags |
|
|
112
|
+
| `get_status(lat, lon)` | `GET /v1/homescreen/status` | service-area info for a point |
|
|
113
|
+
| `get_service_areas(bbox)` | `GET /v1/homescreen/mapExplore` | service-area codes for a bbox |
|
|
114
|
+
| `get_incident_markers(bbox, zoom=12, categories=…, created_gte/lte=…, limit=…, active_definition=…)` | `GET /v1/tile/incidents/{x}/{y}/{z}.pbf` | the discovery workhorse; optional filters mirror the app's tile-URL params |
|
|
115
|
+
| `get_historical_incidents(bbox, zoom)` | `GET /v1/tile/historical_incidents/{x}/{y}/{z}.pbf` | past-window incidents |
|
|
116
|
+
| `get_offender_markers(bbox, zoom)` | `GET /v1/tile/offenders/{x}/{y}/{z}.pbf` | offender registry layer |
|
|
117
|
+
| `get_places(bbox, zoom)` | `GET /v1/tile/places/{x}/{y}/{z}.pbf` | OSM place labels |
|
|
118
|
+
| `get_tile_style(name)` | `GET /v1/tile/style/{name}.json` | MapLibre style docs |
|
|
119
|
+
| `get_incident(id)` | `GET /v3/incident/{id}` | primary detail |
|
|
120
|
+
| `get_incident_v1/v2(id)` | `GET /v{1,2}/incident/{id}` | richer/alternate shapes |
|
|
121
|
+
| `get_incidents(ids)` | `GET /v1/incidents/batch` | chunked at 50 IDs |
|
|
122
|
+
| `get_related_incidents(id)` | `GET /v1/incidents/{id}/related_incidents` | merged/related IDs |
|
|
123
|
+
| `get_incident_content(id)` | `GET /v1/incidents/{id}/content` | links/images/streams |
|
|
124
|
+
| `get_incident_map_sources(id)` | `GET /v1/incidents/{id}/map_sources` | raw |
|
|
125
|
+
| `get_news_feed(code)` | `GET /v2/news/feed` | curated feed |
|
|
126
|
+
| `get_news_briefing(code)` | `GET /v2/incidents/news_briefing` | generated briefings |
|
|
127
|
+
| `get_chat_history(id, ...)` | `GET /v4/incident_chat/history` | read-only, paginated |
|
|
128
|
+
| `get_location_name(lat, lon)` | `GET /v1/safety/location_name` | reverse geocode |
|
|
129
|
+
| `search_locations(q, ...)` | `GET /v1/safety/location_search` | place search |
|
|
130
|
+
| `get_location(lat, lon)` | `GET /v1/safety/location` | safety location record |
|
|
131
|
+
| `get_public_users(ids)` | `GET /v1/users/batch_public` | public profiles |
|
|
132
|
+
| `check_username(name)` | `GET /v1/users/check_username` | availability check |
|
|
133
|
+
| `get_social_presence(ids)` | `GET /v1/incidents/social/batch` | friend presence (empty w/o auth) |
|
|
134
|
+
| `get_impact_statistics()` | `GET /v1/protect/impact_statistics` | Protect marketing stats |
|
|
135
|
+
| `get_neighborhood_details(id)` | `GET /v1/trends/neighborhoods/{id}/details` | crime-level summary |
|
|
136
|
+
| `get_neighborhood_incidents(id)` | `GET /v1/trends/neighborhoods/{id}/incidents` | typed `Incident` list |
|
|
137
|
+
| `get_neighborhood_boundary(id)` | `GET /v1/trends/neighborhoods/{id}/boundary` | GeoJSON geometry |
|
|
138
|
+
| `get_neighborhood_graph(id)` | `GET /v1/trends/neighborhoods/{id}/graph` | category time series |
|
|
139
|
+
| `get_neighborhood_feed(lat, lon)` | `GET /v1/trends/shs_feed` | neighborhood for a point |
|
|
140
|
+
|
|
141
|
+
`client.get_json(path, params)` is a low-level escape hatch for anything not wrapped.
|
|
142
|
+
|
|
143
|
+
## Examples
|
|
144
|
+
|
|
145
|
+
Runnable live demos of the whole public surface — see [`examples/`](examples/README.md):
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
python examples/nearby_incidents.py # the full incident pipeline
|
|
149
|
+
python examples/feed_watcher.py # IncidentFeed diffing
|
|
150
|
+
python examples/map_layers.py # historical / offender / place tiles
|
|
151
|
+
python examples/news_and_trends.py # news + neighborhood trends
|
|
152
|
+
python examples/misc_public.py # users, usernames, stats, settings
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## What this SDK does *not* do
|
|
156
|
+
|
|
157
|
+
- **No authentication flows.** Citizen's private endpoints (homescreen feed/mapIncidents, search, friends, variable_settings, user endpoints) return `401` without a user token obtained via phone-OTP sign-in. Not implemented.
|
|
158
|
+
- **No WebSocket.** `wss://data.sp0n.io/websocket` only carries chat and Protect subscription traffic — not the incident feed — and rejects unauthenticated method calls (`auth required`).
|
|
159
|
+
- **No push notifications.** Alerts arrive via FCM tied to a registered device token; there is no public push channel.
|
|
160
|
+
- **No mutations.** Posting incidents, comments, likes, follows — all auth-gated and out of scope.
|
|
161
|
+
|
|
162
|
+
Real-time updates = **poll the tiles**. They are the same source the app's map consumes.
|
|
163
|
+
|
|
164
|
+
## Configuration
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from pycitizen import CitizenClient, RateLimiter, RetryPolicy
|
|
168
|
+
|
|
169
|
+
client = CitizenClient(
|
|
170
|
+
rate_limiter=RateLimiter(rate=2.0, burst=2, max_concurrent=2),
|
|
171
|
+
retry_policy=RetryPolicy(max_attempts=4),
|
|
172
|
+
timeout=15.0,
|
|
173
|
+
)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Inject an existing session (e.g. Home Assistant's shared one) with `CitizenClient(session=session)`; `close()` then becomes a no-op.
|
|
177
|
+
|
|
178
|
+
## Development
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
python -m venv .venv && source .venv/bin/activate
|
|
182
|
+
pip install -e ".[dev]"
|
|
183
|
+
pytest # fully offline test suite
|
|
184
|
+
ruff check src tests # lint
|
|
185
|
+
mypy # strict type check (8 modules, zero errors)
|
|
186
|
+
python -m build # sdist + wheel
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The test suite never touches the network: HTTP is stubbed and the vector-tile path is exercised against a real tile captured from the live API (`tests/fixtures/incidents_tile.pbf`).
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# pycitizen
|
|
2
|
+
|
|
3
|
+
[](https://github.com/BookCatKid/pycitizen/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/pycitizen/)
|
|
5
|
+
[](https://pypi.org/project/pycitizen/)
|
|
6
|
+
[](https://github.com/BookCatKid/pycitizen/blob/main/LICENSE)
|
|
7
|
+
[](https://mypy-lang.org/)
|
|
8
|
+
|
|
9
|
+
Async-first Python SDK for [Citizen](https://citizen.com)'s **public, unauthenticated** incident API — geographic incident discovery via vector tiles, incident details, batch retrieval, related incidents, news feeds, and read-only chat history.
|
|
10
|
+
|
|
11
|
+
Reverse-engineered from Citizen Android `0.1308.0` (`sp0n.citizen`, build 1140). Every endpoint implemented here was verified reachable without an access token.
|
|
12
|
+
|
|
13
|
+
> **Status: experimental.** Unofficial SDK, not affiliated with Citizen. The endpoints are undocumented and may change or become restricted at any time. Use politely — keep request rates low, cache aggressively, and honor the rate limiter defaults.
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- **Geographic discovery** — fetch every incident in a bounding box with one request per covering Mapbox vector tile (`/v1/tile/incidents/{x}/{y}/{z}.pbf`), decoded by a built-in dependency-free MVT parser.
|
|
18
|
+
- **Incident details** — v1/v2/v3 detail endpoints unified into one `Incident` model; batch retrieval for up to 50 IDs per request.
|
|
19
|
+
- **News** — curated news feed and news briefings per service area.
|
|
20
|
+
- **Auxiliary** — service-area lookup, safety status, reverse geocoding, place search, incident content/map sources, read-only chat history, neighborhood trends (raw).
|
|
21
|
+
- **`IncidentFeed`** — polling tracker with deduplication, field-level change detection, lifecycle-transition history, and graceful stale/removed handling. Designed to wrap in a `DataUpdateCoordinator` (e.g. Home Assistant).
|
|
22
|
+
- **Async-first** — `aiohttp`, injectable sessions, token-bucket rate limiting, bounded concurrency, retries with exponential backoff honoring `Retry-After`.
|
|
23
|
+
- **Fully typed** — `py.typed`, dataclass models, enum severity/lifecycle values matching the app's own DTOs.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install pycitizen
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Requires Python 3.11+. Only runtime dependency is `aiohttp`.
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import asyncio
|
|
37
|
+
from pycitizen import CitizenClient
|
|
38
|
+
|
|
39
|
+
# (west, south, east, north) — e.g. Manhattan
|
|
40
|
+
BBOX = (-74.02, 40.70, -73.93, 40.80)
|
|
41
|
+
|
|
42
|
+
async def main() -> None:
|
|
43
|
+
async with CitizenClient() as client:
|
|
44
|
+
# 1. Discover every incident marker in the bbox via vector tiles
|
|
45
|
+
markers = await client.get_incident_markers(BBOX)
|
|
46
|
+
for m in markers:
|
|
47
|
+
print(m.incident_id, m.title, m.severity.value, m.timestamp)
|
|
48
|
+
|
|
49
|
+
# 2. Hydrate full details (batch or single)
|
|
50
|
+
incidents = await client.get_incidents([m.incident_id for m in markers])
|
|
51
|
+
for inc in incidents:
|
|
52
|
+
print(inc.title, inc.location, inc.lifecycle_state.value)
|
|
53
|
+
|
|
54
|
+
asyncio.run(main())
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Tracking a feed
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from pycitizen import CitizenClient, IncidentFeed, FeedState
|
|
61
|
+
|
|
62
|
+
async with CitizenClient() as client:
|
|
63
|
+
feed = IncidentFeed(client, BBOX, expire_after=900)
|
|
64
|
+
|
|
65
|
+
while True:
|
|
66
|
+
diff = await feed.update()
|
|
67
|
+
for t in diff.added:
|
|
68
|
+
print("NEW:", t.marker.title)
|
|
69
|
+
for t in diff.updated:
|
|
70
|
+
print("UPDATED:", t.marker.title, "->", t.marker.lifecycle_state.value)
|
|
71
|
+
for t in diff.removed:
|
|
72
|
+
print("GONE:", t.marker.title)
|
|
73
|
+
await asyncio.sleep(60)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`TrackedIncident` retains `first_seen`, `last_seen`, and a `lifecycle_history` of `(timestamp, LifecycleState)` transitions (`reported → verified → developing → resolved/inactive`).
|
|
77
|
+
|
|
78
|
+
## API surface
|
|
79
|
+
|
|
80
|
+
| Method | Endpoint | Notes |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `health()` | `GET /healthz` | reachability check |
|
|
83
|
+
| `get_variable_settings()` | `GET /v1/variable_settings_anonymous` | remote config flags |
|
|
84
|
+
| `get_status(lat, lon)` | `GET /v1/homescreen/status` | service-area info for a point |
|
|
85
|
+
| `get_service_areas(bbox)` | `GET /v1/homescreen/mapExplore` | service-area codes for a bbox |
|
|
86
|
+
| `get_incident_markers(bbox, zoom=12, categories=…, created_gte/lte=…, limit=…, active_definition=…)` | `GET /v1/tile/incidents/{x}/{y}/{z}.pbf` | the discovery workhorse; optional filters mirror the app's tile-URL params |
|
|
87
|
+
| `get_historical_incidents(bbox, zoom)` | `GET /v1/tile/historical_incidents/{x}/{y}/{z}.pbf` | past-window incidents |
|
|
88
|
+
| `get_offender_markers(bbox, zoom)` | `GET /v1/tile/offenders/{x}/{y}/{z}.pbf` | offender registry layer |
|
|
89
|
+
| `get_places(bbox, zoom)` | `GET /v1/tile/places/{x}/{y}/{z}.pbf` | OSM place labels |
|
|
90
|
+
| `get_tile_style(name)` | `GET /v1/tile/style/{name}.json` | MapLibre style docs |
|
|
91
|
+
| `get_incident(id)` | `GET /v3/incident/{id}` | primary detail |
|
|
92
|
+
| `get_incident_v1/v2(id)` | `GET /v{1,2}/incident/{id}` | richer/alternate shapes |
|
|
93
|
+
| `get_incidents(ids)` | `GET /v1/incidents/batch` | chunked at 50 IDs |
|
|
94
|
+
| `get_related_incidents(id)` | `GET /v1/incidents/{id}/related_incidents` | merged/related IDs |
|
|
95
|
+
| `get_incident_content(id)` | `GET /v1/incidents/{id}/content` | links/images/streams |
|
|
96
|
+
| `get_incident_map_sources(id)` | `GET /v1/incidents/{id}/map_sources` | raw |
|
|
97
|
+
| `get_news_feed(code)` | `GET /v2/news/feed` | curated feed |
|
|
98
|
+
| `get_news_briefing(code)` | `GET /v2/incidents/news_briefing` | generated briefings |
|
|
99
|
+
| `get_chat_history(id, ...)` | `GET /v4/incident_chat/history` | read-only, paginated |
|
|
100
|
+
| `get_location_name(lat, lon)` | `GET /v1/safety/location_name` | reverse geocode |
|
|
101
|
+
| `search_locations(q, ...)` | `GET /v1/safety/location_search` | place search |
|
|
102
|
+
| `get_location(lat, lon)` | `GET /v1/safety/location` | safety location record |
|
|
103
|
+
| `get_public_users(ids)` | `GET /v1/users/batch_public` | public profiles |
|
|
104
|
+
| `check_username(name)` | `GET /v1/users/check_username` | availability check |
|
|
105
|
+
| `get_social_presence(ids)` | `GET /v1/incidents/social/batch` | friend presence (empty w/o auth) |
|
|
106
|
+
| `get_impact_statistics()` | `GET /v1/protect/impact_statistics` | Protect marketing stats |
|
|
107
|
+
| `get_neighborhood_details(id)` | `GET /v1/trends/neighborhoods/{id}/details` | crime-level summary |
|
|
108
|
+
| `get_neighborhood_incidents(id)` | `GET /v1/trends/neighborhoods/{id}/incidents` | typed `Incident` list |
|
|
109
|
+
| `get_neighborhood_boundary(id)` | `GET /v1/trends/neighborhoods/{id}/boundary` | GeoJSON geometry |
|
|
110
|
+
| `get_neighborhood_graph(id)` | `GET /v1/trends/neighborhoods/{id}/graph` | category time series |
|
|
111
|
+
| `get_neighborhood_feed(lat, lon)` | `GET /v1/trends/shs_feed` | neighborhood for a point |
|
|
112
|
+
|
|
113
|
+
`client.get_json(path, params)` is a low-level escape hatch for anything not wrapped.
|
|
114
|
+
|
|
115
|
+
## Examples
|
|
116
|
+
|
|
117
|
+
Runnable live demos of the whole public surface — see [`examples/`](examples/README.md):
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
python examples/nearby_incidents.py # the full incident pipeline
|
|
121
|
+
python examples/feed_watcher.py # IncidentFeed diffing
|
|
122
|
+
python examples/map_layers.py # historical / offender / place tiles
|
|
123
|
+
python examples/news_and_trends.py # news + neighborhood trends
|
|
124
|
+
python examples/misc_public.py # users, usernames, stats, settings
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## What this SDK does *not* do
|
|
128
|
+
|
|
129
|
+
- **No authentication flows.** Citizen's private endpoints (homescreen feed/mapIncidents, search, friends, variable_settings, user endpoints) return `401` without a user token obtained via phone-OTP sign-in. Not implemented.
|
|
130
|
+
- **No WebSocket.** `wss://data.sp0n.io/websocket` only carries chat and Protect subscription traffic — not the incident feed — and rejects unauthenticated method calls (`auth required`).
|
|
131
|
+
- **No push notifications.** Alerts arrive via FCM tied to a registered device token; there is no public push channel.
|
|
132
|
+
- **No mutations.** Posting incidents, comments, likes, follows — all auth-gated and out of scope.
|
|
133
|
+
|
|
134
|
+
Real-time updates = **poll the tiles**. They are the same source the app's map consumes.
|
|
135
|
+
|
|
136
|
+
## Configuration
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from pycitizen import CitizenClient, RateLimiter, RetryPolicy
|
|
140
|
+
|
|
141
|
+
client = CitizenClient(
|
|
142
|
+
rate_limiter=RateLimiter(rate=2.0, burst=2, max_concurrent=2),
|
|
143
|
+
retry_policy=RetryPolicy(max_attempts=4),
|
|
144
|
+
timeout=15.0,
|
|
145
|
+
)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Inject an existing session (e.g. Home Assistant's shared one) with `CitizenClient(session=session)`; `close()` then becomes a no-op.
|
|
149
|
+
|
|
150
|
+
## Development
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
python -m venv .venv && source .venv/bin/activate
|
|
154
|
+
pip install -e ".[dev]"
|
|
155
|
+
pytest # fully offline test suite
|
|
156
|
+
ruff check src tests # lint
|
|
157
|
+
mypy # strict type check (8 modules, zero errors)
|
|
158
|
+
python -m build # sdist + wheel
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The test suite never touches the network: HTTP is stubbed and the vector-tile path is exercised against a real tile captured from the live API (`tests/fixtures/incidents_tile.pbf`).
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# pycitizen — SDK Report
|
|
2
|
+
|
|
3
|
+
Async-first, standalone Python SDK for Citizen's public incident API.
|
|
4
|
+
Built from the reverse-engineering of Citizen Android `0.1308.0`
|
|
5
|
+
(`sp0n.citizen`, build 1140); see `research/CITIZEN_API_REPORT.md` for the full
|
|
6
|
+
investigation. Version: **0.1.0**.
|
|
7
|
+
|
|
8
|
+
## Supported features
|
|
9
|
+
|
|
10
|
+
### Geographic discovery (vector tiles)
|
|
11
|
+
|
|
12
|
+
- `get_incident_markers(bbox, zoom=12, clip_to_bbox=True, categories=…,
|
|
13
|
+
created_gte/lte=…, limit=…, active_definition=…, with_lifecycle_state=…)` —
|
|
14
|
+
optional server-side filters mirroring the app's tile-URL params
|
|
15
|
+
(`incident_category`, `incident_created_at_gte/lte`, `limit`,
|
|
16
|
+
`active_definition`, `with_lifecycle_state`) — covers a
|
|
17
|
+
bounding box with slippy-map tiles, fetches
|
|
18
|
+
`GET /v1/tile/incidents/{x}/{y}/{z}.pbf` concurrently, decodes the MVT
|
|
19
|
+
`incidents` layer, deduplicates markers across tile edges, and clips to
|
|
20
|
+
the requested bbox. Missing (404) or transiently failing tiles degrade
|
|
21
|
+
to partial coverage instead of failing the whole call.
|
|
22
|
+
- `get_historical_incidents(bbox, zoom)` — `historical_incidents` tile
|
|
23
|
+
layer (past-window incidents: id, title, position, time_frame, score,
|
|
24
|
+
engagement counts).
|
|
25
|
+
- `get_offender_markers(bbox, zoom)` — `offenders` tile layer
|
|
26
|
+
(registry markers: id, names, address, charges, image, position).
|
|
27
|
+
- `get_places(bbox, zoom)` — `places` tile layer (OSM town/suburb/
|
|
28
|
+
neighbourhood labels, position projected from tile geometry).
|
|
29
|
+
- `get_tile_style(name)` — the app's MapLibre style documents
|
|
30
|
+
(`citizen-app-dark-20220303`, `citizen-app-light-20250512`,
|
|
31
|
+
`citizen-incidents-offenders-wildfire-evac-all`) — also the discovery
|
|
32
|
+
source for the tile endpoints above.
|
|
33
|
+
- `pycitizen.tiles` — dependency-free protobuf wire reader + MVT decoder
|
|
34
|
+
(layers, features, properties, point/line/polygon geometry), plus
|
|
35
|
+
`lonlat_to_tile`, `tile_bounds`, `tiles_for_bbox` helpers.
|
|
36
|
+
- `get_status(lat, lon)` — service-area info (`v1/homescreen/status`).
|
|
37
|
+
- `get_service_areas(bbox)` — service-area codes (`v1/homescreen/mapExplore`,
|
|
38
|
+
`lower*/upper*` lon/lat params).
|
|
39
|
+
|
|
40
|
+
### Incident detail
|
|
41
|
+
|
|
42
|
+
- `get_incident(id)` → `v3/incident/{id}` (primary)
|
|
43
|
+
- `get_incident_v1(id)` → `v1/incident/{id}?with_stats&with_facepile`
|
|
44
|
+
- `get_incident_v2(id)` → `v2/incident/{id}?with_stats`
|
|
45
|
+
- `get_incidents(ids)` → `v1/incidents/batch`, deduped, chunked at 50 IDs
|
|
46
|
+
- `get_related_incidents(id)` → related/merged incident IDs
|
|
47
|
+
- `get_incident_content(id)` → web/image/stream attachments
|
|
48
|
+
- `get_incident_map_sources(id)` → raw map-source overlay data
|
|
49
|
+
|
|
50
|
+
### News & auxiliary
|
|
51
|
+
|
|
52
|
+
- `get_news_feed(code)` → `v2/news/feed` curated items (bucket + incident)
|
|
53
|
+
- `get_news_briefing(code)` → `v2/incidents/news_briefing`
|
|
54
|
+
- `get_chat_history(id, limit, before_chat_id, include_deleted, version=4)`
|
|
55
|
+
→ paginated read-only chat history
|
|
56
|
+
- `get_location_name(lat, lon)`, `search_locations(q, ...)`,
|
|
57
|
+
`get_location(lat, lon)` — Citizen's own geocoding/safety endpoints
|
|
58
|
+
- `get_neighborhood_details(id)` → typed `NeighborhoodDetails`
|
|
59
|
+
- `get_neighborhood_incidents(id, category, lookback_days)` → `list[Incident]`
|
|
60
|
+
- `get_neighborhood_boundary(id)` → `NeighborhoodBoundary` (GeoJSON)
|
|
61
|
+
- `get_neighborhood_graph(id, category)` → raw time-series dict
|
|
62
|
+
- `get_neighborhood_feed(lat, lon)` → `NeighborhoodFeed` (shs_feed)
|
|
63
|
+
- `get_public_users(ids)` → `v1/users/batch_public` public profiles
|
|
64
|
+
- `check_username(name)` → availability `{allowed, reason}`
|
|
65
|
+
- `get_social_presence(ids)` → friend presence (lists empty w/o auth)
|
|
66
|
+
- `get_impact_statistics()` → `v1/protect/impact_statistics`
|
|
67
|
+
- `get_variable_settings()`, `health()`
|
|
68
|
+
- `client.get_json(path, params)` — escape hatch for unwrapped endpoints
|
|
69
|
+
|
|
70
|
+
### Feed tracking (`IncidentFeed`)
|
|
71
|
+
|
|
72
|
+
- Polls the tile endpoints for a bbox and diffs each update:
|
|
73
|
+
`FeedUpdate(added, updated, removed)`.
|
|
74
|
+
- Deduplication by `incident_id`; change detection via a SHA-256
|
|
75
|
+
fingerprint over tracked fields (title, categories, severity, level,
|
|
76
|
+
lifecycle_state, recency_tier, timestamps, engagement counts, has_vod) —
|
|
77
|
+
unrelated field churn cannot trigger false updates.
|
|
78
|
+
- Lifecycle tracking: `TrackedIncident.lifecycle_history` records every
|
|
79
|
+
observed `reported → verified → developing → resolved/inactive`
|
|
80
|
+
transition with timestamps; `first_seen`/`last_seen` retained.
|
|
81
|
+
- Graceful disappearance handling: incidents absent from tiles become
|
|
82
|
+
`STALE` and are only `REMOVED` after `expire_after` seconds (default
|
|
83
|
+
900), eliminating flap noise; a reappearing incident reactivates in
|
|
84
|
+
place rather than re-adding.
|
|
85
|
+
- `active_only=True` filters to currently-active markers.
|
|
86
|
+
|
|
87
|
+
### Plumbing
|
|
88
|
+
|
|
89
|
+
- `aiohttp`-only runtime dependency; injectable `ClientSession` (HA-ready).
|
|
90
|
+
- Token-bucket `RateLimiter` (default 5 req/s, burst 5, 4 concurrent).
|
|
91
|
+
- `RetryPolicy`: 3 attempts, exponential backoff + jitter, honors
|
|
92
|
+
`Retry-After`, retries 429/5xx and connection/timeouts.
|
|
93
|
+
- Typed error hierarchy: `CitizenError` → `CitizenConnectionError`,
|
|
94
|
+
`CitizenTimeoutError`, `CitizenResponseError(status)` →
|
|
95
|
+
`CitizenAuthError` (401/403), `CitizenNotFoundError` (404),
|
|
96
|
+
`CitizenRateLimitError` (429); `CitizenParseError`/`CitizenTileError`.
|
|
97
|
+
- Optional `access_token` → `x-access-token` header for future use.
|
|
98
|
+
- Fully typed (`py.typed`), dataclass models, enums matching the app's
|
|
99
|
+
DTO values, every model keeps the raw payload for forward compat.
|
|
100
|
+
|
|
101
|
+
## Verified API behavior (live, read-only)
|
|
102
|
+
|
|
103
|
+
Smoke-tested against `https://data.sp0n.io` on 2026-09-19:
|
|
104
|
+
|
|
105
|
+
| Call | Result |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `health()` | 200 OK |
|
|
108
|
+
| `get_status(40.65, -74.0)` | `inServiceArea=true`, `serviceAreaCode=nyc`, `locationName="Sunset Park"` |
|
|
109
|
+
| `get_incident_markers(bbox)` | 68+ live markers, deduped across tiles; real titles/categories/severities/lifecycle states |
|
|
110
|
+
| `get_historical_incidents(bbox)` | 3 past-window incidents (time_frame, engagement counts) |
|
|
111
|
+
| `get_offender_markers(bbox)` | 112 registry markers with names/positions |
|
|
112
|
+
| `get_places(bbox)` | 17 neighborhood labels |
|
|
113
|
+
| `get_tile_style(name)` | full MapLibre styles incl. `cal-fire-*` sources |
|
|
114
|
+
| `get_incident(id)` (v3) | full detail incl. neighborhood, position, updates w/ radio clips |
|
|
115
|
+
| `get_incidents(ids)` (batch) | stats + update timelines (e.g. 290k views, 15 updates) |
|
|
116
|
+
| `get_related_incidents(id)` | related/merged IDs |
|
|
117
|
+
| `get_chat_history(id)` | real messages, `hasMore` pagination |
|
|
118
|
+
| `get_news_feed("nyc")` | 16 curated items with buckets |
|
|
119
|
+
| `get_news_briefing("nyc")` | 404 (none published — handled) |
|
|
120
|
+
| `get_neighborhood_*` | 200, currently empty payloads (endpoint verified) |
|
|
121
|
+
| `get_public_users(ids)` | 200 `{"results":[]}` |
|
|
122
|
+
| `check_username(name)` | 200 `{allowed, reason}` |
|
|
123
|
+
| `get_social_presence(ids)` | 200, presence lists empty without auth |
|
|
124
|
+
| `get_impact_statistics()` | real stats (174k premium users, 70 calls/week) |
|
|
125
|
+
| `IncidentFeed.update()` ×2 | markers tracked, second cycle zero-change diff |
|
|
126
|
+
|
|
127
|
+
All of the above with **no access token and no app-specific headers** —
|
|
128
|
+
a plain `pycitizen` User-Agent. See `examples/` for runnable proof of
|
|
129
|
+
every row.
|
|
130
|
+
|
|
131
|
+
## Test results
|
|
132
|
+
|
|
133
|
+
`pytest`: **98 passed, 0 failed** (~0.3s, fully offline).
|
|
134
|
+
|
|
135
|
+
- `test_tiles.py` — slippy math, real captured tile decode (10 features),
|
|
136
|
+
synthetic-tile round-trip, geometry→lon/lat projection, malformed input.
|
|
137
|
+
- `test_models.py` — wire-shape parsing, enum mapping, timestamp formats.
|
|
138
|
+
- `test_client.py` — endpoint paths/params, batching/chunking, error
|
|
139
|
+
mapping, retries, `Retry-After`, token header, session ownership.
|
|
140
|
+
- `test_feed.py` — add/update/remove diffs, stale→expire, reactivation,
|
|
141
|
+
lifecycle history, fingerprint stability.
|
|
142
|
+
- `test_ratelimit.py` — burst, sustained rate, concurrency cap, backoff.
|
|
143
|
+
- `test_extended.py` — historical/offender/place tile models, users,
|
|
144
|
+
usernames, social presence, impact stats, neighborhood trends.
|
|
145
|
+
|
|
146
|
+
`ruff check`: clean. `python -m build`: sdist + wheel OK
|
|
147
|
+
(`pycitizen-0.1.0`).
|
|
148
|
+
|
|
149
|
+
## Limitations & caveats
|
|
150
|
+
|
|
151
|
+
- **Private API, no stability guarantee.** Endpoints can change or be
|
|
152
|
+
gated without notice; there is no versioning contract or deprecation
|
|
153
|
+
policy.
|
|
154
|
+
- **Tile freshness is the only "real-time" channel.** The app refreshes
|
|
155
|
+
incident data via tile refetches and status reloads; there is no public
|
|
156
|
+
WebSocket/SSE/push channel for incidents. `IncidentFeed` is a poller.
|
|
157
|
+
Traced map-refresh mechanics (`research/CITIZEN_API_REPORT.md` §8.1.1):
|
|
158
|
+
the app has **no tile poll timer** — it re-sets the `all_incidents`
|
|
159
|
+
source's `tiles` URL on camera-move end and filter changes, and relies
|
|
160
|
+
on the tiles' `Cache-Control: public, max-age=60` expiry for idle
|
|
161
|
+
freshness. `incidentPollingInterval` (15 s) is broadcast-session
|
|
162
|
+
polling, not map polling.
|
|
163
|
+
- **Auth-gated endpoints excluded.** Homescreen feed/mapIncidents,
|
|
164
|
+
`v1/search`, friends, `variable_settings`, user endpoints, and all
|
|
165
|
+
mutations require a user token (phone-OTP). The WebSocket returns
|
|
166
|
+
`auth required` unauthenticated.
|
|
167
|
+
- **Unverified response shapes** are returned raw (`map_sources`,
|
|
168
|
+
`search_locations`, `get_location`, `neighborhood_graph`) rather than
|
|
169
|
+
parsed into wrong models.
|
|
170
|
+
- **Geographic edge cases:** bboxes crossing the antimeridian must be
|
|
171
|
+
split by the caller; tile coverage is clamped to Web-Mercator latitude.
|
|
172
|
+
- **`mapExplore` response parsing is heuristic** — it returns any list of
|
|
173
|
+
strings found in the payload (observed as service-area codes).
|
|
174
|
+
- **Trend endpoints return 200 with empty bodies** for all tested
|
|
175
|
+
neighborhood ids — real ids are issued by `shs_feed`, itself empty in
|
|
176
|
+
tested areas. Typed parsers are in place for when data appears.
|
|
177
|
+
- **`social/batch` presence lists are empty without auth** — friends are
|
|
178
|
+
an authenticated concept; the endpoint itself is public.
|
|
179
|
+
- **Tile resilience:** a transiently failing tile yields partial coverage
|
|
180
|
+
rather than failing `get_*_markers` (tiles are re-fetched every poll).
|
|
181
|
+
- **Chat `createdAt` format** inferred from `DateDeserializer` in the
|
|
182
|
+
app; both ISO strings and epoch numerics are accepted.
|