proconip 2.1.2__tar.gz → 2.2.1__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.1}/CHANGELOG.md +14 -0
- {proconip-2.1.2 → proconip-2.2.1}/PKG-INFO +5 -6
- {proconip-2.1.2 → proconip-2.2.1}/README.md +3 -4
- {proconip-2.1.2 → proconip-2.2.1}/pyproject.toml +3 -7
- {proconip-2.1.2 → proconip-2.2.1}/src/proconip/__init__.py +8 -0
- {proconip-2.1.2 → proconip-2.2.1}/src/proconip/_version.py +2 -2
- {proconip-2.1.2 → proconip-2.2.1}/src/proconip/api.py +164 -6
- {proconip-2.1.2 → proconip-2.2.1}/src/proconip/definitions.py +79 -5
- proconip-2.2.1/tests/test_api.py +548 -0
- {proconip-2.1.2 → proconip-2.2.1}/tests/test_definitions.py +43 -0
- proconip-2.1.2/tests/test_api.py +0 -305
- {proconip-2.1.2 → proconip-2.2.1}/.gitignore +0 -0
- {proconip-2.1.2 → proconip-2.2.1}/LICENSE +0 -0
- {proconip-2.1.2 → proconip-2.2.1}/src/proconip/py.typed +0 -0
- {proconip-2.1.2 → proconip-2.2.1}/tests/conftest.py +0 -0
- {proconip-2.1.2 → proconip-2.2.1}/tests/fixtures/get_dmx.csv +0 -0
- {proconip-2.1.2 → proconip-2.2.1}/tests/fixtures/get_state.csv +0 -0
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.2.1](https://github.com/ylabonte/proconip-pypi/compare/v2.2.0...v2.2.1) (2026-10-08)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* support aiohttp 3.14 without deprecation warnings; test against it ([#94](https://github.com/ylabonte/proconip-pypi/issues/94), [#97](https://github.com/ylabonte/proconip-pypi/issues/97)) ([#98](https://github.com/ylabonte/proconip-pypi/issues/98)) ([87dcc3d](https://github.com/ylabonte/proconip-pypi/commit/87dcc3d01ab2d5cae7fb826638086665f2076c7c))
|
|
9
|
+
|
|
10
|
+
## [2.2.0](https://github.com/ylabonte/proconip-pypi/compare/v2.1.2...v2.2.0) (2026-06-14)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* add digital input triggering via WEBIO ([#93](https://github.com/ylabonte/proconip-pypi/issues/93)) ([8c35420](https://github.com/ylabonte/proconip-pypi/commit/8c354202ded3bbe389d6a53c0059b78d14337b97))
|
|
16
|
+
|
|
3
17
|
## [2.1.2](https://github.com/ylabonte/proconip-pypi/compare/v2.1.1...v2.1.2) (2026-06-14)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: proconip
|
|
3
|
-
Version: 2.1
|
|
3
|
+
Version: 2.2.1
|
|
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/
|
|
@@ -176,8 +176,8 @@ async def manual_dosage_example():
|
|
|
176
176
|
client_session = aiohttp.ClientSession()
|
|
177
177
|
config = ConfigObject("http://192.168.2.3", "admin", "admin")
|
|
178
178
|
dosage_control = DosageControl(client_session, config)
|
|
179
|
-
await dosage_control.async_chlorine_dosage(3600)
|
|
180
|
-
await dosage_control.async_ph_minus_dosage(60)
|
|
179
|
+
await dosage_control.async_chlorine_dosage(3600) # start for 1 hour
|
|
180
|
+
await dosage_control.async_ph_minus_dosage(60) # start for 1 minute
|
|
181
181
|
await client_session.close()
|
|
182
182
|
|
|
183
183
|
|
|
@@ -201,13 +201,12 @@ async def dmx_example():
|
|
|
201
201
|
print(f"{channel.name} before: {channel.value}")
|
|
202
202
|
dmx_data.set(channel.index, (channel.value + 128) % 256)
|
|
203
203
|
print(f"{channel.name} after: {dmx_data.get_value(channel.index)}")
|
|
204
|
-
|
|
204
|
+
|
|
205
205
|
await dmx_control.async_set(dmx_data)
|
|
206
206
|
await client_session.close()
|
|
207
207
|
|
|
208
208
|
|
|
209
209
|
asyncio.run(dmx_example())
|
|
210
|
-
|
|
211
210
|
```
|
|
212
211
|
|
|
213
212
|
## A brief description of the ProCon.IP pool controller
|
|
@@ -148,8 +148,8 @@ async def manual_dosage_example():
|
|
|
148
148
|
client_session = aiohttp.ClientSession()
|
|
149
149
|
config = ConfigObject("http://192.168.2.3", "admin", "admin")
|
|
150
150
|
dosage_control = DosageControl(client_session, config)
|
|
151
|
-
await dosage_control.async_chlorine_dosage(3600)
|
|
152
|
-
await dosage_control.async_ph_minus_dosage(60)
|
|
151
|
+
await dosage_control.async_chlorine_dosage(3600) # start for 1 hour
|
|
152
|
+
await dosage_control.async_ph_minus_dosage(60) # start for 1 minute
|
|
153
153
|
await client_session.close()
|
|
154
154
|
|
|
155
155
|
|
|
@@ -173,13 +173,12 @@ async def dmx_example():
|
|
|
173
173
|
print(f"{channel.name} before: {channel.value}")
|
|
174
174
|
dmx_data.set(channel.index, (channel.value + 128) % 256)
|
|
175
175
|
print(f"{channel.name} after: {dmx_data.get_value(channel.index)}")
|
|
176
|
-
|
|
176
|
+
|
|
177
177
|
await dmx_control.async_set(dmx_data)
|
|
178
178
|
await client_session.close()
|
|
179
179
|
|
|
180
180
|
|
|
181
181
|
asyncio.run(dmx_example())
|
|
182
|
-
|
|
183
182
|
```
|
|
184
183
|
|
|
185
184
|
## A brief description of the ProCon.IP pool controller
|
|
@@ -37,15 +37,11 @@ test = [
|
|
|
37
37
|
"pytest",
|
|
38
38
|
"pytest-asyncio",
|
|
39
39
|
"pytest-cov",
|
|
40
|
-
"aioresponses",
|
|
41
|
-
# aioresponses 0.7.8 can't build mocked responses against aiohttp 3.14+
|
|
42
|
-
# (ClientResponse gained a required `stream_writer` kwarg), which breaks
|
|
43
|
-
# the whole test_api suite. Pin tests to the 3.13.x line HA Core ships.
|
|
44
|
-
# TODO: lift this cap once a compatible aioresponses release lands.
|
|
45
|
-
"aiohttp<3.14",
|
|
46
40
|
]
|
|
47
41
|
docs = [
|
|
48
|
-
|
|
42
|
+
# MkDocs 2.0 drops the plugin system and the theming that Material and
|
|
43
|
+
# mkdocstrings rely on, with no migration path — stay on 1.x.
|
|
44
|
+
"mkdocs<2",
|
|
49
45
|
"mkdocs-material",
|
|
50
46
|
"mkdocstrings[python]",
|
|
51
47
|
"mkdocs-include-markdown-plugin",
|
|
@@ -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.1
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
21
|
+
__version__ = version = '2.2.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 2, 1)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -26,10 +26,11 @@ subclasses so callers can handle them uniformly.
|
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
28
|
import asyncio
|
|
29
|
+
import base64
|
|
30
|
+
import contextlib
|
|
29
31
|
import socket
|
|
30
32
|
|
|
31
33
|
from aiohttp import (
|
|
32
|
-
BasicAuth,
|
|
33
34
|
ClientError,
|
|
34
35
|
ClientResponse,
|
|
35
36
|
ClientSession,
|
|
@@ -83,6 +84,18 @@ class TimeoutException(ProconipApiException):
|
|
|
83
84
|
"""
|
|
84
85
|
|
|
85
86
|
|
|
87
|
+
def _auth_headers(config: ConfigObject) -> dict[str, str]:
|
|
88
|
+
"""Build the HTTP Basic ``Authorization`` header for the controller credentials.
|
|
89
|
+
|
|
90
|
+
Built by hand rather than via ``aiohttp.BasicAuth`` / ``auth=``, which
|
|
91
|
+
aiohttp 3.14 deprecates (removal in 4.0), while its replacement
|
|
92
|
+
``aiohttp.encode_basic_auth`` doesn't exist on older supported versions.
|
|
93
|
+
Credentials are latin-1 encoded, matching ``BasicAuth``'s default.
|
|
94
|
+
"""
|
|
95
|
+
credentials = f"{config.username}:{config.password}".encode("latin1")
|
|
96
|
+
return {"Authorization": "Basic " + base64.b64encode(credentials).decode("ascii")}
|
|
97
|
+
|
|
98
|
+
|
|
86
99
|
async def _handle_response(response: ClientResponse) -> str:
|
|
87
100
|
"""Validate the response and return its body, mapping HTTP errors to typed exceptions."""
|
|
88
101
|
if response.status in (401, 403):
|
|
@@ -129,10 +142,9 @@ async def async_get_raw_data(
|
|
|
129
142
|
ProconipApiException: For DNS failures, connection resets, and other
|
|
130
143
|
network-level errors.
|
|
131
144
|
"""
|
|
132
|
-
auth = BasicAuth(config.username, config.password)
|
|
133
145
|
try:
|
|
134
146
|
async with asyncio.timeout(timeout):
|
|
135
|
-
async with client_session.get(url,
|
|
147
|
+
async with client_session.get(url, headers=_auth_headers(config)) as response:
|
|
136
148
|
return await _handle_response(response)
|
|
137
149
|
except TimeoutError as exc:
|
|
138
150
|
raise TimeoutException("API request timed out") from exc
|
|
@@ -299,15 +311,16 @@ async def async_post_usrcfg_cgi(
|
|
|
299
311
|
ProconipApiException: For network-level errors.
|
|
300
312
|
"""
|
|
301
313
|
url = URL(config.base_url).with_path(API_PATH_USRCFG)
|
|
302
|
-
|
|
303
|
-
|
|
314
|
+
headers = {
|
|
315
|
+
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
316
|
+
**_auth_headers(config),
|
|
317
|
+
}
|
|
304
318
|
try:
|
|
305
319
|
async with asyncio.timeout(timeout):
|
|
306
320
|
async with client_session.post(
|
|
307
321
|
url=url,
|
|
308
322
|
headers=headers,
|
|
309
323
|
data=payload,
|
|
310
|
-
auth=auth,
|
|
311
324
|
) as response:
|
|
312
325
|
return await _handle_response(response)
|
|
313
326
|
except TimeoutError as exc:
|
|
@@ -894,3 +907,148 @@ class DmxControl:
|
|
|
894
907
|
dmx_states=data,
|
|
895
908
|
timeout=self.timeout if timeout is None else timeout,
|
|
896
909
|
)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
DIGITAL_INPUT_COUNT = 4
|
|
913
|
+
DIGITAL_INPUT_PULSE_SECONDS = 0.6
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
async def async_trigger_digital_input(
|
|
917
|
+
client_session: ClientSession,
|
|
918
|
+
config: ConfigObject,
|
|
919
|
+
digital_input_id: int,
|
|
920
|
+
timeout: float = 10.0,
|
|
921
|
+
hold_seconds: float = DIGITAL_INPUT_PULSE_SECONDS,
|
|
922
|
+
) -> str:
|
|
923
|
+
"""Trigger (momentarily pulse) a digital input via the WEBIO ``IO`` field.
|
|
924
|
+
|
|
925
|
+
The controller's web UI exposes each digital input as a push button: it
|
|
926
|
+
sets the input's bit, waits, then clears it. This sends the same two
|
|
927
|
+
`/usrcfg.cgi` writes — ``IO=<mask>&WEBIO=1`` (press) then, after
|
|
928
|
+
``hold_seconds``, ``IO=0&WEBIO=1`` (release) — where
|
|
929
|
+
``mask = 1 << digital_input_id``. The hold mirrors the web UI (~600ms) so
|
|
930
|
+
the controller reliably registers the pulse.
|
|
931
|
+
|
|
932
|
+
Unlike relay switching, no `GetStateData` snapshot is needed: the ``IO``
|
|
933
|
+
field is written directly rather than read-modify-written.
|
|
934
|
+
|
|
935
|
+
Because the pulse is two separate writes, the release is not fully
|
|
936
|
+
guaranteed. If the hold is **cancelled** (e.g. the caller's task is shut
|
|
937
|
+
down), a best-effort release is attempted before the cancellation
|
|
938
|
+
propagates. If the **release POST itself fails** (timeout / network blip),
|
|
939
|
+
that error propagates and the input is left asserted HIGH until the next
|
|
940
|
+
write.
|
|
941
|
+
|
|
942
|
+
Args:
|
|
943
|
+
client_session: An open `aiohttp.ClientSession`.
|
|
944
|
+
config: Controller configuration including base URL and credentials.
|
|
945
|
+
digital_input_id: Zero-based digital input index (0–3).
|
|
946
|
+
timeout: Per-request timeout in seconds, applied to each of the two
|
|
947
|
+
POSTs.
|
|
948
|
+
hold_seconds: Seconds to hold the input HIGH between the press and
|
|
949
|
+
release POSTs. Defaults to the web UI's ~600ms.
|
|
950
|
+
|
|
951
|
+
Returns:
|
|
952
|
+
The raw response body returned by the release POST.
|
|
953
|
+
|
|
954
|
+
Raises:
|
|
955
|
+
ValueError: If ``digital_input_id`` is not in 0–3.
|
|
956
|
+
BadCredentialsException: On HTTP 401 or 403.
|
|
957
|
+
BadStatusCodeException: On any other 4xx or 5xx response.
|
|
958
|
+
TimeoutException: If an exchange exceeds ``timeout`` seconds.
|
|
959
|
+
ProconipApiException: For network-level errors.
|
|
960
|
+
"""
|
|
961
|
+
if not 0 <= digital_input_id < DIGITAL_INPUT_COUNT:
|
|
962
|
+
raise ValueError(
|
|
963
|
+
f"digital_input_id must be in 0..{DIGITAL_INPUT_COUNT - 1}, got {digital_input_id}"
|
|
964
|
+
)
|
|
965
|
+
mask = 1 << digital_input_id
|
|
966
|
+
await async_post_usrcfg_cgi(
|
|
967
|
+
client_session=client_session,
|
|
968
|
+
config=config,
|
|
969
|
+
payload=f"IO={mask}&WEBIO=1",
|
|
970
|
+
timeout=timeout,
|
|
971
|
+
)
|
|
972
|
+
try:
|
|
973
|
+
await asyncio.sleep(hold_seconds)
|
|
974
|
+
except asyncio.CancelledError:
|
|
975
|
+
# Best-effort release so a cancelled hold doesn't leave the input
|
|
976
|
+
# asserted HIGH. Networking inside a cancellation is awkward, so this
|
|
977
|
+
# is a best effort, not a guarantee.
|
|
978
|
+
with contextlib.suppress(Exception):
|
|
979
|
+
await async_post_usrcfg_cgi(
|
|
980
|
+
client_session=client_session,
|
|
981
|
+
config=config,
|
|
982
|
+
payload="IO=0&WEBIO=1",
|
|
983
|
+
timeout=timeout,
|
|
984
|
+
)
|
|
985
|
+
raise
|
|
986
|
+
return await async_post_usrcfg_cgi(
|
|
987
|
+
client_session=client_session,
|
|
988
|
+
config=config,
|
|
989
|
+
payload="IO=0&WEBIO=1",
|
|
990
|
+
timeout=timeout,
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
class DigitalInputControl:
|
|
995
|
+
"""Convenience wrapper that binds a session and config for triggering inputs.
|
|
996
|
+
|
|
997
|
+
Construct once with your `aiohttp.ClientSession` and `ConfigObject`, then
|
|
998
|
+
pulse digital inputs by zero-based id (0–3) without repeating those
|
|
999
|
+
arguments each time.
|
|
1000
|
+
|
|
1001
|
+
The constructor also binds a default `timeout` for every call made via this
|
|
1002
|
+
wrapper. Each method then accepts an optional per-call `timeout` that
|
|
1003
|
+
overrides the bound default when supplied.
|
|
1004
|
+
|
|
1005
|
+
Example:
|
|
1006
|
+
```python
|
|
1007
|
+
dic = DigitalInputControl(session, config)
|
|
1008
|
+
await dic.async_trigger(0) # pulse the first digital input
|
|
1009
|
+
```
|
|
1010
|
+
"""
|
|
1011
|
+
|
|
1012
|
+
def __init__(
|
|
1013
|
+
self,
|
|
1014
|
+
client_session: ClientSession,
|
|
1015
|
+
config: ConfigObject,
|
|
1016
|
+
timeout: float = 10.0,
|
|
1017
|
+
):
|
|
1018
|
+
"""Bind the session, config, and default per-request timeout.
|
|
1019
|
+
|
|
1020
|
+
Args:
|
|
1021
|
+
client_session: An open `aiohttp.ClientSession`.
|
|
1022
|
+
config: Controller configuration.
|
|
1023
|
+
timeout: Default per-request timeout in seconds, used when a method
|
|
1024
|
+
is called without its own ``timeout`` argument.
|
|
1025
|
+
"""
|
|
1026
|
+
self.client_session = client_session
|
|
1027
|
+
self.config = config
|
|
1028
|
+
self.timeout = timeout
|
|
1029
|
+
|
|
1030
|
+
async def async_trigger(
|
|
1031
|
+
self,
|
|
1032
|
+
digital_input_id: int,
|
|
1033
|
+
timeout: float | None = None,
|
|
1034
|
+
hold_seconds: float = DIGITAL_INPUT_PULSE_SECONDS,
|
|
1035
|
+
) -> str:
|
|
1036
|
+
"""Trigger the digital input identified by ``digital_input_id``.
|
|
1037
|
+
|
|
1038
|
+
Args:
|
|
1039
|
+
digital_input_id: Zero-based digital input index (0–3).
|
|
1040
|
+
timeout: Override for this call only. If ``None``, the timeout
|
|
1041
|
+
bound in `__init__` is used.
|
|
1042
|
+
hold_seconds: Seconds to hold the input HIGH between press and
|
|
1043
|
+
release. Defaults to the web UI's ~600ms.
|
|
1044
|
+
|
|
1045
|
+
See `async_trigger_digital_input` (the free function) for the full
|
|
1046
|
+
description of behavior and raised exceptions.
|
|
1047
|
+
"""
|
|
1048
|
+
return await async_trigger_digital_input(
|
|
1049
|
+
client_session=self.client_session,
|
|
1050
|
+
config=self.config,
|
|
1051
|
+
digital_input_id=digital_input_id,
|
|
1052
|
+
timeout=self.timeout if timeout is None else timeout,
|
|
1053
|
+
hold_seconds=hold_seconds,
|
|
1054
|
+
)
|
|
@@ -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
|
|
|
@@ -543,7 +585,13 @@ class GetStateData:
|
|
|
543
585
|
@property
|
|
544
586
|
def config_other_enable(self) -> int:
|
|
545
587
|
"""Misc configuration flags. Use the `is_*_enabled` methods to query
|
|
546
|
-
individual bits (TCP/IP boost, SD card, DMX, …).
|
|
588
|
+
individual bits (TCP/IP boost, SD card, DMX, …).
|
|
589
|
+
|
|
590
|
+
Not every "Sonstiges" checkbox is mirrored here on current firmware:
|
|
591
|
+
on 1.7.6, bits 0 (TCP/IP boost), 1 (SD card) and 5 (high bus load)
|
|
592
|
+
were observed as 0 although those options were enabled, while bit 2
|
|
593
|
+
(DMX) tracked its checkbox exactly. Bits 3, 4, 6, 7 and 8 are
|
|
594
|
+
unverified on hardware."""
|
|
547
595
|
return self._config_other_enable
|
|
548
596
|
|
|
549
597
|
@property
|
|
@@ -707,15 +755,28 @@ class GetStateData:
|
|
|
707
755
|
return NTP_FAULT_STATE[0]
|
|
708
756
|
|
|
709
757
|
def is_tcpip_boost_enabled(self) -> bool:
|
|
710
|
-
"""True if TCP/IP boost
|
|
758
|
+
"""True if bit 0 (TCP/IP boost) of `config_other_enable` is set.
|
|
759
|
+
|
|
760
|
+
Unreliable on current firmware: 1.7.6 was observed reporting this bit
|
|
761
|
+
as 0 while TCP/IP boost was enabled, so ``False`` doesn't prove the
|
|
762
|
+
feature is off.
|
|
763
|
+
"""
|
|
711
764
|
return self._config_other_enable & 1 == 1
|
|
712
765
|
|
|
713
766
|
def is_sd_card_enabled(self) -> bool:
|
|
714
|
-
"""True if SD card
|
|
767
|
+
"""True if bit 1 (SD card) of `config_other_enable` is set.
|
|
768
|
+
|
|
769
|
+
Unreliable on current firmware: 1.7.6 was observed reporting this bit
|
|
770
|
+
as 0 while the SD card was enabled, so ``False`` doesn't prove the
|
|
771
|
+
feature is off.
|
|
772
|
+
"""
|
|
715
773
|
return self._config_other_enable & 2 == 2
|
|
716
774
|
|
|
717
775
|
def is_dmx_enabled(self) -> bool:
|
|
718
|
-
"""True if DMX output is enabled in the controller config (bit 2).
|
|
776
|
+
"""True if DMX output is enabled in the controller config (bit 2).
|
|
777
|
+
|
|
778
|
+
Verified on firmware 1.7.6: the bit follows the "DMX512 aktiv" checkbox.
|
|
779
|
+
"""
|
|
719
780
|
return self._config_other_enable & 4 == 4
|
|
720
781
|
|
|
721
782
|
def is_avatar_enabled(self) -> bool:
|
|
@@ -732,7 +793,12 @@ class GetStateData:
|
|
|
732
793
|
return self._config_other_enable & 16 == 16
|
|
733
794
|
|
|
734
795
|
def is_high_bus_load_enabled(self) -> bool:
|
|
735
|
-
"""True if high bus load
|
|
796
|
+
"""True if bit 5 (high bus load, 1-Wire) of `config_other_enable` is set.
|
|
797
|
+
|
|
798
|
+
Unreliable on current firmware: 1.7.6 was observed reporting this bit
|
|
799
|
+
as 0 while high bus load was enabled, so ``False`` doesn't prove the
|
|
800
|
+
feature is off.
|
|
801
|
+
"""
|
|
736
802
|
return self._config_other_enable & 32 == 32
|
|
737
803
|
|
|
738
804
|
def is_flow_sensor_enabled(self) -> bool:
|
|
@@ -822,6 +888,14 @@ class GetStateData:
|
|
|
822
888
|
"""The four digital inputs (columns 24–27), in column order."""
|
|
823
889
|
return self._digital_input_objects
|
|
824
890
|
|
|
891
|
+
def digital_inputs(self) -> list[DigitalInput]:
|
|
892
|
+
"""The four digital inputs as `DigitalInput` instances.
|
|
893
|
+
|
|
894
|
+
Equivalent to wrapping each entry in `digital_input_objects` with
|
|
895
|
+
`DigitalInput(...)`. Use these to get `get_bit_mask()` for triggering.
|
|
896
|
+
"""
|
|
897
|
+
return [DigitalInput(obj) for obj in self._digital_input_objects]
|
|
898
|
+
|
|
825
899
|
@property
|
|
826
900
|
def external_relay_objects(self) -> list[DataObject]:
|
|
827
901
|
"""The eight external relays (columns 28–35), in column order.
|