|
| 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 |
0 commit comments