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.
Files changed (35) hide show
  1. pycitizen-0.1.0/.github/workflows/ci.yml +22 -0
  2. pycitizen-0.1.0/.github/workflows/publish.yml +47 -0
  3. pycitizen-0.1.0/.gitignore +17 -0
  4. pycitizen-0.1.0/LICENSE +21 -0
  5. pycitizen-0.1.0/PKG-INFO +193 -0
  6. pycitizen-0.1.0/README.md +165 -0
  7. pycitizen-0.1.0/SDK_REPORT.md +182 -0
  8. pycitizen-0.1.0/examples/README.md +21 -0
  9. pycitizen-0.1.0/examples/feed_watcher.py +46 -0
  10. pycitizen-0.1.0/examples/map_layers.py +57 -0
  11. pycitizen-0.1.0/examples/misc_public.py +50 -0
  12. pycitizen-0.1.0/examples/nearby_incidents.py +90 -0
  13. pycitizen-0.1.0/examples/news_and_trends.py +64 -0
  14. pycitizen-0.1.0/pyproject.toml +59 -0
  15. pycitizen-0.1.0/research/CITIZEN_API_REPORT.md +489 -0
  16. pycitizen-0.1.0/research/apktool.log +116 -0
  17. pycitizen-0.1.0/research/jadx.log +3 -0
  18. pycitizen-0.1.0/src/pycitizen/__init__.py +130 -0
  19. pycitizen-0.1.0/src/pycitizen/client.py +794 -0
  20. pycitizen-0.1.0/src/pycitizen/const.py +81 -0
  21. pycitizen-0.1.0/src/pycitizen/exceptions.py +53 -0
  22. pycitizen-0.1.0/src/pycitizen/feed.py +222 -0
  23. pycitizen-0.1.0/src/pycitizen/models.py +948 -0
  24. pycitizen-0.1.0/src/pycitizen/py.typed +0 -0
  25. pycitizen-0.1.0/src/pycitizen/ratelimit.py +103 -0
  26. pycitizen-0.1.0/src/pycitizen/tiles.py +366 -0
  27. pycitizen-0.1.0/tests/__init__.py +0 -0
  28. pycitizen-0.1.0/tests/conftest.py +220 -0
  29. pycitizen-0.1.0/tests/fixtures/incidents_tile.pbf +0 -0
  30. pycitizen-0.1.0/tests/test_client.py +328 -0
  31. pycitizen-0.1.0/tests/test_extended.py +240 -0
  32. pycitizen-0.1.0/tests/test_feed.py +174 -0
  33. pycitizen-0.1.0/tests/test_models.py +187 -0
  34. pycitizen-0.1.0/tests/test_ratelimit.py +81 -0
  35. 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
@@ -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.
@@ -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
+ [![CI](https://github.com/BookCatKid/pycitizen/actions/workflows/ci.yml/badge.svg)](https://github.com/BookCatKid/pycitizen/actions/workflows/ci.yml)
32
+ [![PyPI](https://img.shields.io/pypi/v/pycitizen.svg)](https://pypi.org/project/pycitizen/)
33
+ [![Python](https://img.shields.io/pypi/pyversions/pycitizen.svg)](https://pypi.org/project/pycitizen/)
34
+ [![License](https://img.shields.io/pypi/l/pycitizen.svg)](https://github.com/BookCatKid/pycitizen/blob/main/LICENSE)
35
+ [![Typed](https://img.shields.io/badge/typed-mypy%20strict-blue.svg)](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
+ [![CI](https://github.com/BookCatKid/pycitizen/actions/workflows/ci.yml/badge.svg)](https://github.com/BookCatKid/pycitizen/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/pycitizen.svg)](https://pypi.org/project/pycitizen/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/pycitizen.svg)](https://pypi.org/project/pycitizen/)
6
+ [![License](https://img.shields.io/pypi/l/pycitizen.svg)](https://github.com/BookCatKid/pycitizen/blob/main/LICENSE)
7
+ [![Typed](https://img.shields.io/badge/typed-mypy%20strict-blue.svg)](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.