proconip 2.1.2__tar.gz → 2.2.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.
- {proconip-2.1.2 → proconip-2.2.0}/CHANGELOG.md +7 -0
- {proconip-2.1.2 → proconip-2.2.0}/PKG-INFO +1 -1
- {proconip-2.1.2 → proconip-2.2.0}/src/proconip/__init__.py +8 -0
- {proconip-2.1.2 → proconip-2.2.0}/src/proconip/_version.py +2 -2
- {proconip-2.1.2 → proconip-2.2.0}/src/proconip/api.py +146 -0
- {proconip-2.1.2 → proconip-2.2.0}/src/proconip/definitions.py +50 -0
- {proconip-2.1.2 → proconip-2.2.0}/tests/test_api.py +126 -0
- {proconip-2.1.2 → proconip-2.2.0}/tests/test_definitions.py +43 -0
- {proconip-2.1.2 → proconip-2.2.0}/.gitignore +0 -0
- {proconip-2.1.2 → proconip-2.2.0}/LICENSE +0 -0
- {proconip-2.1.2 → proconip-2.2.0}/README.md +0 -0
- {proconip-2.1.2 → proconip-2.2.0}/pyproject.toml +0 -0
- {proconip-2.1.2 → proconip-2.2.0}/src/proconip/py.typed +0 -0
- {proconip-2.1.2 → proconip-2.2.0}/tests/conftest.py +0 -0
- {proconip-2.1.2 → proconip-2.2.0}/tests/fixtures/get_dmx.csv +0 -0
- {proconip-2.1.2 → proconip-2.2.0}/tests/fixtures/get_state.csv +0 -0
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.2.0](https://github.com/ylabonte/proconip-pypi/compare/v2.1.2...v2.2.0) (2026-06-14)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add digital input triggering via WEBIO ([#93](https://github.com/ylabonte/proconip-pypi/issues/93)) ([8c35420](https://github.com/ylabonte/proconip-pypi/commit/8c354202ded3bbe389d6a53c0059b78d14337b97))
|
|
9
|
+
|
|
3
10
|
## [2.1.2](https://github.com/ylabonte/proconip-pypi/compare/v2.1.1...v2.1.2) (2026-06-14)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proconip
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Async Python library for interacting with the ProCon.IP pool controller.
|
|
5
5
|
Project-URL: Homepage, https://github.com/ylabonte/proconip-pypi
|
|
6
6
|
Project-URL: Documentation, https://ylabonte.github.io/proconip-pypi/
|
|
@@ -11,8 +11,10 @@ except ImportError: # pragma: no cover
|
|
|
11
11
|
__version__ = "0.0.0.dev0"
|
|
12
12
|
|
|
13
13
|
from .api import (
|
|
14
|
+
DIGITAL_INPUT_COUNT,
|
|
14
15
|
BadCredentialsException,
|
|
15
16
|
BadStatusCodeException,
|
|
17
|
+
DigitalInputControl,
|
|
16
18
|
DmxControl,
|
|
17
19
|
DosageControl,
|
|
18
20
|
GetState,
|
|
@@ -30,6 +32,7 @@ from .api import (
|
|
|
30
32
|
async_start_dosage,
|
|
31
33
|
async_switch_off,
|
|
32
34
|
async_switch_on,
|
|
35
|
+
async_trigger_digital_input,
|
|
33
36
|
)
|
|
34
37
|
from .definitions import (
|
|
35
38
|
CATEGORY_ANALOG,
|
|
@@ -45,6 +48,7 @@ from .definitions import (
|
|
|
45
48
|
BadRelayException,
|
|
46
49
|
ConfigObject,
|
|
47
50
|
DataObject,
|
|
51
|
+
DigitalInput,
|
|
48
52
|
DmxChannelData,
|
|
49
53
|
DosageTarget,
|
|
50
54
|
GetDmxData,
|
|
@@ -67,12 +71,14 @@ __all__ = [
|
|
|
67
71
|
# data classes
|
|
68
72
|
"DataObject",
|
|
69
73
|
"Relay",
|
|
74
|
+
"DigitalInput",
|
|
70
75
|
"GetStateData",
|
|
71
76
|
"DmxChannelData",
|
|
72
77
|
"GetDmxData",
|
|
73
78
|
# enums
|
|
74
79
|
"DosageTarget",
|
|
75
80
|
# constants
|
|
81
|
+
"DIGITAL_INPUT_COUNT",
|
|
76
82
|
"EXTERNAL_RELAY_ID_OFFSET",
|
|
77
83
|
"CATEGORY_TIME",
|
|
78
84
|
"CATEGORY_ANALOG",
|
|
@@ -88,6 +94,7 @@ __all__ = [
|
|
|
88
94
|
"RelaySwitch",
|
|
89
95
|
"DosageControl",
|
|
90
96
|
"DmxControl",
|
|
97
|
+
"DigitalInputControl",
|
|
91
98
|
# free async functions
|
|
92
99
|
"async_get_raw_data",
|
|
93
100
|
"async_get_raw_state",
|
|
@@ -100,4 +107,5 @@ __all__ = [
|
|
|
100
107
|
"async_get_raw_dmx",
|
|
101
108
|
"async_get_dmx",
|
|
102
109
|
"async_set_dmx",
|
|
110
|
+
"async_trigger_digital_input",
|
|
103
111
|
]
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '2.
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
21
|
+
__version__ = version = '2.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 2, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -26,6 +26,7 @@ subclasses so callers can handle them uniformly.
|
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
28
|
import asyncio
|
|
29
|
+
import contextlib
|
|
29
30
|
import socket
|
|
30
31
|
|
|
31
32
|
from aiohttp import (
|
|
@@ -894,3 +895,148 @@ class DmxControl:
|
|
|
894
895
|
dmx_states=data,
|
|
895
896
|
timeout=self.timeout if timeout is None else timeout,
|
|
896
897
|
)
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
DIGITAL_INPUT_COUNT = 4
|
|
901
|
+
DIGITAL_INPUT_PULSE_SECONDS = 0.6
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
async def async_trigger_digital_input(
|
|
905
|
+
client_session: ClientSession,
|
|
906
|
+
config: ConfigObject,
|
|
907
|
+
digital_input_id: int,
|
|
908
|
+
timeout: float = 10.0,
|
|
909
|
+
hold_seconds: float = DIGITAL_INPUT_PULSE_SECONDS,
|
|
910
|
+
) -> str:
|
|
911
|
+
"""Trigger (momentarily pulse) a digital input via the WEBIO ``IO`` field.
|
|
912
|
+
|
|
913
|
+
The controller's web UI exposes each digital input as a push button: it
|
|
914
|
+
sets the input's bit, waits, then clears it. This sends the same two
|
|
915
|
+
`/usrcfg.cgi` writes — ``IO=<mask>&WEBIO=1`` (press) then, after
|
|
916
|
+
``hold_seconds``, ``IO=0&WEBIO=1`` (release) — where
|
|
917
|
+
``mask = 1 << digital_input_id``. The hold mirrors the web UI (~600ms) so
|
|
918
|
+
the controller reliably registers the pulse.
|
|
919
|
+
|
|
920
|
+
Unlike relay switching, no `GetStateData` snapshot is needed: the ``IO``
|
|
921
|
+
field is written directly rather than read-modify-written.
|
|
922
|
+
|
|
923
|
+
Because the pulse is two separate writes, the release is not fully
|
|
924
|
+
guaranteed. If the hold is **cancelled** (e.g. the caller's task is shut
|
|
925
|
+
down), a best-effort release is attempted before the cancellation
|
|
926
|
+
propagates. If the **release POST itself fails** (timeout / network blip),
|
|
927
|
+
that error propagates and the input is left asserted HIGH until the next
|
|
928
|
+
write.
|
|
929
|
+
|
|
930
|
+
Args:
|
|
931
|
+
client_session: An open `aiohttp.ClientSession`.
|
|
932
|
+
config: Controller configuration including base URL and credentials.
|
|
933
|
+
digital_input_id: Zero-based digital input index (0–3).
|
|
934
|
+
timeout: Per-request timeout in seconds, applied to each of the two
|
|
935
|
+
POSTs.
|
|
936
|
+
hold_seconds: Seconds to hold the input HIGH between the press and
|
|
937
|
+
release POSTs. Defaults to the web UI's ~600ms.
|
|
938
|
+
|
|
939
|
+
Returns:
|
|
940
|
+
The raw response body returned by the release POST.
|
|
941
|
+
|
|
942
|
+
Raises:
|
|
943
|
+
ValueError: If ``digital_input_id`` is not in 0–3.
|
|
944
|
+
BadCredentialsException: On HTTP 401 or 403.
|
|
945
|
+
BadStatusCodeException: On any other 4xx or 5xx response.
|
|
946
|
+
TimeoutException: If an exchange exceeds ``timeout`` seconds.
|
|
947
|
+
ProconipApiException: For network-level errors.
|
|
948
|
+
"""
|
|
949
|
+
if not 0 <= digital_input_id < DIGITAL_INPUT_COUNT:
|
|
950
|
+
raise ValueError(
|
|
951
|
+
f"digital_input_id must be in 0..{DIGITAL_INPUT_COUNT - 1}, got {digital_input_id}"
|
|
952
|
+
)
|
|
953
|
+
mask = 1 << digital_input_id
|
|
954
|
+
await async_post_usrcfg_cgi(
|
|
955
|
+
client_session=client_session,
|
|
956
|
+
config=config,
|
|
957
|
+
payload=f"IO={mask}&WEBIO=1",
|
|
958
|
+
timeout=timeout,
|
|
959
|
+
)
|
|
960
|
+
try:
|
|
961
|
+
await asyncio.sleep(hold_seconds)
|
|
962
|
+
except asyncio.CancelledError:
|
|
963
|
+
# Best-effort release so a cancelled hold doesn't leave the input
|
|
964
|
+
# asserted HIGH. Networking inside a cancellation is awkward, so this
|
|
965
|
+
# is a best effort, not a guarantee.
|
|
966
|
+
with contextlib.suppress(Exception):
|
|
967
|
+
await async_post_usrcfg_cgi(
|
|
968
|
+
client_session=client_session,
|
|
969
|
+
config=config,
|
|
970
|
+
payload="IO=0&WEBIO=1",
|
|
971
|
+
timeout=timeout,
|
|
972
|
+
)
|
|
973
|
+
raise
|
|
974
|
+
return await async_post_usrcfg_cgi(
|
|
975
|
+
client_session=client_session,
|
|
976
|
+
config=config,
|
|
977
|
+
payload="IO=0&WEBIO=1",
|
|
978
|
+
timeout=timeout,
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
class DigitalInputControl:
|
|
983
|
+
"""Convenience wrapper that binds a session and config for triggering inputs.
|
|
984
|
+
|
|
985
|
+
Construct once with your `aiohttp.ClientSession` and `ConfigObject`, then
|
|
986
|
+
pulse digital inputs by zero-based id (0–3) without repeating those
|
|
987
|
+
arguments each time.
|
|
988
|
+
|
|
989
|
+
The constructor also binds a default `timeout` for every call made via this
|
|
990
|
+
wrapper. Each method then accepts an optional per-call `timeout` that
|
|
991
|
+
overrides the bound default when supplied.
|
|
992
|
+
|
|
993
|
+
Example:
|
|
994
|
+
```python
|
|
995
|
+
dic = DigitalInputControl(session, config)
|
|
996
|
+
await dic.async_trigger(0) # pulse the first digital input
|
|
997
|
+
```
|
|
998
|
+
"""
|
|
999
|
+
|
|
1000
|
+
def __init__(
|
|
1001
|
+
self,
|
|
1002
|
+
client_session: ClientSession,
|
|
1003
|
+
config: ConfigObject,
|
|
1004
|
+
timeout: float = 10.0,
|
|
1005
|
+
):
|
|
1006
|
+
"""Bind the session, config, and default per-request timeout.
|
|
1007
|
+
|
|
1008
|
+
Args:
|
|
1009
|
+
client_session: An open `aiohttp.ClientSession`.
|
|
1010
|
+
config: Controller configuration.
|
|
1011
|
+
timeout: Default per-request timeout in seconds, used when a method
|
|
1012
|
+
is called without its own ``timeout`` argument.
|
|
1013
|
+
"""
|
|
1014
|
+
self.client_session = client_session
|
|
1015
|
+
self.config = config
|
|
1016
|
+
self.timeout = timeout
|
|
1017
|
+
|
|
1018
|
+
async def async_trigger(
|
|
1019
|
+
self,
|
|
1020
|
+
digital_input_id: int,
|
|
1021
|
+
timeout: float | None = None,
|
|
1022
|
+
hold_seconds: float = DIGITAL_INPUT_PULSE_SECONDS,
|
|
1023
|
+
) -> str:
|
|
1024
|
+
"""Trigger the digital input identified by ``digital_input_id``.
|
|
1025
|
+
|
|
1026
|
+
Args:
|
|
1027
|
+
digital_input_id: Zero-based digital input index (0–3).
|
|
1028
|
+
timeout: Override for this call only. If ``None``, the timeout
|
|
1029
|
+
bound in `__init__` is used.
|
|
1030
|
+
hold_seconds: Seconds to hold the input HIGH between press and
|
|
1031
|
+
release. Defaults to the web UI's ~600ms.
|
|
1032
|
+
|
|
1033
|
+
See `async_trigger_digital_input` (the free function) for the full
|
|
1034
|
+
description of behavior and raised exceptions.
|
|
1035
|
+
"""
|
|
1036
|
+
return await async_trigger_digital_input(
|
|
1037
|
+
client_session=self.client_session,
|
|
1038
|
+
config=self.config,
|
|
1039
|
+
digital_input_id=digital_input_id,
|
|
1040
|
+
timeout=self.timeout if timeout is None else timeout,
|
|
1041
|
+
hold_seconds=hold_seconds,
|
|
1042
|
+
)
|
|
@@ -409,6 +409,48 @@ class Relay(DataObject):
|
|
|
409
409
|
return 1 << self._category_id
|
|
410
410
|
|
|
411
411
|
|
|
412
|
+
class DigitalInput(DataObject):
|
|
413
|
+
"""Typed view of a single digital input (CSV columns 24–27).
|
|
414
|
+
|
|
415
|
+
Construct one by passing the `DataObject` you got from
|
|
416
|
+
`GetStateData.digital_input_objects`; the column, name, calibration, and
|
|
417
|
+
raw value are copied across so the physical value is computed exactly once.
|
|
418
|
+
``GetStateData.digital_inputs()`` is a shorthand that does this for you.
|
|
419
|
+
|
|
420
|
+
The wrapper exists so digital inputs can be *triggered* (not just read):
|
|
421
|
+
`get_bit_mask` yields the bit used in the controller's WEBIO ``IO`` field.
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
def __init__(self, data_object: DataObject):
|
|
425
|
+
"""Wrap an existing digital-input `DataObject`.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
data_object: A `DataObject` produced by parsing a `/GetState.csv`
|
|
429
|
+
response. It should be in the digital_input category, but no
|
|
430
|
+
check is enforced — passing another object yields a
|
|
431
|
+
`DigitalInput` whose `get_bit_mask` is meaningless.
|
|
432
|
+
"""
|
|
433
|
+
super().__init__(
|
|
434
|
+
data_object.column,
|
|
435
|
+
data_object.name,
|
|
436
|
+
data_object.unit,
|
|
437
|
+
data_object.offset,
|
|
438
|
+
data_object.gain,
|
|
439
|
+
data_object.raw_value, # pass raw value so offset+gain are applied exactly once
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
def get_bit_mask(self) -> int:
|
|
443
|
+
"""Bit for this input in the WEBIO ``IO`` field (``1 << category_id``).
|
|
444
|
+
|
|
445
|
+
The four digital inputs occupy bits 0–3, so this returns 1, 2, 4, or 8.
|
|
446
|
+
To *trigger* an input, pass its ``category_id`` to
|
|
447
|
+
`async_trigger_digital_input` (which derives the same mask itself); use
|
|
448
|
+
this method when assembling an ``IO`` payload for a manual
|
|
449
|
+
`async_post_usrcfg_cgi` write.
|
|
450
|
+
"""
|
|
451
|
+
return 1 << self._category_id
|
|
452
|
+
|
|
453
|
+
|
|
412
454
|
class GetStateData:
|
|
413
455
|
"""Parsed representation of a single `/GetState.csv` response.
|
|
414
456
|
|
|
@@ -822,6 +864,14 @@ class GetStateData:
|
|
|
822
864
|
"""The four digital inputs (columns 24–27), in column order."""
|
|
823
865
|
return self._digital_input_objects
|
|
824
866
|
|
|
867
|
+
def digital_inputs(self) -> list[DigitalInput]:
|
|
868
|
+
"""The four digital inputs as `DigitalInput` instances.
|
|
869
|
+
|
|
870
|
+
Equivalent to wrapping each entry in `digital_input_objects` with
|
|
871
|
+
`DigitalInput(...)`. Use these to get `get_bit_mask()` for triggering.
|
|
872
|
+
"""
|
|
873
|
+
return [DigitalInput(obj) for obj in self._digital_input_objects]
|
|
874
|
+
|
|
825
875
|
@property
|
|
826
876
|
def external_relay_objects(self) -> list[DataObject]:
|
|
827
877
|
"""The eight external relays (columns 28–35), in column order.
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"""Tests for the API module — HTTP layer, error mapping, and class wrappers."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
3
6
|
import aiohttp
|
|
4
7
|
import pytest
|
|
5
8
|
from aioresponses import aioresponses
|
|
@@ -7,6 +10,7 @@ from aioresponses import aioresponses
|
|
|
7
10
|
from proconip.api import (
|
|
8
11
|
BadCredentialsException,
|
|
9
12
|
BadStatusCodeException,
|
|
13
|
+
DigitalInputControl,
|
|
10
14
|
DmxControl,
|
|
11
15
|
DosageControl,
|
|
12
16
|
GetState,
|
|
@@ -22,6 +26,7 @@ from proconip.api import (
|
|
|
22
26
|
async_start_dosage,
|
|
23
27
|
async_switch_off,
|
|
24
28
|
async_switch_on,
|
|
29
|
+
async_trigger_digital_input,
|
|
25
30
|
)
|
|
26
31
|
from proconip.definitions import ConfigObject, DosageTarget, GetDmxData, GetStateData
|
|
27
32
|
|
|
@@ -228,6 +233,115 @@ async def test_set_dmx_sends_post(config: ConfigObject, get_dmx_csv: str) -> Non
|
|
|
228
233
|
assert result == "ok"
|
|
229
234
|
|
|
230
235
|
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
# async_trigger_digital_input
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _usrcfg_posts(m: aioresponses) -> list:
|
|
242
|
+
"""Return the recorded POST calls to /usrcfg.cgi, in order."""
|
|
243
|
+
return [
|
|
244
|
+
call
|
|
245
|
+
for (method, url), calls in m.requests.items()
|
|
246
|
+
for call in calls
|
|
247
|
+
if method == "POST" and "usrcfg.cgi" in str(url)
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@pytest.mark.parametrize(
|
|
252
|
+
("digital_input_id", "expected_mask"),
|
|
253
|
+
[(0, 1), (1, 2), (2, 4), (3, 8)],
|
|
254
|
+
)
|
|
255
|
+
async def test_trigger_digital_input_sends_posts(
|
|
256
|
+
config: ConfigObject, digital_input_id: int, expected_mask: int
|
|
257
|
+
) -> None:
|
|
258
|
+
with aioresponses() as m:
|
|
259
|
+
m.post(USRCFG_URL, body="press-ok", status=200)
|
|
260
|
+
m.post(USRCFG_URL, body="release-ok", status=200)
|
|
261
|
+
async with aiohttp.ClientSession() as session:
|
|
262
|
+
result = await async_trigger_digital_input(
|
|
263
|
+
session, config, digital_input_id, hold_seconds=0
|
|
264
|
+
)
|
|
265
|
+
posts = _usrcfg_posts(m)
|
|
266
|
+
assert len(posts) == 2
|
|
267
|
+
assert posts[0].kwargs["data"] == f"IO={expected_mask}&WEBIO=1"
|
|
268
|
+
assert posts[1].kwargs["data"] == "IO=0&WEBIO=1"
|
|
269
|
+
# The function returns the *release* response body.
|
|
270
|
+
assert result == "release-ok"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@pytest.mark.parametrize("digital_input_id", [-1, 4, 99])
|
|
274
|
+
async def test_trigger_digital_input_invalid_id_raises(
|
|
275
|
+
config: ConfigObject, digital_input_id: int
|
|
276
|
+
) -> None:
|
|
277
|
+
with aioresponses() as m:
|
|
278
|
+
async with aiohttp.ClientSession() as session:
|
|
279
|
+
with pytest.raises(ValueError):
|
|
280
|
+
await async_trigger_digital_input(session, config, digital_input_id)
|
|
281
|
+
assert _usrcfg_posts(m) == []
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def test_trigger_digital_input_401_raises_bad_credentials(config: ConfigObject) -> None:
|
|
285
|
+
with aioresponses() as m:
|
|
286
|
+
m.post(USRCFG_URL, status=401)
|
|
287
|
+
async with aiohttp.ClientSession() as session:
|
|
288
|
+
with pytest.raises(BadCredentialsException):
|
|
289
|
+
await async_trigger_digital_input(session, config, 0)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
async def test_trigger_digital_input_holds_high_between_posts(config: ConfigObject) -> None:
|
|
293
|
+
"""By default the input is held HIGH ~600ms between press and release.
|
|
294
|
+
|
|
295
|
+
Mirrors the controller's web UI, which waits before clearing the bit.
|
|
296
|
+
asyncio.sleep is patched so the test stays fast while proving the hold
|
|
297
|
+
happens *between* the two POSTs (one POST recorded when sleep fires).
|
|
298
|
+
"""
|
|
299
|
+
posts_at_sleep: list[int] = []
|
|
300
|
+
|
|
301
|
+
async def record_sleep(delay: float) -> None:
|
|
302
|
+
posts_at_sleep.append(len(_usrcfg_posts(m)))
|
|
303
|
+
assert delay == 0.6
|
|
304
|
+
|
|
305
|
+
with aioresponses() as m:
|
|
306
|
+
m.post(USRCFG_URL, body="press-ok", status=200)
|
|
307
|
+
m.post(USRCFG_URL, body="release-ok", status=200)
|
|
308
|
+
with patch("proconip.api.asyncio.sleep", side_effect=record_sleep):
|
|
309
|
+
async with aiohttp.ClientSession() as session:
|
|
310
|
+
result = await async_trigger_digital_input(session, config, 0)
|
|
311
|
+
|
|
312
|
+
assert result == "release-ok"
|
|
313
|
+
assert len(_usrcfg_posts(m)) == 2
|
|
314
|
+
# sleep fired exactly once, after the press POST and before the release.
|
|
315
|
+
assert posts_at_sleep == [1]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
async def test_trigger_digital_input_releases_on_cancel(config: ConfigObject) -> None:
|
|
319
|
+
"""A cancelled hold still attempts a best-effort release, then re-raises.
|
|
320
|
+
|
|
321
|
+
Otherwise a press would set the bit and the cancellation would leave the
|
|
322
|
+
input asserted HIGH with no release.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
async def cancel_during_hold(delay: float) -> None:
|
|
326
|
+
raise asyncio.CancelledError
|
|
327
|
+
|
|
328
|
+
with aioresponses() as m:
|
|
329
|
+
m.post(USRCFG_URL, body="press-ok", status=200)
|
|
330
|
+
m.post(USRCFG_URL, body="release-ok", status=200)
|
|
331
|
+
with patch("proconip.api.asyncio.sleep", side_effect=cancel_during_hold):
|
|
332
|
+
async with aiohttp.ClientSession() as session:
|
|
333
|
+
with pytest.raises(asyncio.CancelledError):
|
|
334
|
+
await async_trigger_digital_input(session, config, 0)
|
|
335
|
+
posts = _usrcfg_posts(m)
|
|
336
|
+
assert [p.kwargs["data"] for p in posts] == ["IO=1&WEBIO=1", "IO=0&WEBIO=1"]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def test_digital_input_count_is_exported_from_package() -> None:
|
|
340
|
+
from proconip import DIGITAL_INPUT_COUNT
|
|
341
|
+
|
|
342
|
+
assert DIGITAL_INPUT_COUNT == 4
|
|
343
|
+
|
|
344
|
+
|
|
231
345
|
# ---------------------------------------------------------------------------
|
|
232
346
|
# OO class wrappers
|
|
233
347
|
# ---------------------------------------------------------------------------
|
|
@@ -294,6 +408,18 @@ async def test_dmx_control_class(config: ConfigObject, get_dmx_csv: str) -> None
|
|
|
294
408
|
assert isinstance(parsed, GetDmxData)
|
|
295
409
|
|
|
296
410
|
|
|
411
|
+
async def test_digital_input_control_class(config: ConfigObject) -> None:
|
|
412
|
+
with aioresponses() as m:
|
|
413
|
+
m.post(USRCFG_URL, body="press-ok", status=200)
|
|
414
|
+
m.post(USRCFG_URL, body="release-ok", status=200)
|
|
415
|
+
async with aiohttp.ClientSession() as session:
|
|
416
|
+
dic = DigitalInputControl(session, config)
|
|
417
|
+
result = await dic.async_trigger(2, hold_seconds=0)
|
|
418
|
+
posts = _usrcfg_posts(m)
|
|
419
|
+
assert [p.kwargs["data"] for p in posts] == ["IO=4&WEBIO=1", "IO=0&WEBIO=1"]
|
|
420
|
+
assert result == "release-ok"
|
|
421
|
+
|
|
422
|
+
|
|
297
423
|
# ---------------------------------------------------------------------------
|
|
298
424
|
# Exception hierarchy
|
|
299
425
|
# ---------------------------------------------------------------------------
|
|
@@ -16,6 +16,7 @@ from proconip.definitions import (
|
|
|
16
16
|
BadRelayException,
|
|
17
17
|
ConfigObject,
|
|
18
18
|
DataObject,
|
|
19
|
+
DigitalInput,
|
|
19
20
|
DmxChannelData,
|
|
20
21
|
GetDmxData,
|
|
21
22
|
GetStateData,
|
|
@@ -199,6 +200,16 @@ def test_digital_input_objects(get_state_data: GetStateData) -> None:
|
|
|
199
200
|
assert obj.column == idx + 24
|
|
200
201
|
|
|
201
202
|
|
|
203
|
+
def test_digital_inputs(get_state_data: GetStateData) -> None:
|
|
204
|
+
inputs = get_state_data.digital_inputs()
|
|
205
|
+
assert len(inputs) == 4
|
|
206
|
+
for idx, digital_input in enumerate(inputs):
|
|
207
|
+
assert isinstance(digital_input, DigitalInput)
|
|
208
|
+
assert digital_input.category == CATEGORY_DIGITAL_INPUT
|
|
209
|
+
assert digital_input.category_id == idx
|
|
210
|
+
assert digital_input.column == idx + 24
|
|
211
|
+
|
|
212
|
+
|
|
202
213
|
def test_external_relay_objects(get_state_data: GetStateData) -> None:
|
|
203
214
|
objs = get_state_data.external_relay_objects
|
|
204
215
|
assert len(objs) == 8
|
|
@@ -327,6 +338,38 @@ def test_relay_str(get_state_data: GetStateData) -> None:
|
|
|
327
338
|
assert "Terassenlicht" in str(relay)
|
|
328
339
|
|
|
329
340
|
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
# DigitalInput
|
|
343
|
+
# ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@pytest.mark.parametrize(
|
|
347
|
+
("digital_input_id", "expected_mask"),
|
|
348
|
+
[(0, 1), (1, 2), (2, 4), (3, 8)],
|
|
349
|
+
)
|
|
350
|
+
def test_digital_input_get_bit_mask(
|
|
351
|
+
get_state_data: GetStateData, digital_input_id: int, expected_mask: int
|
|
352
|
+
) -> None:
|
|
353
|
+
digital_input = get_state_data.digital_inputs()[digital_input_id]
|
|
354
|
+
assert digital_input.get_bit_mask() == expected_mask
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def test_digital_input_value_not_double_applied() -> None:
|
|
358
|
+
"""DigitalInput must apply offset+gain exactly once, like Relay.
|
|
359
|
+
|
|
360
|
+
Mirrors `test_relay_value_not_double_applied`: a non-trivial offset would
|
|
361
|
+
expose a double-application bug. column 24 is the first digital input.
|
|
362
|
+
"""
|
|
363
|
+
obj = DataObject(column=24, name="test", unit="--", offset=1.0, gain=1.0, value=1.0)
|
|
364
|
+
assert obj.value == pytest.approx(2.0)
|
|
365
|
+
|
|
366
|
+
digital_input = DigitalInput(obj)
|
|
367
|
+
assert digital_input.value == pytest.approx(2.0)
|
|
368
|
+
assert digital_input.raw_value == pytest.approx(1.0)
|
|
369
|
+
assert digital_input.category == CATEGORY_DIGITAL_INPUT
|
|
370
|
+
assert digital_input.category_id == 0
|
|
371
|
+
|
|
372
|
+
|
|
330
373
|
# ---------------------------------------------------------------------------
|
|
331
374
|
# DmxChannelData
|
|
332
375
|
# ---------------------------------------------------------------------------
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|