Skip to content

Commit 2b81f61

Browse files
committed
3.1.0 support
1 parent 9332280 commit 2b81f61

File tree

8 files changed

+288
-119
lines changed

8 files changed

+288
-119
lines changed

.github/release-drafter.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
template: |
22
## What’s Changed
33
4-
$CHANGES
4+
$CHANGES

.pre-commit-config.yaml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
repos:
22
- repo: https://github.com/pre-commit/pre-commit-hooks
3-
rev: v4.5.0
3+
rev: v6.0.0
44
hooks:
55
- id: check-added-large-files
66
- id: check-case-conflict
@@ -16,32 +16,32 @@ repos:
1616
- id: trailing-whitespace
1717

1818
- repo: https://github.com/asottile/pyupgrade
19-
rev: v3.15.0
19+
rev: v3.21.2
2020
hooks:
2121
- id: pyupgrade
2222
args: ["--py36-plus"]
2323

2424
- repo: https://github.com/psf/black
25-
rev: 24.1.0
25+
rev: 25.12.0
2626
hooks:
2727
- id: black
2828
args:
2929
- --safe
3030
- --quiet
3131

3232
- repo: https://github.com/pycqa/isort
33-
rev: 5.13.2
33+
rev: 7.0.0
3434
hooks:
3535
- id: isort
3636

3737
- repo: https://github.com/PyCQA/flake8
38-
rev: 7.0.0
38+
rev: 7.3.0
3939
hooks:
4040
- id: flake8
4141
additional_dependencies: [flake8-bugbear]
4242

4343
- repo: https://github.com/codespell-project/codespell
44-
rev: v2.2.6
44+
rev: v2.4.1
4545
hooks:
4646
- id: codespell
4747

tests/data/js/3.1.0.bundle.js

Lines changed: 36 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vallox_websocket_api/client.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
ProtocolError,
1515
)
1616

17-
from .data.model import DataModel, DataModelReadException
17+
from .data.model import DataModel
1818
from .exceptions import (
19+
DataModelReadException,
1920
ValloxApiException,
2021
ValloxInvalidInputException,
2122
ValloxWebsocketException,
@@ -274,9 +275,16 @@ async def fetch_metrics(
274275
metric_keys = list(self.data_model.addresses.keys())
275276

276277
for key in metric_keys:
277-
value = data[
278-
self.data_model.calculate_offset(self.data_model.addresses[key])
279-
]
278+
offset = self.data_model.calculate_offset(self.data_model.addresses[key])
279+
280+
if offset is None:
281+
continue
282+
283+
try:
284+
value = data[offset]
285+
except IndexError:
286+
logger.warning(f"Offset {offset} for key {key} is out of range")
287+
continue
280288

281289
if self.is_temperature(key):
282290
value = to_celsius(value)
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import logging
2+
from typing import List, Optional
3+
4+
from vallox_websocket_api.data.constants import ConstantsDict
5+
from vallox_websocket_api.exceptions import DataModelReadException
6+
7+
logger = logging.getLogger("vallox").getChild(__name__)
8+
9+
10+
class BufferRange:
11+
"""Represents a single buffer range mapping."""
12+
13+
def __init__(
14+
self, range_start: int, range_end: int, buffer_offset: int, range_name: str
15+
):
16+
self.range_start = range_start
17+
self.range_end = range_end
18+
self.buffer_offset = buffer_offset
19+
self.range_name = range_name
20+
21+
@property
22+
def count(self) -> int:
23+
"""Number of items in this range."""
24+
return self.range_end - self.range_start + 1
25+
26+
def contains(self, address: int) -> bool:
27+
"""Check if an address is within this range."""
28+
return self.range_start <= address <= self.range_end
29+
30+
def calculate_offset(self, address: int) -> Optional[int]:
31+
"""Calculate buffer offset for an address within this range."""
32+
if not self.contains(address):
33+
return None
34+
return self.buffer_offset + (address - self.range_start)
35+
36+
37+
class BufferRanges:
38+
"""Manages collection of buffer ranges for efficient address-to-offset mapping."""
39+
40+
def __init__(self, ranges: List[BufferRange]):
41+
# Sort by range_start for efficient lookup
42+
self._ranges = sorted(ranges, key=lambda r: r.range_start)
43+
44+
def calculate_offset(self, address: int) -> Optional[int]:
45+
"""Map a Modbus address to its buffer index.
46+
47+
Args:
48+
address: Modbus-style address (e.g., 4353 for A_CYC_FAN_SPEED)
49+
50+
Returns:
51+
Buffer index (0-based position in the data array)
52+
"""
53+
for buffer_range in self._ranges:
54+
offset = buffer_range.calculate_offset(address)
55+
if offset is not None:
56+
return offset
57+
return None
58+
59+
def __len__(self) -> int:
60+
"""Return number of ranges."""
61+
return len(self._ranges)
62+
63+
@property
64+
def total_buffer_size(self) -> int:
65+
"""Calculate total buffer size needed."""
66+
if not self._ranges:
67+
return 0
68+
# Find the maximum end position
69+
return max(r.buffer_offset + r.count for r in self._ranges)
70+
71+
@classmethod
72+
def from_constants(cls, constants: ConstantsDict) -> "BufferRanges":
73+
"""Build buffer ranges from constants dictionary.
74+
75+
The buffer contains all data ranges packed sequentially. We track
76+
the current buffer position as we add each range.
77+
"""
78+
dev = constants["VlxDevConstants"]
79+
vlx_read = constants.get("VlxReadConstants", {})
80+
ranges = []
81+
82+
# Track current position in buffer
83+
buffer_position = 0
84+
85+
# Detect firmware version
86+
is_fw_v2 = "RANGE_START_g_self_test" in dev
87+
88+
# Helper to add a range if it exists
89+
def add_range(range_name: str, count_key: str, required: bool = True) -> None:
90+
"""Add range to mapping if it exists in constants.
91+
92+
Args:
93+
range_name: Name of the range (e.g., 'g_cyclone_general_info')
94+
count_key: Key in VlxReadConstants for the count (e.g., 'CYC_NUM_OF_GENERAL_INFO')
95+
required: If True, raises error when range is missing. If False, silently skips.
96+
"""
97+
nonlocal buffer_position
98+
99+
start_key = f"RANGE_START_{range_name}"
100+
end_key = f"RANGE_END_{range_name}"
101+
102+
# Check if range exists
103+
has_start = start_key in dev
104+
has_end = end_key in dev
105+
has_count = count_key in vlx_read
106+
107+
if has_start and has_end and has_count:
108+
range_start = dev[start_key]
109+
range_end = dev[end_key]
110+
count = vlx_read[count_key]
111+
112+
# Add this range at current buffer position
113+
ranges.append(
114+
BufferRange(range_start, range_end, buffer_position, range_name)
115+
)
116+
117+
# Move buffer position forward by the number of items in this range
118+
buffer_position += count
119+
elif required:
120+
# Required range is missing - this is an error
121+
missing_parts = []
122+
if not has_start:
123+
missing_parts.append(start_key)
124+
if not has_end:
125+
missing_parts.append(end_key)
126+
if not has_count:
127+
missing_parts.append(count_key)
128+
129+
raise DataModelReadException(
130+
f"Required range '{range_name}' is missing: {', '.join(missing_parts)}"
131+
)
132+
133+
# Build ranges in buffer order (matches the order in JavaScript vlxBufferSize)
134+
# All these are required for the data model to work
135+
add_range("g_cyclone_general_info", "CYC_NUM_OF_GENERAL_INFO")
136+
add_range("g_typhoon_general_info", "CYC_NUM_OF_GENERAL_TYP_INFO")
137+
add_range("g_cyclone_hw_state", "CYC_NUM_OF_HW_STATES")
138+
add_range("g_cyclone_sw_state", "CYC_NUM_OF_SW_STATES")
139+
add_range("g_cyclone_time", "CYC_NUM_OF_TIME_ELEMENTS")
140+
add_range("g_cyclone_output", "CYC_NUM_OF_OUTPUTS")
141+
add_range("g_cyclone_input", "CYC_NUM_OF_INPUTS")
142+
add_range("g_cyclone_config", "CYC_NUM_OF_CONFIGS")
143+
add_range("g_cyclone_settings", "CYC_NUM_OF_CYC_SETTINGS")
144+
add_range("g_typhoon_settings", "CYC_NUM_OF_TYP_SETTINGS")
145+
146+
# Version-specific ranges (required for their respective versions)
147+
if is_fw_v2:
148+
add_range("g_self_test", "CYC_NUM_OF_SELF_TESTS")
149+
else: # FW v3+
150+
add_range("g_constant_flow", "CYC_NUM_OF_CF")
151+
152+
# Common: faults range (required)
153+
add_range("g_faults", "CYC_NUM_OF_FAULTS")
154+
155+
# Common ranges (required)
156+
add_range("g_cyclone_weekly_schedule", "CYC_NUM_OF_SCHEDULED_EVENTS")
157+
158+
# Extended settings - exists in both v2 and v3, but may be optional in some versions
159+
add_range("g_cyclone_extended", "CYC_NUM_OF_EXT_SETTINGS", required=False)
160+
161+
buffer_ranges = cls(ranges)
162+
163+
# Log the mapping for debugging
164+
logger.debug(f"Built buffer range mapping with {len(buffer_ranges)} ranges:")
165+
logger.debug(f"Total buffer size: {buffer_ranges.total_buffer_size}")
166+
for r in buffer_ranges._ranges:
167+
logger.debug(
168+
f"{r.range_name:30s} : "
169+
f" Address {r.range_start:5d}-{r.range_end:5d} ({r.count:3d} items) "
170+
f"→ Buffer[{r.buffer_offset:3d}-{r.buffer_offset + r.count - 1:3d}]"
171+
)
172+
173+
return buffer_ranges

vallox_websocket_api/data/constants.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Dict
2+
13
ALARM_MESSAGES = [
24
{"key": None, "text": "No error"},
35
{"key": "alarm_extract_stop", "text": "Extract air fan "},
@@ -25,3 +27,6 @@
2527
{"key": "alarm_supply_too_low", "text": "Low supply air temperature"},
2628
{"key": "alarm_tor0_communication", "text": "Communication error"},
2729
]
30+
31+
32+
ConstantsDict = Dict[str, Dict[str, int]]

0 commit comments

Comments
 (0)