Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 39 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,12 @@ with ISAPIClient('X.X.X.X', port=80, username='admin', password='1234') as clien
* `reboot()` - reboot the device
* `custom_request(...)` - issue an arbitrary ISAPI request for endpoints not otherwise wrapped
* `request_raw(method, endpoint, body=None)` - like `custom_request` but sends the body unmodified and returns the unparsed response (`RawResponse`: status code, bytes, case-insensitive headers) without raising on HTTP error statuses (after a 401 the request is retried once with Digest auth)
* `get_image_settings(channel=1)` - list the image settings a camera supports (shutter, gain, WDR, BLC, day/night, ...) with their current values (`ImageSetting` objects, see `pyhik.image`)
* `get_image_values(channel=1)` - read only the current values as `{path: value}`; cheap enough to poll
* `set_image_setting(path, value, channel=1)` - change one image setting; known conflicts between settings are resolved automatically
* `set_image_linked_setting(setting, value, channel=1)` - set a select that also controls an `enabled` flag, such as BLC mode
* `put_image_settings(changes, channel=1)` - lower level: write several `{path: value}` changes in one request, without conflict handling
* `get_image_settings(channel=1, proxy=False)` - list the image settings a camera supports (shutter, gain, WDR, BLC, day/night, ...) with their current values (`ImageSetting` objects, see `pyhik.image`)
* `get_image_values(channel=1, proxy=False)` - read only the current values as `{path: value}`; cheap enough to poll
* `set_image_setting(path, value, channel=1, proxy=False)` - change one image setting; known conflicts between settings are resolved automatically
* `set_image_linked_setting(setting, value, channel=1, proxy=False)` - set a select that also controls an `enabled` flag, such as BLC mode
* `put_image_settings(changes, channel=1, proxy=False)` - lower level: write several `{path: value}` changes in one request, without conflict handling
* `get_image_channels(proxy=False)` - list the channel IDs that have image settings (`ImageChannelInfo`)

### Image settings

Expand All @@ -116,7 +117,13 @@ settings["Color/brightnessLevel"].value # '80'
settings["Shutter/ShutterLevel"].options # ['1/3', '1/6', ..., '1/100000']
```

Reading raises `ISAPINotSupportedError` (a subclass of `ISAPINotFoundError`) if the device does not offer the endpoint (404, 501 or a `notSupport` reply), `ISAPIAuthError` for credential or permission problems and `ISAPIConnectionError` if the device cannot be reached. These exceptions carry `status_code` and `sub_status`, the HTTP status and the device's `subStatusCode`.
Reading raises:

* `ISAPINotSupportedError` (a subclass of `ISAPINotFoundError`) if the device does not offer the endpoint (404, 501 or a `notSupport` reply)
* `ISAPIAuthError` for credential or permission problems
* `ISAPIConnectionError` if the device cannot be reached

These exceptions carry `status_code` and `sub_status`, the HTTP status and the device's `subStatusCode`.

Changing a setting:

Expand All @@ -125,7 +132,32 @@ result = client.set_image_setting("Shutter/ShutterLevel", "1/500")
result.success, result.sub_status # True, 'ok'
```

A write reads the full document, changes only the target value as text and sends it back, which the devices require. Some settings conflict (enabling WDR while BLC or HLC is on, and the reverse); the setting in the way is switched off first. Firmware that reports the HLC conflict as `HLCMutexWDROrDehaze` or `WDROrDehazeMutexHLC` (HTTP 403) is handled the same way: HLC is switched off when WDR or defog is turned on, and WDR and defog are switched off when HLC is turned on. The settings that were switched off to make room are listed in `ImageWriteResult.also_changed` (for example `{"HLC/enabled": "false"}`). If a conflict cannot be resolved, `ImageWriteResult.error_description` explains it. Cameras that reject writes to the full document with `notSupport` but accept a per-feature sub-endpoint (for example `/ISAPI/Image/channels/1/Shutter`) are handled automatically when all the changes belong to the same feature (for example `BLC/enabled` and `BLC/BLCMode`). Writes are not atomic, so avoid overlapping writes to the same device.
A write reads the full document, changes only the target value and sends it back, which is what the devices require.

* Some settings conflict (enabling WDR while BLC or HLC is on and the reverse); the setting in the way is switched off first.
* Firmware that reports the HLC conflict as `HLCMutexWDROrDehaze` or `WDROrDehazeMutexHLC` (HTTP 403) is handled the same way: HLC is switched off when WDR or defog is turned on; WDR and defog are switched off when HLC is turned on.
* The settings that were switched off to make room are listed in `ImageWriteResult.also_changed` (example `{"HLC/enabled": "false"}`).
* If a conflict cannot be resolved `ImageWriteResult.error_description` has the explanation.
* Cameras that reject writes to the full document with `notSupport` but accept a per-feature sub-endpoint (example `/ISAPI/Image/channels/1/Shutter`) are handled automatically when all the changes belong to the same feature (example `BLC/enabled` and `BLC/BLCMode`).
* Writes are not atomic, avoid overlapping writes to the same device.

For the cameras attached to an NVR, pass `proxy=True` (`/ISAPI/ContentMgmt/ImageProxy/channels/<id>`) and use the IDs returned by `get_image_channels(proxy=True)`:

```python
for channel in client.get_image_channels(proxy=True):
settings = client.get_image_settings(channel.id, proxy=True)
```

* The sub-endpoint fallback is not used through the NVR.
* An NVR may list a channel whose camera it cannot proxy; reading it raises `ISAPINotSupportedError`.

Recorders do not always report what they are; be ready to fall back to the NVR endpoints:

* A recorder may report `deviceType` `IPC`, the type a camera reports; the type alone cannot tell an NVR from a camera.
* If the direct listing fails, try `get_image_channels(proxy=True)`.
* An NVR may reject the camera endpoints (`/ISAPI/Image/channels`) with HTTP 503 `serviceUnavailable` or 400 `badXmlContent`. pyhik raises a plain `ISAPIError` for these; catching only `ISAPINotSupportedError` will not catch them.
* `ISAPINotSupportedError` only means that the device says it does not offer the endpoint: HTTP 404, 501 or a `notSupport` reply.
* For any other error, check `status_code` and `sub_status` on the exception.

# TODO

Expand Down
4 changes: 4 additions & 0 deletions pyhik/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@
DeviceCapabilities,
)
from pyhik.image import (
ImageChannelInfo,
ImageSetting,
ImageSettingType,
ImageWriteResult,
parse_image_capabilities,
parse_image_channels,
parse_image_values,
)

Expand Down Expand Up @@ -62,9 +64,11 @@
'EventState',
'DeviceCapabilities',
# Image settings
'ImageChannelInfo',
'ImageSetting',
'ImageSettingType',
'ImageWriteResult',
'parse_image_capabilities',
'parse_image_channels',
'parse_image_values',
]
52 changes: 52 additions & 0 deletions pyhik/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,3 +568,55 @@ def apply_enable_and_mode(
else:
edited = raw_insert_after(edited, enabled_path, mode_path, mode_value)
return edited


@dataclass
class ImageChannelInfo:
"""One entry of a device's image channel list.

Attributes:
id: The channel ID to pass back to the image methods unchanged.
enabled: Whether the channel is enabled, if reported.
input_port: The video input port, if reported (``inputPort`` on an
NVR, ``videoInputID`` on a camera).
"""

id: int
enabled: Optional[bool] = None
input_port: Optional[int] = None


def parse_image_channels(xml: Union[str, bytes]) -> List[ImageChannelInfo]:
"""Parse a list of image channels (``ImageChannellist``).

Namespace-agnostic. A document whose root is a single ``ImageChannel`` is
accepted too. Entries without a usable integer ``id`` are skipped, and a
repeated ``id`` is listed once.
"""
root = _parse(xml)
if _strip_ns(root.tag) == "ImageChannel":
nodes = [root]
else:
nodes = [e for e in root.iter() if _strip_ns(e.tag) == "ImageChannel"]

channels: List[ImageChannelInfo] = []
seen: Set[int] = set()
for node in nodes:
raw_id = _child_text(node, "id")
try:
channel_id = int(raw_id)
except ValueError:
continue
if channel_id in seen:
continue
seen.add(channel_id)
enabled_text = _child_text(node, "enabled").lower()
port_text = _child_text(node, "inputPort") or _child_text(node, "videoInputID")
channels.append(
ImageChannelInfo(
id=channel_id,
enabled=(enabled_text == "true") if enabled_text else None,
input_port=int(port_text) if port_text.isdigit() else None,
)
)
return channels
Loading
Loading