Blauberg Vento API — Test Case Descriptions

Project: blauberg-vento (blauberg_vento_api) · Version: 1.0.3 · Updated: 2026-10-04 · Test framework: pytest (asyncio_mode=auto)

Run: python -m pytest tests -q · Current status: 92 test cases, all passing. The robust-ack functionality described below was implemented on 2026-10-04; the test-first cases that were red during design are now green.

Passes now — all 92 cases are green in the current build
Live evidence base. These descriptions were written against measurements from six real Vento Expert fans on 192.168.29.0/24 (4× unit type 3 A50-1/A85-1/A100-1 W V.2, 2× type 4 Duo A30-1 W V.2, firmware 0.4), using the Blauberg Smart Home connection guide (B133-4-1EN-02) as the protocol source of truth. Key measured facts:

Table of contents

  1. Write and verification policy under test
  2. New: tests/test_ack_protocol.py
  3. New: tests/test_ack_client.py
  4. New: tests/test_ack_transport.py
  5. Existing: tests/test_protocol.py
  6. Existing: tests/test_protocol_invalid.py
  7. Existing: tests/test_transport.py
  8. Existing: tests/test_client_discovery.py

Write and verification policy under test

The new tests encode these rules:

New: tests/test_ack_protocol.py

Protocol-layer contract for the robust-ack functionality. 19 cases, all green.

TestEmptyAckParsing

Basis: live probe — a 0x03 write to W-only param 128 returned 0x06 with an empty data section; the current parser raises “Response packet missing payload data”.

Test caseDescriptionExpected resultStatus
test_empty_response_ack_returns_empty_dict Parses a valid 0x06 response packet (correct header, checksum) whose data section is empty (0 bytes). parse_response() returns {} without raising. Passes now
test_empty_response_ack_with_matching_expected_id Same empty-data 0x06 packet, parsed with expected_device_id equal to the packet's device ID. Returns {}; the correlation check accepts a matching ID. Passes now

TestMixedAckAppliedValues

Basis: live mixed ack data=02 01 fd 90 — SPEED applied, 0x90 rejected; today the applied value is discarded when the error raises.

Test caseDescriptionExpected resultStatus
test_unsupported_param_error_carries_applied_values Parses a 0x06 ack containing one applied param (SPEED=01) and one 0xFD entry (param 0x90). Raises VentoUnsupportedParamError; .params == [0x90] and .applied == {Param.SPEED: b"\x01"}. Passes now
test_unsupported_param_error_applied_defaults_empty Parses an ack containing only a 0xFD entry (no applied params). Raises VentoUnsupportedParamError with .applied == {}. Passes now

TestAckDeviceIdCorrelation

Basis: the protocol has no transaction ID; the header device ID is the only correlation key between request and ack.

Test caseDescriptionExpected resultStatus
test_parse_response_rejects_wrong_device_id A 0x06 ack whose header device ID (OTHER_ID) differs from the expected one (DEV_ID). Raises VentoAckMismatchError. Passes now
test_parse_response_accepts_matching_expected_id A normal ack whose device ID equals expected_device_id. Returns the parsed mapping ({Param.SPEED: b"\x01"}). Passes now
test_parse_response_without_expected_id_accepts_any_device Ack from a foreign device parsed without expected_device_id (backward compatibility). Returns the parsed mapping; no correlation check is performed. Passes now

TestWriteCapabilityValidation

Encodes the write policy: FUNC 0x02 is reserved for W-only params; FUNC 0x03 accepts any writable param; read-only and unknown params are rejected by both write paths before any packet is built.

Test caseDescriptionExpected resultStatus
test_build_write_rejects_rw_param build_write() (FUNC 0x02) with RW-capable param POWER. Raises VentoCapabilityError (subclass of VentoProtocolError, so existing invalid-input tests remain coherent). Passes now
test_build_write_allows_w_only_param build_write() with W-only param FILTER_RESET. Builds a packet whose FUNC byte is 0x02. Passes now
test_build_write_rejects_read_only_param build_write() with read-only param FIRMWARE_VERSION. Raises VentoCapabilityError. Passes now
test_build_write_allows_unknown_param_for_probing build_write() with raw unlisted param 0x90 — the library does not know the param's capabilities, so the controller's 0xFD negative ack is the source of truth. Builds a packet whose FUNC byte is 0x02. Passes now
test_build_write_resp_allows_rw_param build_write_resp() (FUNC 0x03) with RW param SPEED. Builds a packet whose FUNC byte is 0x03. Passes now
test_build_write_resp_allows_w_only_param build_write_resp() with W-only param RESET_ALARMS. Builds a packet whose FUNC byte is 0x03. Passes now
test_build_write_resp_rejects_read_only_param build_write_resp() with read-only param UNIT_TYPE. Raises VentoCapabilityError. Passes now
test_build_write_resp_allows_unknown_param_for_probing build_write_resp() with raw unlisted param 0x90 — probing writes must reach the controller so its 0xFD ack can answer. Builds a packet whose FUNC byte is 0x03. Passes now

TestReadCapabilityValidation

Test caseDescriptionExpected resultStatus
test_build_read_rejects_w_only_param build_read() with W-only param FACTORY_RESET (a real device would answer fd 85). Raises VentoCapabilityError. Passes now
test_build_read_allows_unknown_param_for_probing build_read() with raw param 0x90 — negative-ack capability probing must stay possible. Builds a packet whose FUNC byte is 0x01. Passes now

TestParamSizeRobustness

Test caseDescriptionExpected resultStatus
test_param_size_unknown_returns_none param_size(0x0090) for a param absent from PARAM_META. Returns None (today raises KeyError). Passes now
test_param_size_known_param param_size(Param.POWER). Returns 1. Passes now

New: tests/test_ack_client.py

Client-layer verified-write contract, exercised through scripted fake transports (counting sends and popping canned acks). 21 cases, all green.

TestVerifiedWrite (sync)

Test caseDescriptionExpected resultStatus
test_verified_write_returns_applied_echo write_params({SPEED: 2}) with a scripted echo ack (data 02 02). Returns {Param.SPEED: b"\x02"} — the applied values, not a None. Passes now
test_verified_write_uses_func_03 Inspects the single packet the client sends for an RW write. Exactly one send; FUNC byte is 0x03 (WRITE_RESP). Passes now
test_clamped_echo_returns_actual_value Writes HUMIDITY_THRESHOLD=30 (below the 40–80 range); scripted echo reports 40 (19 28). Returns {Param.HUMIDITY_THRESHOLD: b"\x28"}; no exception — the caller sees the clamped applied value (live-proven semantics). Passes now
test_verified_write_retries_on_timeout Two timeouts, then a good ack. Write succeeds on the 3rd attempt; 3 sends; all three packets byte-identical. Passes now
test_verified_write_raises_ack_error_after_all_attempts Transport always times out. Raises VentoAckError; exactly 3 sends (default attempt budget). Passes now
test_ack_error_reports_attempts Transport always times out. VentoAckError.attempts == 3. Passes now
test_invert_value_write_single_attempt write_params({POWER: 2}) (2 = invert); transport always times out. Exactly 1 send; VentoAckError.attempts == 1 — a retry could double-toggle the fan (hazard identified during design). Passes now
test_speed_two_is_not_treated_as_invert write_params({SPEED: 2}) where 2 is a plain speed value; one timeout, then ack. Succeeds with 2 sends — the no-retry rule is scoped to params with invert semantics, not to the value 2 generally. Passes now

TestWriteOnlyRouting (sync)

Test caseDescriptionExpected resultStatus
test_w_only_write_fire_and_forget write_params({FILTER_RESET: 1}) — W-only param. Returns {}; one send_only (FUNC 0x02); no send_recv. Unverifiable by design. Passes now
test_mixed_rw_and_w_only_rejected One call mixing RW (SPEED) and W-only (FILTER_RESET) params. Raises VentoCapabilityError before any traffic (a single packet cannot serve both rules). Passes now
test_write_read_only_param_rejected write_params({FIRMWARE_VERSION: ...}) — read-only param. Raises VentoCapabilityError; nothing sent. Passes now
test_empty_write_map_no_traffic write_params({}). Returns {}; no packets sent at all. Passes now

TestNegativeAcks (sync)

Test caseDescriptionExpected resultStatus
test_unsupported_param_ack_raises_with_applied Scripted mixed ack 02 01 fd 90 for a write of SPEED + 0x90. Raises VentoUnsupportedParamError whose .applied == {Param.SPEED: b"\x01"} — the caller learns what took effect. Passes now
test_wrong_device_ack_raises_mismatch Scripted ack with a foreign device ID in the header. Raises VentoAckMismatchError; exactly 1 send (anomalous acks are not retried). Passes now

TestScheduleDaySelectors (sync)

Pins the datasheet rule: day byte 0 (all days), 8 (Mon–Fri), 9 (Sat–Sun) are write-only selectors; reads accept only 1–7. This asymmetry is intended behaviour, not a bug.

Test caseDescriptionExpected resultStatus
test_schedule_write_accepts_day_zero set_schedule_period(0, 1, 1, 8, 0). Accepted without VentoValueError. Passes now
test_schedule_write_accepts_day_nine set_schedule_period(9, 1, 1, 8, 0). Accepted without VentoValueError. Passes now
test_schedule_read_rejects_day_zero get_schedule_period(0, 1). Raises VentoValueError (read selector range is 1–7). Passes now

TestAsyncVerifiedWrite

Async mirror of the core sync cases using a fake async transport. async client must not lag the sync client.

Test caseDescriptionExpected resultStatus
test_async_verified_write_returns_applied_echo AsyncVentoClient.write_params({SPEED: 2}) with a scripted echo ack. Returns {Param.SPEED: b"\x02"}; packet FUNC byte is 0x03. Passes now
test_async_verified_write_retries_on_timeout Two timeouts, then a good ack. Succeeds on 3rd attempt; 3 sends total. Passes now
test_async_w_only_write_fire_and_forget write_params({FACTORY_RESET: 1}) — W-only param. Returns {}; one send_only; no send_recv. Passes now
test_async_invert_single_attempt write_params({POWER: 2}) with a timing-out transport. Exactly 1 send; VentoAckError.attempts == 1. Passes now

New: tests/test_ack_transport.py

Transport-layer source-address filtering so a stray datagram from another host on the LAN cannot be mistaken for the device's ack. 6 cases.

TestSyncSourceFiltering

Test caseDescriptionExpected resultStatus
test_sync_send_recv_ignores_foreign_source Mocked socket first receives a datagram from 10.9.9.9, then the real reply from the target host 192.0.2.10. send_recv() returns the target host's datagram; the stray one is discarded. Passes now
test_sync_send_recv_timeout_when_only_foreign_source Mocked socket receives only foreign datagrams, then times out. Raises VentoTimeoutError; foreign data is never returned. Passes now

TestAsyncSourceFiltering

Test caseDescriptionExpected resultStatus
test_protocol_ignores_foreign_source _SingleResponseProtocol(future, expected_host=HOST) receives a datagram from a foreign address. The future stays unresolved. Passes now
test_protocol_resolves_matching_source Same protocol receives a datagram from the expected host. The future resolves with (data, addr). Passes now
test_protocol_without_expected_host_accepts_any_source Protocol constructed without expected_host (legacy signature) receives any datagram. The future resolves — backward compatibility with the current behaviour. Passes now
test_async_send_recv_end_to_end_matching_source Real sockets: a local responder on 127.0.0.1 answers the transport's ping. AsyncVentoTransport.send_recv() returns the responder's b"pong" — the happy path keeps working with the filter active. Passes now

Existing: tests/test_protocol.py

Original protocol suite (spec packets, builders, parsers, decoders, input validation). All 28 cases pass today and must stay green.

TestChecksum

Test caseDescriptionExpected result
test_spec_rqChecksum verification of the datasheet's example request packet (FUNC 0x01, params 1 and 2).No exception (checksum 0x00DE matches).
test_spec_rsChecksum verification of the datasheet's example response packet (FUNC 0x06).No exception (checksum 0x00E6 matches).
test_bad_checksumSame request packet with the last checksum byte corrupted.Raises VentoChecksumError.

TestBuilding

Test caseDescriptionExpected result
test_matches_specbuild_read() for POWER and SPEED must byte-match the datasheet's example request.Exact match with SPEC_RQ.
test_starts_fd_fdEvery packet starts with the 0xFD 0xFD framing bytes.First two bytes are \xfd\xfd.
test_checksum_validA freshly built read packet passes checksum verification.No exception from verify_checksum().
test_discovery_default_idDiscovery packet uses the DEFAULT_DEVICEID magic ID.Bytes 4–20 of the packet equal DEFAULT_DEVICEID.
test_night_timer_page_cmdParam 770 (0x0302) requires the 0xFF page command in the data block.Data contains 0xFF 0x03 followed by low byte 0x02.
test_bad_id_raisesDevice ID shorter than 16 characters.Raises (VentoProtocolError).
test_long_password_raisesPassword longer than 8 characters.Raises (VentoProtocolError).

TestParsing

Test caseDescriptionExpected result
test_spec_responseParses the datasheet's example response (params 1 and 2).{POWER: b"\x00", SPEED: b"\x03"}.
test_unsupported_paramData block fd 01 02 03: 0xFD negative ack for param 1, then a value pair.Raises VentoUnsupportedParamError containing 0x0001.
test_size_override0xFE size command for a 2-byte battery voltage param.Decodes 5000 mV.
test_page_change0xFF page switch to page 0x03, then a 2-byte NIGHT_TIMER value.Value decodes to minutes=30, hours=8.

TestDecoders

Test caseDescriptionExpected result
test_decode_ip4-byte IP decode."192.168.1.50".
test_encode_ipIP string encode.bytes([192,168,1,50]).
test_firmware6-byte firmware block decode.major=1, minor=5, day=14, month=3, year=2024.
test_machine_hours4-byte machine hours decode.minutes=30, hours=5, days=10.
test_rtc_time3-byte RTC time decode.seconds=45, minutes=30, hours=14.
test_rtc_calendar4-byte RTC calendar decode.year=2024, month=6.
test_schedule6-byte schedule period decode.speed=2, end_hours=8, end_minutes=30.
test_filter_countdown3-byte filter countdown decode.minutes=0, hours=12, days=90.
test_bad_ip_sizeIP decode with a 2-byte value.Raises (VentoProtocolError).

TestValidation (client input ranges)

Test caseDescriptionExpected result
test_bad_speedset_speed(5) — only 1–3 valid.Raises VentoValueError.
test_bad_humidity_thresholdset_humidity_threshold(100) — range 40–80.Raises VentoValueError.
test_bad_boost_delayset_boost_delay(61) — range 0–60.Raises VentoValueError.
test_long_passwordchange_password("TOOLONGPWD") — max 8 chars.Raises VentoValueError.
test_manual_speed_boundariesset_manual_speed(0) and set_manual_speed(255) are the valid boundaries; the transport is mocked to return a verified-write echo ack (SPEED and MANUAL_SPEED).Both accepted (no raise), applied echo parsed.

Existing: tests/test_protocol_invalid.py

Malformed-packet rejections. All cases pass today and must stay green.

Test caseDescriptionExpected result
test_invalid_packet_rejections[truncated]4-byte fragment (too short for any parsing).Raises VentoProtocolError.
test_invalid_packet_rejections[no-header]Packet without the 0xFD 0xFD start bytes.Raises VentoProtocolError.
test_invalid_packet_rejections[bad-checksum]Full-length packet whose checksum does not match.Raises VentoChecksumError.
test_parse_response_rejects_truncated_packetPacket cut off before the data section and checksum bytes begin (no checksum to verify).Raises VentoProtocolError.
test_parse_response_rejects_invalid_header_lengths[bad-id-size]ID size byte 0x00 instead of 0x10.Raises VentoProtocolError.
test_parse_response_rejects_invalid_header_lengths[bad-pwd-size]Password size byte 0x09 (max is 0x08).Raises VentoProtocolError.
test_parse_data_bytes_rejects_truncated_control_commands[0xff]Page command with no page byte.Raises VentoProtocolError.
test_parse_data_bytes_rejects_truncated_control_commands[0xfe]Size command with no size byte.Raises VentoProtocolError.
test_parse_data_bytes_rejects_truncated_control_commands[0xfe-00]Size command with size 0 (must be > 0).Raises VentoProtocolError.
test_parse_data_bytes_rejects_truncated_control_commands[0xfd]Unsupported-param command with no param byte.Raises VentoProtocolError.
test_build_write_rejects_wrong_fixed_sizeWriting a 1-byte value to a 2-byte param (NIGHT_TIMER).Raises VentoProtocolError.
test_build_write_rejects_integer_overflowWriting 256 to a 1-byte param (POWER).Raises VentoProtocolError.
test_build_packet_rejects_non_ascii_passwordPassword containing non-ASCII characters.Raises VentoProtocolError.

Existing: tests/test_transport.py

Test caseDescriptionExpected result
test_sync_send_recv_raises_timeout_errorMocked socket that never receives; sync transport times out.Raises VentoTimeoutError (wraps TimeoutError).
test_async_send_recv_raises_timeout_errorReal local socket with nothing answering; async transport times out.Raises VentoTimeoutError.
test_async_send_only_wraps_socket_creation_errorSocket creation fails (create_datagram_endpoint raises OSError).Raises VentoConnectionError ("Cannot open socket").

Existing: tests/test_client_discovery.py

Test caseDescriptionExpected result
test_parse_discovery_items_deduplicates_device_advertisementsTwo discovery responses advertising the same device.One DiscoveredDevice is returned (duplicates removed).
test_discover_returns_unique_devicesVentoClient.discover() with a mocked transport yielding duplicate responses.Unique devices only.