|
27 | 27 | parse_heartbeat_pn, |
28 | 28 | ) |
29 | 29 | from .common import ( |
30 | | - _AT_TEXT_MAX_MIXED_FRAME_PAYLOAD_LEN, |
31 | | - _AT_TEXT_MIXED_FRAME_FCODES, |
32 | 30 | _AT_TEXT_MIXED_FRAME_READ_TIMEOUT, |
33 | 31 | _PrefixedAsyncReader, |
34 | 32 | _cancel_and_join_task, |
|
38 | 36 | _looks_like_plain_raw_response_start, |
39 | 37 | _looks_like_uart_passthrough_value, |
40 | 38 | _parse_fc2_collector_pn, |
| 39 | + _runtime_eybond_header_error, |
41 | 40 | _short_ascii, |
42 | 41 | ) |
43 | 42 |
|
44 | 43 | logger = logging.getLogger(__name__) |
45 | 44 |
|
46 | 45 |
|
| 46 | +# An idle connection may remain silent indefinitely. Once the collector has |
| 47 | +# started a frame, however, every remaining structural part is bounded. This |
| 48 | +# prevents a truncated header or a false payload length from pinning the one |
| 49 | +# reader task and swallowing later valid responses on the same TCP socket. |
| 50 | +_FRAMED_HEADER_COMPLETION_TIMEOUT = 2.0 |
| 51 | +_FRAMED_PAYLOAD_COMPLETION_TIMEOUT = 5.0 |
| 52 | + |
| 53 | + |
47 | 54 | def _modbus_rtu_response_length(prefix: bytes) -> int | None: |
48 | 55 | """Return the complete RTU response length for a three-byte prefix. |
49 | 56 |
|
@@ -422,17 +429,82 @@ async def _read_loop( |
422 | 429 | reader = _PrefixedAsyncReader(reader) |
423 | 430 | try: |
424 | 431 | while True: |
425 | | - prefix = await reader.readexactly(3) |
| 432 | + first = await reader.readexactly(1) |
| 433 | + try: |
| 434 | + prefix = first + await asyncio.wait_for( |
| 435 | + reader.readexactly(2), |
| 436 | + timeout=_FRAMED_HEADER_COMPLETION_TIMEOUT, |
| 437 | + ) |
| 438 | + except asyncio.TimeoutError: |
| 439 | + self._collector.last_disconnect_reason = ( |
| 440 | + "collector_frame_header_timeout" |
| 441 | + ) |
| 442 | + logger.warning( |
| 443 | + "Closing collector session after partial frame prefix " |
| 444 | + "remote=%s prefix=%s", |
| 445 | + self._collector.remote_ip, |
| 446 | + first.hex(), |
| 447 | + ) |
| 448 | + return |
426 | 449 | if prefix == b"AT+": |
427 | 450 | line = prefix + await reader.read_at_response() |
428 | 451 | self._handle_at_response(line) |
429 | 452 | continue |
430 | 453 |
|
431 | | - header_bytes = prefix + await reader.readexactly(HEADER_SIZE - len(prefix)) |
| 454 | + try: |
| 455 | + header_bytes = prefix + await asyncio.wait_for( |
| 456 | + reader.readexactly(HEADER_SIZE - len(prefix)), |
| 457 | + timeout=_FRAMED_HEADER_COMPLETION_TIMEOUT, |
| 458 | + ) |
| 459 | + except asyncio.TimeoutError: |
| 460 | + self._collector.last_disconnect_reason = ( |
| 461 | + "collector_frame_header_timeout" |
| 462 | + ) |
| 463 | + logger.warning( |
| 464 | + "Closing collector session after incomplete frame header " |
| 465 | + "remote=%s prefix=%s", |
| 466 | + self._collector.remote_ip, |
| 467 | + prefix.hex(), |
| 468 | + ) |
| 469 | + return |
432 | 470 | header = decode_header(header_bytes) |
| 471 | + header_error = _runtime_eybond_header_error(header) |
| 472 | + if header_error: |
| 473 | + self._collector.last_disconnect_reason = header_error |
| 474 | + logger.warning( |
| 475 | + "Closing collector session after malformed frame header " |
| 476 | + "remote=%s reason=%s header=%s tid=%d devcode=0x%04X " |
| 477 | + "devaddr=0x%02X fc=%d payload=%d", |
| 478 | + self._collector.remote_ip, |
| 479 | + header_error, |
| 480 | + header_bytes.hex(), |
| 481 | + header.tid, |
| 482 | + header.devcode, |
| 483 | + header.devaddr, |
| 484 | + header.fcode, |
| 485 | + header.payload_len, |
| 486 | + ) |
| 487 | + return |
433 | 488 | payload = b"" |
434 | 489 | if header.payload_len > 0: |
435 | | - payload = await reader.readexactly(header.payload_len) |
| 490 | + try: |
| 491 | + payload = await asyncio.wait_for( |
| 492 | + reader.readexactly(header.payload_len), |
| 493 | + timeout=_FRAMED_PAYLOAD_COMPLETION_TIMEOUT, |
| 494 | + ) |
| 495 | + except asyncio.TimeoutError: |
| 496 | + self._collector.last_disconnect_reason = ( |
| 497 | + "collector_frame_payload_timeout" |
| 498 | + ) |
| 499 | + logger.warning( |
| 500 | + "Closing collector session after incomplete frame payload " |
| 501 | + "remote=%s tid=%d fc=%d expected=%d", |
| 502 | + self._collector.remote_ip, |
| 503 | + header.tid, |
| 504 | + header.fcode, |
| 505 | + header.payload_len, |
| 506 | + ) |
| 507 | + return |
436 | 508 |
|
437 | 509 | self._collector.last_devcode = header.devcode |
438 | 510 | logger.debug( |
@@ -1218,13 +1290,7 @@ async def _read_mixed_frame_tail( |
1218 | 1290 | return None |
1219 | 1291 |
|
1220 | 1292 | def _looks_like_mixed_frame_header(self, header: EybondHeader) -> bool: |
1221 | | - if header.payload_len < 0: |
1222 | | - return False |
1223 | | - if header.payload_len > _AT_TEXT_MAX_MIXED_FRAME_PAYLOAD_LEN: |
1224 | | - return False |
1225 | | - if header.fcode not in _AT_TEXT_MIXED_FRAME_FCODES: |
1226 | | - return False |
1227 | | - return True |
| 1293 | + return _runtime_eybond_header_error(header) == "" |
1228 | 1294 |
|
1229 | 1295 | def _record_unhandled_raw_fragment(self, payload: bytes, *, parser: str) -> None: |
1230 | 1296 | self._collector.raw_unhandled_line_count += 1 |
|
0 commit comments