aiosecurityspy
Async, fully-typed Python client library for the Ben Software SecuritySpy HTTP and event API.
Install
pip install aiosecurityspy
Requires Python 3.14 or newer.
What this is
aiosecurityspy owns all SecuritySpy protocol knowledge — endpoint URLs, event-stream
framing, capture-field and bitmask decoding, the detection-episode reducer, and
credential-safe diagnostics — as an ordinary PyPI package usable from any script.
- No Home Assistant. The library imports nothing from Home Assistant and carries no Home Assistant test tooling. It works in a bare virtual environment.
- Session-injected.
aiohttpis a declared dependency, but the library never creates an HTTP session. The caller owns session lifetime and passes one in. - Typed. A
py.typedmarker ships with the wheel; the source passesmypy --strict.
Usage
The caller creates and owns the aiohttp session. aiosecurityspy never creates,
reconfigures, or closes one — SecuritySpyClient deliberately has no close().
import asyncio
import aiohttp
from aiosecurityspy import SecuritySpyAuthError, SecuritySpyClient, SecuritySpyError
async def main() -> None:
async with aiohttp.ClientSession() as session:
client = SecuritySpyClient(
session,
"nvr.example.com",
8001,
username="ha-readonly",
password="...",
use_https=True,
)
try:
info = await client.async_get_server_info()
except SecuritySpyAuthError:
print("credentials rejected")
return
except SecuritySpyError as err:
print(f"could not read server info: {err}")
return
print(f"SecuritySpy {info.version} ({info.uuid}) — {info.camera_count} cameras")
for number, camera in sorted(info.cameras.items()):
state = "connected" if camera.connected else "offline"
print(f" {number}: {camera.name} [{state}] {sorted(camera.permission_names)}")
asyncio.run(main())
Read the live event stream
event_stream() returns a reader that owns its own lifecycle: CR-only record framing,
a heartbeat watchdog (loss after three missed ~10 s heartbeats), indefinite exponential
backoff, and explicit lifecycle callbacks. Callbacks may be sync or async, and one that
raises is logged and swallowed rather than killing the stream.
import asyncio
import aiohttp
from aiosecurityspy import ClassificationPayload, SecuritySpyClient, StreamEvent
async def main() -> None:
async with aiohttp.ClientSession() as session:
client = SecuritySpyClient(
session,
"nvr.example.com",
8001,
username="ha-readonly",
password="...",
use_https=True,
)
def on_event(event: StreamEvent) -> None:
if isinstance(event.payload, ClassificationPayload):
print(f"camera {event.camera}: {dict(event.payload.classes)}")
stream = client.event_stream(
on_event=on_event,
on_connected=lambda: print("stream live"),
on_disconnected=lambda: print("stream lost; reconnecting"),
on_reconnected=lambda: print("stream back; reconcile state"),
on_auth_failed=lambda: print("credentials rejected; call resume() to retry"),
)
await stream.connect()
try:
await asyncio.sleep(60)
finally:
await stream.disconnect()
asyncio.run(main())
A few things the protocol makes non-obvious:
-
connectedfires once, on the first successful connect of the stream's life. Every later successful connect firesreconnected— including after your owndisconnect()/connect()pair, because any gap means state must be reconciled. -
On 401/403 the stream pauses rather than retrying.
on_auth_failedfires once, and nothing else happens until you callawait stream.resume()—connect()declines while paused, and the pause survivesdisconnect(), so the rejected credential has exactly one door out of it. The library never re-authenticates and never counts auth failures. -
event.camera is Nonemeans the record was not camera-specific (the wire format sendsX), not that it was invalid.NULLheartbeats arrive this way. -
event.event_numberrestarts at 0 on every reconnect. Record it; never key off it. -
The classification vocabulary is open. A label from a custom CoreML model arrives in
ClassificationPayload.classesunchanged. Useslugged()only when you need a permanent key. -
MOTION_ENDis unreliable and is not an inactivity signal; implement your own timeout if you need one. -
Backoff resets after every successful connection, so a server that drops the stream periodically retries promptly instead of creeping up to the five-minute ceiling.
disconnect() is idempotent, is safe to call from inside a callback, and leaves no task,
timer, or socket behind. Your session is untouched either way.
Reduce CLASSIFY frames into detection episodes
CLASSIFY is a per-frame inference stream, not a detection event: 191 records on one
camera in 95 s, 0–2 s apart, with confidence swinging 8 → 97 between adjacent frames for a
single subject. EpisodeReducer turns that into one "a human was here, peak confidence
99". It is a pure component — no I/O, no timers, no asyncio — so it is equally usable
against a recording or a list of synthetic signals.
You own the clock. A pure reducer cannot notice that nothing has happened, so you
must call tick(now) periodically or an episode whose camera went quiet stays open
forever. This is the one obligation that fails silently if you skip it.
import asyncio
from datetime import UTC, datetime, timedelta
import aiohttp
from aiosecurityspy import (
EpisodeClosed,
EpisodeOpened,
EpisodeReducer,
ReducerConfig,
SecuritySpyClient,
)
async def main() -> None:
async with aiohttp.ClientSession() as session:
client = SecuritySpyClient(
session,
"nvr.example.com",
8001,
username="ha-readonly",
password="...",
use_https=True,
)
# An override REPLACES the default outright — it is not merged into it —
# so each one restates all three values rather than inheriting two.
reducer = EpisodeReducer(
default=ReducerConfig(threshold=70.0, debounce=3, gap=timedelta(seconds=30)),
overrides={
# A dim doorway camera: lower bar, everything else as above.
(4, None): ReducerConfig(threshold=50.0, debounce=3, gap=timedelta(seconds=30)),
# Vehicles are slower and larger: more evidence, longer memory.
(None, "vehicle"): ReducerConfig(
threshold=70.0, debounce=5, gap=timedelta(seconds=60)
),
},
)
def report(events: tuple[EpisodeOpened | EpisodeClosed, ...]) -> None:
for event in events:
episode = event.episode
verb = "started" if isinstance(event, EpisodeOpened) else "ended"
print(
f"camera {episode.camera}: {episode.object_class} {verb} "
f"peak={episode.peak_confidence:.0f} signals={episode.signal_count}"
)
stream = client.event_stream(on_event=lambda event: report(reducer.feed(event)))
await stream.connect()
try:
while True:
# The tick obligation. Anything comfortably shorter than your gap works.
await asyncio.sleep(5)
report(reducer.tick(datetime.now(UTC)))
finally:
await stream.disconnect()
# The stream is gone, so no further signal can arrive: end what is open
# rather than stranding it.
report(reducer.close_all(datetime.now(UTC)))
asyncio.run(main())
Worth knowing:
- The three defaults are provisional.
DEFAULT_DETECTION_THRESHOLD(70 %),DEFAULT_DETECTION_DEBOUNCE(3 signals) andDEFAULT_DETECTION_GAP(30 s) are starting points, not values verified against a real installation. Expect to tune them. - Threshold, debounce and gap are per camera per object class. Overrides resolve
(camera, class)→(camera, None)→(None, class)→ the default, and the first match wins whole. An override is a replacement, not a merge: any field it leaves out falls back to the provisional module default, not to thedefault=config you passed. Two override keys that normalize to the same pair ("Delivery Van"and"DELIVERY_VAN") are aValueErrorrather than a silent last-one-wins. - Episodes close on inactivity, never on low confidence. A run of below-threshold
frames is mid-episode, not the end of one — and
MOTION_ENDis far too unreliable to close anything with. peak_confidencecovers the whole span, including the debounce signals that opened the episode and any below-threshold frame inside it. It is never the value at the threshold crossing.endis the instant the episode lapsed (last_signal + gap), not thenowthat noticed. A late tick does not stretch an episode, and a signal arriving after the gap has already elapsed closes the stale episode before starting a fresh debounce run — so tick and arrival always agree about where the boundary was.add()only expires its own camera and class. A signal's timestamp is evidence about the camera that sent it; one camera with a fast clock must not end another camera's live episode. Sweeping everything istick(now)'s job, with your clock.reset()emits nothing on purpose: it means you stopped tracking, not that anything ended. Useclose_all(now)when you do want to claim the boundaries.close_allstampsend=now, raised to the episode's own last signal if yournowpredates it.- Two raw labels that slug the same are one episode. Both are kept in
raw_labels. Note thatclass_slug()keeps only[a-z0-9_]and falls back to"unknown", so labels written entirely in a non-Latin script all reduce under a single"unknown"episode per camera;raw_labelsis where they stay distinguishable.
Ask when a human was last seen
The event stream is transient and restarts at zero. ++caplist is SecuritySpy's
persisted record, so an answer derived from it is still correct after a restart.
async_get_captures() batches every camera into one request and lets the server do
the class filtering, so the cost is one request — not one per camera, and not
cameras × classes.
import asyncio
from datetime import UTC, datetime, timedelta
import aiohttp
from aiosecurityspy import SecuritySpyClient, SecuritySpyError
async def main() -> None:
async with aiohttp.ClientSession() as session:
client = SecuritySpyClient(
session,
"nvr.example.com",
8001,
username="ha-readonly",
password="...",
use_https=True,
)
today = datetime.now(UTC).date()
try:
captures = await client.async_get_captures(
[1, 2, 3],
start_date=today - timedelta(days=1),
end_date=today,
object_class="human",
)
except SecuritySpyError as err:
print(f"could not read capture history: {err}")
return
if not captures:
print("no human captures in the window")
return
newest = captures[0] # results come back newest first
print(f"camera {newest.camera}: human at {newest.start} ({newest.filename})")
print(f" classes={sorted(newest.object_classes)} type={newest.capture_type_name}")
asyncio.run(main())
Worth knowing:
- The date range is required and is never widened. How far back to look is your policy, not the library's.
object_classis filtered by the server, and SecuritySpy offers a filter forhuman,vehicleandanimalonly. Anything else raisesValueErrorbefore a request is issued rather than silently degrading into a fetch-everything scan. For the non-class filters (movies only, continuous capture only) passcapture_filter=with aCAPTURE_FILTER_*constant instead; passing both is aValueError. Note that these three filters select motion-capture movies of that class: a JPG capture or a continuous recording that carries the same class in itsobitmask is not returned by them.- Both date bounds are
dateobjects, notdatetimes. Adatetimeis rejected: the server matches folder dates, and an ISO instant is a query it cannot satisfy. - The whole response is read into memory and capped, and
caplistoffers no paging. A wide window over many cameras with no filter can exceed the cap and fail; narrowing the window or the filter is the fix. Capture.startis a timezone-aware UTC instant reconstructed from the folder date plus seconds-since-midnight, because the wire format carries no absolute time. Passserver_timezone=if your server does not run in UTC. An unreconstructable time isNone— never epoch, never zero — and those captures sort last. The wire format sends a wall-clock second-of-day with no fold bit, so on the one ambiguous local hour of a DST fall-back the earlier instant is chosen, and on a spring-forward day two captures in the skipped hour can reconstruct to the same instant.Capture.pathis a<camera>/<folderDate>/<filename>triple, not a URL. It is not percent-encoded — real filenames contain spaces — so quote it before use. An entry whose filename or folder date carries a path separator gets an emptypathrather than one that could address a different file.Capture.object_classesis the persisted classification, empty rather thanNonewhen the server recorded none.Capture.capture_typeis a bareinton purpose.caplist's type field andclip'smovieTypeshare a letter and mean different things, so there is no shared enumeration; useis_movieorcapture_type_name, and an unknown future value carries through rather than being rejected.
Read and change camera settings, and arm a camera
import asyncio
import aiohttp
from aiosecurityspy import (
ARM_OVERRIDE_ARMED_2_HOURS,
CameraSettingsPatch,
CaptureModes,
SecuritySpyClient,
SecuritySpyPermissionError,
arm_override,
require_permission,
)
async def main() -> None:
async with aiohttp.ClientSession() as session:
client = SecuritySpyClient(session, "nvr.example.com", username="viewer", password="secret")
# `require_permission` is a pure guard: it costs no round trip, so run
# it before touching a plane, and guard each call with the permission
# that call actually needs. `"camera_control"` covers the settings page
# -- both reading it and writing it; `"schedule"` covers arming. They
# are separate grants, so holding one says nothing about the other.
info = await client.async_get_server_info()
camera = info.cameras.get(3)
if camera is None:
print("camera 3 is not on this server")
return
try:
require_permission(camera, "camera_control")
except SecuritySpyPermissionError as err:
print(err)
return
# Read: the returned model carries only curated, credential-free fields.
settings = await client.async_get_camera_settings(3)
print(settings.name, settings.overlay_text, settings.motion_sensitivity)
print(settings.motion_capture_triggers_human) # a real bool, not 1/0
# Write: partial. Only the fields you set are sent; everything else on
# the ~120-key settings page keeps its value. No read-modify-write.
await client.async_set_camera_settings(
3,
CameraSettingsPatch(
overlay_text="Front Gate",
motion_capture_triggers_human=True,
motion_capture_triggers_vehicle=False,
),
)
# Arming: three independent booleans, so all eight combinations are
# expressible -- including all-false, which disarms all three.
try:
require_permission(camera, "schedule")
except SecuritySpyPermissionError as err:
print(err)
return
override = arm_override(ARM_OVERRIDE_ARMED_2_HOURS)
print(override.label, override.duration) # Armed For 2 Hours 2:00:00
await client.async_set_camera_arming(
3,
CaptureModes(continuous=False, motion=True, actions=True),
override=ARM_OVERRIDE_ARMED_2_HOURS,
)
# The camera's arm state comes back off ++systemInfo. `Camera` is
# frozen, so re-read it: the object fetched above still holds the
# pre-write state.
info = await client.async_get_server_info()
camera = info.cameras[3]
print(camera.capture_modes.mode_string, camera.schedules.motion_schedule_id)
asyncio.run(main())
Three things about this surface are worth stating plainly:
- The override is transient and bounded. It suspends the camera's schedule for the
stated duration and then the schedule resumes; it is not a permanent arm or disarm.
ARM_OVERRIDE_UNCHANGED(the default) leaves any existing override alone,ARM_OVERRIDE_NONEclears it, and the "until next scheduled event" values reportduration is Nonewithuntil_next_scheduledtrue.arm_override()rejects any value outside the published-1..14table rather than guessing. - Schedules are read-only.
Camera.schedulesreports the ids SecuritySpy assigned, and no method in this library reassigns one: the arming request sendscameraNum,modeandoverride, and neverschedule=. - A settings payload contains the camera's device credentials in plaintext.
CameraSettingstherefore keeps only a declared, curated set of non-credential fields — the raw payload is dropped at decode, never retained, and never logged at any level including debug. Itsrepris deliberately just the camera number.
Booleans read back from SecuritySpy as JSON true/false but must be written as
1/0. That asymmetry is absorbed inside the library, so a call site only ever sees
bool.
Anonymize a diagnostics dump before you publish it
The library keeps credentials out of its own models, logs, URLs and exceptions. What it
cannot do is see the object you are about to write to a diagnostics file, a bug report
or a log line. anonymize() is that tool, and redact_url() is the one you need before a
credential-bearing stream URL reaches a log or a subprocess argument — that exact leak has
been observed in the wild, where an external tool echoed an rtsp://user:pass@host/… URL
back verbatim.
from aiosecurityspy import CREDENTIAL_KEYS, anonymize, redact_url
# A raw ++settings-cameras page carries the camera's device credentials in
# plaintext. Structure survives; only the credential-shaped values go.
raw = {
"name": "Driveway",
"overlayText": "Front Gate",
"motionSensitivity": 55,
"passwordProtected": True, # not a credential — a fact worth keeping
"username": "camera-admin",
"password": "hunter2",
}
print(anonymize(raw))
# {'name': 'Driveway', 'overlayText': 'Front Gate', 'motionSensitivity': 55,
# 'passwordProtected': True, 'username': '**REDACTED**', 'password': '**REDACTED**'}
# Anything at all: a library model, an aiohttp.BasicAuth, a config mapping, a
# list of URLs. Pass your own known secrets when you have them — key matching
# cannot see a password embedded in a free-text field, but you know yours.
print(anonymize({"note": "login failed for hunter2"}, secrets=["hunter2"]))
# {'note': 'login failed for **REDACTED**'}
# A URL embedded in an ordinary message is redacted where it sits.
print(anonymize({"note": "connecting to rtsp://bob:hunter2@nvr.example.com/x"}))
# {'note': 'connecting to rtsp://**REDACTED**:**REDACTED**@nvr.example.com/x'}
# Before a URL is logged, printed, or handed to ffmpeg as an argument:
print(redact_url("rtsp://bob:hunter2@nvr.example.com:8000/++stream?auth=Ym9i&cameraNum=3"))
# rtsp://**REDACTED**:**REDACTED**@nvr.example.com:8000/++stream?auth=**REDACTED**&cameraNum=3
# One declared vocabulary, and it is the only place to extend.
print(sorted(CREDENTIAL_KEYS))
# ['apikey', 'auth', 'authorization', 'authtoken', 'bearer', 'cookie',
# 'credentials', 'pass', 'passphrase', 'passwd', 'password', 'privatekey',
# 'secret', 'sessionid', 'setcookie', 'token', 'username', 'xapikey']
Three things worth knowing about it:
aiosecurityspy.const.CREDENTIAL_KEYSis the single place to extend. Every redaction decision — inanonymize()and inredact_url()alike — routes throughis_credential_key(), which tests the key lowercased with non-alphanumerics stripped for exact membership in that one set. SoauthToken,auth_tokenandAUTH-TOKENall match, whilepasswordProtecteddoes not: it is a boolean telling you whether the camera uses authentication at all, and a substring test would have thrown it away. If a future SecuritySpy version grows a credential-shaped key, add it toaiosecurityspy.const.CREDENTIAL_KEYSand both redactors pick it up — they read that module attribute at call time, so the addition needs no other change.- It is fail-closed on shapes and fail-open on structure. Mappings,
NamedTuples (by field name —aiohttp.BasicAuthwalked positionally would yield["bob", "hunter2"]with no key to match on), dataclasses, lists, tuples, sets, strings and scalars are walked and preserved (a sequence that is neither a list nor a tuple, such as adeque, is not — like any other unrecognised shape it becomes its bare type name); a timestamp, duration,Decimal,UUID,PurePathorEnumrenders throughstr()so a capture dump keeps its times, and an exception is walked argument by argument rather than throughstr(), which on a multi-argument exception is thereprof its arguments and would publish a credential-bearing one; bytes become<bytes: 27>; anything else becomes its bare type name such as<ClientSession>, never itsrepr. Cycles and runaway nesting stop at<recursive>/<truncated>, a container that refuses to be walked becomes<unwalkable TypeName>, andanonymize()never raises. - There is no diagnostics-dump builder here, deliberately. The library supplies the
anonymizer; you decide what belongs in your own diagnostics.
anonymize()is pure — no network, no I/O, no logging, no Home Assistant — so it can never be the thing that fails.
Use a least-privileged SecuritySpy account
Create a dedicated SecuritySpy user for this library rather than reusing an administrator
account, and grant it only the per-camera permissions you actually need — typically
view live video and access captured files. Withhold delete files, camera
control, and arm/disarm unless a feature you use requires them. Each camera's
granted permissions are decoded for you into Camera.permission_names, so you can check
capability before attempting an operation.
Prefer HTTPS
Credentials are sent as HTTP Basic auth, which is only base64-encoded — over plain HTTP
anyone on the path can read them. Enable SecuritySpy's HTTPS listener and pass
use_https=True. SecuritySpy's certificate is issued for its DDNS hostname, so connecting
by LAN IP will fail verification; prefer configuring the hostname, and reach for
verify_ssl=False only when that is genuinely impossible.
Credentials never appear in a URL, a log line, an exception message, a repr, or a
traceback.
Status
Early development. The client, typed models, protocol constants, exception hierarchy, the event stream, capture history, the detection-episode reducer, the settings, arming and permission surface, and the credential anonymizer are all in place. The public API is not yet stable, and the reducer's three defaults are explicitly provisional.
Development
uv sync
uv run ruff check . && uv run ruff format --check .
uv run mypy --strict src tests
uv run pytest -q
License
MIT — see LICENSE.
Metadata
Release files for aiosecurityspy 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| aiosecurityspy-0.1.0.tar.gz | 163.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| aiosecurityspy-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 253.8 kB
Release files / aiosecurityspy-0.1.0.tar.gz
| Download URL | aiosecurityspy-0.1.0.tar.gz |
|---|---|
| Size | 163.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b4fce5d4e3836f56248b95a3e91d2c46971fa1a27b4fb8df0ac628c33304a883
|
|
BLAKE2b-256 checksum How to use checksums |
6cdcef176d89a9e5e68631ef8b30234d587d8105146861c3ab5786f9f2f1ca32
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / aiosecurityspy-0.1.0-py3-none-any.whl
| Download URL | aiosecurityspy-0.1.0-py3-none-any.whl |
|---|---|
| Size | 90.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0bbd3b2166cb7c83fe7c9f3cd70774fd633150d3f6b934e765b1eb2f6fe03046
|
|
BLAKE2b-256 checksum How to use checksums |
09daba281c054b2a552182e24aada03b1808520b4663346ceb2940429ababd82
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|