Skip to content
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
python-version: ${{ matrix.python-version }}

- name: Install package with test dependencies
run: python -m pip install --upgrade pip && pip install -e .[isapi]
run: python -m pip install --upgrade pip && pip install -e .

- name: Run tests
run: python -m unittest discover -s test -v
37 changes: 29 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ Code is licensed under the MIT license.

* Python 3.10+
* [requests](https://pypi.org/project/requests/) >= 2.20.0
* [xmltodict](https://pypi.org/project/xmltodict/) >= 0.13.0

Optional:
* `xmltodict` >= 0.13.0 - required for `ISAPIClient` (install with the `isapi` extra)
* [pyDispatcher](https://pypi.org/project/PyDispatcher/) 2.0.5 - only needed if you want `HikCamera` to also broadcast updates via `pydispatch` signals; the built-in callback mechanism (`add_update_callback`) works without it

# Installation
Expand All @@ -24,12 +24,6 @@ Optional:
pip install pyhik
```

To also use `ISAPIClient`:

```
pip install pyhik[isapi]
```

# Usage - Event streaming (`HikCamera`)

```python
Expand Down Expand Up @@ -125,7 +119,34 @@ 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.
#### Write Mechanism

* **Read-Modify-Write:** A write operation reads the full document, changes only the target value as text, and sends the updated document back to the device.
* **Non-Atomic Operations:** Writes are **not atomic**—avoid overlapping or concurrent writes to the same device.

#### Conflict Resolution

Some settings inherently conflict with one another (e.g., enabling WDR while BLC or HLC is active, and vice versa). When a conflict occurs:

* **Automatic Override:** The conflicting setting is automatically switched off first to make room for the new setting.
* **Firmware Error Codes:** Firmware reporting conflicts as `HLCMutexWDROrDehaze` or `WDROrDehazeMutexHLC` (HTTP 403) is handled identically:
* HLC is switched off when WDR or defog is turned on.
* WDR and defog are switched off when HLC is turned on.

* **Result Tracking:**
* `ImageWriteResult.also_changed` lists the settings that were automatically disabled (e.g., `{"HLC/enabled": "false"}`).
* `ImageWriteResult.error_description` explains any conflicts that could not be resolved.

#### Fallback Handling (Sub-Endpoints)

* **`notSupport` Handling:** If a camera rejects writes to the full document with a `notSupport` error, the system automatically attempts to use per-feature sub-endpoints (example `/ISAPI/Image/channels/1/Shutter`).
* **Condition:** This fallback only triggers when all requested changes belong to the **same feature** (such as `BLC/enabled` and `BLC/BLCMode`).

### Checking a device

`python -m pyhik check` tries the image settings code against a real camera or NVR.
[TESTING.md](TESTING.md) has the instructions to use it and can be used to report a
problem.

# TODO

Expand Down
135 changes: 135 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Testing the image settings on a real camera or NVR

The unit tests (`python -m unittest discover test -v`) never touch a device. This page is
for trying the code on real hardware before it is released and for reporting what
happens. `python -m pyhik check` does the work.

## Before you start

- Use an account that has remote configuration rights. The script never prints the
password and stops at the first authentication error.
- Everything that changes a value puts it back afterwards, even after an error. If it
could not, it says `NOT RESTORED` and tells you the value to set by hand.
- Try a camera you do not mind experimenting. The changes are small (one brightness
step, WDR and HLC toggled, or the one setting you pick with `--path`) but they are
real writes.

```bash
git clone git@github.com:mezz64/pyHik.git
cd pyhik
python -m venv .venv && . .venv/bin/activate && pip install -e .
```

## Required Environment Variables

```bash
export CAMERA_HOST=192.168.1.100 # IP address or hostname of your camera or NVR.
export CAMERA_USERNAME=admin
export CAMERA_PASSWORD='passwd123'
```
> For security, the password is read exclusively from the environment.

## Optional Environment Variables

```bash
export CAMERA_PORT=80 # Custom port (default: 80; 443 enables HTTPS)
export CAMERA_SSL=1 # Enable SSL/HTTPS
export CAMERA_VERIFY_SSL=0 # Disable SSL cert verification
```

You may also use arguments:

* `--host IP/hostname` (`CAMERA_HOST=...`)
* `--username admin ` (`CAMERA_USERNAME=...`)
* `--port 443` (`CAMERA_PORT=...`)
* `--ssl` (`CAMERA_SSL=1`)
* `--no-verify` (`CAMERA_VERIFY_SSL=0`)

## What to run

| Command | What it checks | Changes the device? |
|---|---|---|
| `python -m pyhik check` | channels, settings (with options and ranges), current values | No |
| `python -m pyhik check --write` | changes one number by one step (brightness), reads it back, restores it. With `--path` it can change a switch or a select instead (see below). If the change switched another setting off to make room, that one is put back too | Briefly |
| `python -m pyhik check --conflicts` | WDR, HLC, BLC and defog cannot all be on together. For every pair that the device offers it turns one on, then the other and prints a table of what happened. Checks that pyhik switches the other one off by itself and shows the refusal code the device used | Briefly (restores everything) |
| `python -m pyhik check --bad-password` | a wrong password is reported as an authentication failure by each client: `ISAPIAuthError` 401 from `ISAPIClient` and `HikCamera.auth_failed` | No but it makes at least two failed logins and the device may log them. Do not use it on an account close to being locked |
| `python -m pyhik check --all` | all of the above | Briefly |
| add `--proxy` / `--no-proxy` | force the NVR (ImageProxy) or the camera endpoints. Normally not needed: the script uses the NVR endpoints for an NVR or DVR, and for any device whose camera image endpoints fail while the NVR endpoints work | as above |
| add `--channel N` | use channel ID N instead of the first listed | as above |
| add `--path Some/path` | with `--write`, change this setting instead of brightness. Take the path from the list the read-only run prints (a plain `--write` also suggests a few switch and select paths). A number moves one step, a switch flips (true to false or false to true), a select takes the first option that is not the current one. Always put back afterwards | as above |
| add `-v` | print pyhik's debug log, which shows the retries and conflict handling | |

Each check ends with `PASS`, `FAIL` or `SKIP` and the run ends with a summary. The exit
code is 0 when nothing failed, 1 when a check failed and 2 when the device could not be
reached or refused the login.

A `SKIP` is normal: for example `--conflicts` is skipped on a device that offers fewer than
two of WDR, HLC, BLC and defog, and `--write --path` is skipped when the path is not in the
list or the setting has no other value to try.

### Changing a switch or a select

```bash
python -m pyhik check --channel 1 --write --path enableImageLossDetection
```

The script prints what it changes (`path (switch): true -> false`), the result, the value
read back, and `restore ...: restored`. Use the exact path from the read-only run: an NVR
can list settings with other names than a camera does.

### What the `--conflicts` table looks like

```
already on | turn on: WDR=open WDR=auto HLC BLC=CENTER
-------------------------------------------------------------------------
WDR=open | - - > >
HLC | > > - =
BLC=CENTER | > > = -

= both can be on > the other one was switched off, the change worked
X refused ? could not be tried - same setting
WDR on, then HLC: WDR was switched off (by pyhik: WDR/mode) after WDROrDehazeMutexHLC
HLC on, then WDR=open: HLC was switched off (by pyhik: HLC/enabled) after HLCMutexWDROrDehaze
...
```

Read it as "with the row on, turn the column on". `>` is the normal result on a camera that
has the conflict: the device refused with the code shown, pyhik switched the other setting off
and the change went through. `=` means this device allows both. `X` is a failure: the device
refused and pyhik did not know how to resolve it. That is the case to report, with the code
shown. The cases depend on which of WDR, HLC, BLC and defog your device offers; with fewer
than two the check is skipped. The camera's original values are put back at the end
(`restore: restored`).

## NVRs

Nothing special is needed: the script asks the device for its type and, for an NVR or DVR (or a device
whose camera endpoints fail while the NVR endpoints work), lists the attached cameras as channels and reads and writes them through the ImageProxy
endpoints. It prints `proxy endpoints: yes ...` with the reason, so you can see what it chose.
`--write` and `--conflicts` act on the first channel, or on the one given with `--channel`,
so you can test one camera behind the NVR at a time. Run the read-only check first.
If you report an issue, include if the NVR is a Hikvision or a re-branded one.

### When the device information cannot be read

If the device information request fails, the script prints, for example,
`device: model=? (ISAPIError: Request failed with status 503), ...` and carries on: it asks
only once, and falls back to trying the camera endpoints and then the NVR endpoints. That line
is not a failure of the check. Please include it in a report, because it has the error.

If the error says `xmltodict is required`, the `xmltodict` package is missing from your
Python environment: run `pip install xmltodict` and try again.

## Reporting a problem

Open an issue (or paste into the pull request) with:

1. The full output of the run, with `-v` for anything that failed. The script already
prints the pyhik version and the model, type and firmware of the device. Remove the
host name, IP addresses and anything else that identifies you.
2. Whether it is a camera or an NVR and whether it is rebranded.
3. What you expected and what happened, including whether the setting really changed
on the device (its own web page shows the truth).
4. The exact command you ran.

Do not include the password, serial number or MAC address.
36 changes: 36 additions & 0 deletions pyhik/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""``python -m pyhik <command>``: command line tools that come with pyhik.

Commands are imported only when they are asked for, so nothing here costs anything
for programs that use pyhik as a library.
"""

import importlib
import sys
from typing import Optional, Sequence

COMMANDS = {
"check": "try the image settings against a real camera or NVR",
}


def usage() -> str:
width = max(len(name) for name in COMMANDS)
lines = ["usage: python -m pyhik <command> [options]", "", "commands:"]
lines += [" %-*s %s" % (width, name, text) for name, text in COMMANDS.items()]
lines += ["", "Run python -m pyhik <command> --help for the options of a command."]
return "\n".join(lines)


def main(argv: Optional[Sequence[str]] = None) -> int:
args = list(sys.argv[1:] if argv is None else argv)
if not args or args[0] in ("-h", "--help"):
print(usage())
return 0 if args else 2
if args[0] not in COMMANDS:
print("pyhik: unknown command %r\n\n%s" % (args[0], usage()), file=sys.stderr)
return 2
return importlib.import_module("pyhik." + args[0]).main(args[1:])


if __name__ == "__main__":
sys.exit(main())
Loading
Loading