Metadata-Version: 2.4
Name: pyswann
Version: 0.0.25
Summary: Python client for Swann DVRs
Author-email: brendann993 <brendann993@icloud.com>
License-Expression: MIT
Project-URL: Documentation, https://github.com/brendann993/pyswann#readme
Project-URL: Issues, https://github.com/brendann993/pyswann/issues
Project-URL: Source, https://github.com/brendann993/pyswann
Project-URL: Download, https://github.com/brendann993/pyswann/releases
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Topic :: Home Automation
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: aiohttp>=3.9.0
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == "dev"
Requires-Dist: coverage>=7.6; extra == "dev"
Requires-Dist: ruff>=0.15.1; extra == "dev"
Requires-Dist: twine>=6.0; extra == "dev"
Dynamic: license-file

# pyswann

An unofficial, environment-agnostic, pure Python client under development for
Swann DVR/NVR hardware using the RaySharp client service on TCP port 9000.

This project is not affiliated with or endorsed by Swann or RaySharp. Protocol
support has been tested against one Swann recorder and firmware version; other
models and firmware may behave differently.

The project deliberately has no dependency on RaySharp's DLLs or shared
libraries. Protocol behaviour is added only after it is supported by SDK source
evidence and confirmed against the physical Swann recorder.

## Transport policy

This library is intentionally TCP-first. The native RaySharp client service on
TCP port 9000 remains the default path for authentication, session lifecycle,
heartbeat maintenance, runtime status, and live event delivery. The web API on
port 85 is kept for supplementary metadata and configuration enrichment when it
exposes the relevant data, but it is not the live runtime transport and it is
not the default path for motion or alarm handling.

Compatibility runs that intentionally prefer HTTP are useful as diagnostics and
coverage comparisons, but they are not the supported default architecture.

## Current capability

The package currently provides:

- a bounded asyncio TCP transport;
- explicit connection/session state;
- recorder-confirmed encrypted RaySharp login and response parsing;
- a managed authenticated session with encrypted type-32 heartbeats and a
  single protocol reader;
- framed, decrypted motion-alarm reception (`891/191`);
- combined pedestrian/vehicle (PD+VD) decoding with explicit ON/OFF state
  (`891/251`);
- a typed async event iterator currently yielding `MotionEvent` and `PdVdEvent`;
- typed read-only recorder and channel metadata from the HTTP API;
- lossless timestamped capture of every received byte;
- a diagnostic CLI that runs anywhere Python 3.12 runs;
- no dependency on any higher-level application framework; the library is
  intentionally standalone and reusable outside Home Assistant.

The physical recorder now confirms authentication, heartbeat maintenance, and
automatic motion-report delivery. Parallel channel/status bitmasks are decoded
into explicit per-channel ON/OFF transitions.

Only `MotionEvent` and `PdVdEvent` are currently implemented because those are
the event types validated against the available test recorder. RaySharp devices
may emit additional alarm families, but they remain unsupported until they can
be captured and decoded reliably.

## Normalized snapshot model

Both the TCP protocol client and the HTTP metadata client can be normalized into
a single downstream-friendly shape. This keeps the transport-specific raw payload
as diagnostics while giving callers one consistent contract for system/channel
status and capability discovery.

```python
from swann.normalized import RecorderSnapshot

snapshot: RecorderSnapshot
```

The normalized shape keeps the common fields aligned across both transports,
matching the benchmark channel summary minus the timing/elapsed fields. It
includes the available alarm configuration list as a structured payload, but it
keeps the raw transport data separate for diagnostics.

```python
{
    "source": "tcp",
    "system": {
        "name": "DVR84580RN",
        "model": "DVR8-4580RN",
        "firmware_version": "V8.2.2-20240820",
        "mac_address": "BC-51-FE-E2-EC-CB",
        "channel_count": 8,
    },
    "channels": [
        {
            "channel": 1,
            "alias": "Front Door",
            "enabled": True,
            "record_state": "idle",
            "motion_state": "inactive",
            "video_loss_state": "normal",
            "state": "online",
            "mainstream_url": "rtsp://10.1.1.118:554/rtsp/streaming?channel=01&subtype=0",
            "substream_url": "rtsp://10.1.1.118:554/rtsp/streaming?channel=01&subtype=1",
        }
    ],
    "capabilities": {
        "motion": True,
        "pid": False,
        "lcd": False,
        "pd": False,
        "sod": False,
        "video_loss": True,
    },
    "alarm_configurations": [
        {
            "alarm_type": "PD",
            "error": None,
            "channels": [
                {
                    "channel": 1,
                    "enabled": False,
                    "sensitivity": 60,
                    "detection_type": "Pedestrian & Vehicle",
                    "uses_ai_parameters": None,
                    "source": "tcp",
                    "unavailable_reason": None,
                    "rules": [
                        {
                            "number": 1,
                            "enabled": True,
                            "rule_type": "Normal",
                            "points": [
                                {"x": 30, "y": 30},
                                {"x": 30, "y": 546},
                            ],
                        }
                    ],
                }
            ],
        }
    ],
    "storage": {
        "name": "DVR84580RN",
        "model": "DVR8-4580RN",
        "hardware_version": "V8.2.2-20240820",
        "software_version": "V8.2.2-20240820",
        "hdd_volume": "1.8G",
        "ip_address": "10.1.1.118",
        "storage": {
            "drives": [
                {
                    "channel_mask": 1,
                    "state": 2,
                    "total_size": 2000000000,
                    "free_size": 1500000000,
                    "free_record": 0,
                    "drive_type": 0,
                    "drive_number": 1,
                    "free_record_minutes": 0,
                }
            ]
        }
    },
    "raw": {
        "transport": "tcp",
        "payload": {"...protocol-specific fields...": "..."},
    },
}
```

The raw transport payload is deliberately kept in a separate `raw` field so the
common consumer path remains stable regardless of whether the data originated in
the TCP protocol or the HTTP metadata API.

### Snapshot usage

```python
from swann import SwannClient

client = SwannClient(
    "192.0.2.1",
    username="admin",
    password="secret",
)

async with client:
    tcp_snapshot = client.snapshot()
    print(tcp_snapshot.source)
    print(tcp_snapshot.channels[0].alias)
    print(tcp_snapshot.channels[0].mainstream_url)

    # Optional: when HTTP metadata was explicitly collected, surface the same
    # normalized contract using the HTTP adapter path.
    if client.http_metadata is not None:
        http_snapshot = client.snapshot(source="http")
        print(http_snapshot.source)
        print(http_snapshot.channels[0].alias)
```

Example TCP-normalized output:

```python
{
    "source": "tcp",
    "system": {
        "name": "DVR84580RN",
        "model": "DVR8-4580RN",
        "firmware_version": "V8.2.2-20240820",
        "channel_count": 8,
    },
    "channels": [
        {
            "channel": 1,
            "alias": "Front Door",
            "enabled": True,
            "record_state": "idle",
            "motion_state": "inactive",
            "video_loss_state": "normal",
            "state": "online",
            "mainstream_url": "rtsp://10.1.1.118:554/rtsp/streaming?channel=01&subtype=0",
            "substream_url": "rtsp://10.1.1.118:554/rtsp/streaming?channel=01&subtype=1",
        }
    ],
    "capabilities": {
        "motion": True,
        "pid": False,
        "lcd": False,
        "pd": False,
        "sod": False,
        "video_loss": True,
    },
    "alarm_configurations": [
        {
            "alarm_type": "PD",
            "error": None,
            "channels": [
                {
                    "channel": 1,
                    "enabled": False,
                    "sensitivity": 60,
                    "detection_type": "Pedestrian & Vehicle",
                    "source": "tcp",
                    "rules": [
                        {
                            "number": 1,
                            "enabled": True,
                            "rule_type": "Normal",
                            "points": [{"x": 30, "y": 30}, {"x": 30, "y": 546}],
                        }
                    ],
                }
            ],
        }
    ],
    "storage": {
        "name": "DVR84580RN",
        "model": "DVR8-4580RN",
        "hardware_version": "V8.2.2-20240820",
        "software_version": "V8.2.2-20240820",
        "hdd_volume": "1.8G",
        "ip_address": "10.1.1.118",
        "storage": {"drives": [{"drive_number": 1, "total_size": 2000000000, "free_size": 1500000000}]},
    },
    "raw": {
        "transport": "tcp",
        "payload": {...},
    },
}
```

Example HTTP-normalized output:

```python
{
    "source": "http",
    "system": {
        "name": "DVR84580RN",
        "model": "DVR8-4580RN",
        "firmware_version": "V8.2.2-20240820",
        "channel_count": 8,
    },
    "channels": [
        {
            "channel": 1,
            "alias": "Front Door",
            "enabled": True,
            "record_state": "unknown",
            "motion_state": "unknown",
            "video_loss_state": "unknown",
            "state": "online",
            "mainstream_url": "rtsp://10.1.1.118:554/rtsp/streaming?channel=01&subtype=0",
            "substream_url": "rtsp://10.1.1.118:554/rtsp/streaming?channel=01&subtype=1",
        }
    ],
    "capabilities": {
        "motion": True,
        "pid": False,
        "lcd": False,
        "pd": False,
        "sod": False,
        "video_loss": True,
    },
    "alarm_configurations": [
        {
            "alarm_type": "PD",
            "error": None,
            "channels": [
                {
                    "channel": 1,
                    "enabled": True,
                    "sensitivity": 60,
                    "detection_type": "Pedestrian & Vehicle",
                    "source": "http",
                    "rules": [
                        {
                            "number": 1,
                            "enabled": True,
                            "rule_type": "Normal",
                            "points": [{"x": 30, "y": 30}, {"x": 30, "y": 546}],
                        }
                    ],
                }
            ],
        }
    ],
    "storage": {
        "name": "DVR84580RN",
        "model": "DVR8-4580RN",
        "hardware_version": "DM-441",
        "software_version": "V8.2.2-20240820",
        "hdd_volume": "931G",
        "ip_address": "10.1.1.118",
        "storage": {"drives": [{"id": 1, "display_id": 1, "status": "ok", "total_size": 1000000000, "free_size": 800000000}]},
    },
    "raw": {
        "transport": "http",
        "payload": {...},
    },
}
```

The managed client still behaves as a TCP event stream: the event iterator and
heartbeat/reader tasks continue to run in the background, and the snapshot API
simply exposes the normalized current state without replacing the live event
delivery path.

## Managed client

The primary library API owns authentication, heartbeat maintenance, packet
reading, and typed event dispatch for the lifetime of the context manager:

```python
from swann import MotionEvent, PdVdEvent, SwannClient

client = SwannClient(
    "ip.address or hostname",
    username="admin",
    password="recorder-password",
)

async with client:
    info = client.device_info
    if info is not None:
        print(info.model, info.firmware_version, info.preferred_identifier)

    async for event in client.events():
        if isinstance(event, MotionEvent):
            print("motion", event.channel, event.active)
        elif isinstance(event, PdVdEvent):
            print("PD+VD", event.channel, event.active)
```

`RaySharpClient` remains available as a compatibility alias for existing
callers; new code should use `SwannClient`.

For setup or validation flows that need one initial snapshot but should not
keep a runtime session open, use `identify()`. It collects the configured
primary transport's required state, returns a ready normalized snapshot, and
closes the temporary session automatically:

```python
client = SwannClient(
    "ip.address or hostname",
    username="admin",
    password="recorder-password",
    http_primary=True,
)
initial_snapshot = await client.identify()
```

Use the normal context-manager and `events()` flow when the client should stay
connected for live updates.

`PdVdEvent` intentionally represents the combined DVR category. Controlled
captures show that pedestrian and vehicle detections use the same wire subtype
and alarm value, so the low-level library does not invent a distinction that
the recorder does not transmit.

After authentication, `client.device_info` contains confirmed `LoginRsp`
fields including model, recorder name, firmware version, serial number, MAC
address, channel counts, and raw advertised push/control flags. The preferred
stable identifier uses the serial number when present and otherwise falls back
to the MAC address.

The managed client can also collect supplementary HTTP metadata after TCP login
when the caller opts in with `collect_http_metadata=True`. `client.http_metadata`
contains the recorder model, hardware and software versions, storage/video
details, and typed channel aliases, enabled states, and stream descriptions.
HTTP failure is exposed as `client.http_metadata_error` and does not stop TCP
event delivery. TCP port 9000 remains the primary transport for the live
session, heartbeat management, and runtime event/state information. The HTTP
API serves as an optional enrichment source for human-readable metadata and
configuration whenever it exposes the relevant data.

Each `ChannelMetadata` also exposes `mainstream_url` and `substream_url`.
These use explicit URL fields when a recorder returns them; otherwise they use
the confirmed RaySharp RTSP path with the channel number formatted as `01`,
`02`, and so on.

The client accepts an injected `aiohttp.ClientSession` when a caller already has
one, but it never closes a caller-owned session. For standalone use, omitting
`websession` creates an internal session which is closed with the client. The
recorder HTTP flow uses Digest authentication plus the returned CSRF token and
session cookie. Credentials are not logged, and the sensitive P2P identifier
returned by the recorder is intentionally not included in `SystemMetadata`.

HTTP metadata collection includes the Motion alarm configuration endpoint. It
exposes Motion through `IntelligentAlarmType.MOTION` with the typed channel
configuration model, and this is the preferred config source when the recorder
provides it.

Compatibility and fallback helpers still exist for low-level TCP parameter
queries via `await client.refresh_alarm_configurations()`, but project policy is
to keep TCP as the primary transport for live alarms/events and runtime state,
with HTTP as an optional enrichment source for recorder metadata and
configuration when it exposes the relevant data. HTTP metadata collection is
opt-in, while TCP startup state remains the default runtime path.

TCP media/configuration refreshes remain available as compatibility helpers,
but they are not the default path for normal operation and should not be used
as an eager fallback simply because another transport is slower. The slow
optional Living/preview page 573 is disabled by default and must be requested
explicitly with `include_living=True`.

HDD, Motion, Abnormal, and AVD status pages are still available as optional
TCP compatibility queries, but they are not the primary source when the web
API exposes equivalent data. Set `collect_status_configurations=False` to skip
these pages entirely. Refresh failures remain available through
`client.status_configuration_errors`, and the last attempt time is exposed as
`client.status_configuration_refreshed_at`.

Alarm configuration uses a primary/fallback policy. TCP remains the primary
transport for live runtime status and compatibility pages, while HTTP is used
as an optional enrichment/fallback source for human-readable configuration when
it exposes the values. A TCP query should not be replaced by HTTP merely because
it is slower; fallback should happen only on actual failure or missing data.

Use `--http-primary-test --status-output captures/http-primary.json` as an
explicit HTTP-primary path. This collects system/channel metadata and alarm
configuration through HTTP and starts the modern `/API/Event/Check` long-poll
event chain. It does not open the TCP runtime session. The default CLI path
continues to use TCP for authentication, heartbeats, runtime state, and live
alarm events; `--tcp-primary` makes that choice explicit. The HTTP event path
is polling rather than an unsolicited push stream and should remain a separate
compatibility option until its coverage and reconnect behavior are validated.

## Diagnostic connection

Copy `.env.example` to `.env`, then replace the password value. `.env` is
ignored by Git and loaded automatically by the diagnostic CLI:

```bash
cp .env.example .env
```

```dotenv
RAYSHARP_HOST=ip.address or hostname
RAYSHARP_PORT=9000
RAYSHARP_HTTP_PORT=85
RAYSHARP_USERNAME=admin
RAYSHARP_PASSWORD=your-recorder-password
RAYSHARP_LOGIN_VARIANT=crypto
```

Then run from the project directory:

```bash
py -m swann.cli --output captures/connection.jsonl
```

To write an authenticated TCP status snapshot for review, use
`--status-output`. This reports recorder identity, channel names and
availability, motion/recording/video-loss masks, and intelligent-analysis
capabilities without writing credentials or the raw login request:

```bash
py -m swann.cli --status-output captures/status.json
```

The status snapshot can be combined with the raw JSON Lines capture:

```bash
py -m swann.cli --output captures/connection.jsonl --status-output captures/status.json
```

Existing exported environment variables take precedence over `.env`. Set
`PYSWANN_ENV_FILE` to load a different file.

PowerShell environment variables are also supported:

```powershell
$env:RAYSHARP_HOST = "ip.address or hostname"
$env:RAYSHARP_PORT = "9000"
$env:RAYSHARP_USERNAME = "admin"
$env:RAYSHARP_PASSWORD = "your-recorder-password"

py -m swann.cli --output captures\connection.jsonl
```

This sends the recorder-confirmed encrypted `CryptoMsgLogin` packet and then an
encrypted heartbeat every five seconds. Password-bearing transmit packets are
always omitted from logs and captures. Incoming motion reports are logged as
human-numbered channel transitions while retaining both raw bitmasks.

See [`docs/protocol.md`](docs/protocol.md) for established findings, observed
packet layouts, and remaining protocol questions.
