Repository navigation
feat(expose): add declarative HA-to-SCS bus expose engine and virtual actuator LED sync - #750
GreenGrassBlueOcean wants to merge 11 commits into
Conversation
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
… actuator LED sync
…and strict typing
…ions and simplify WHO=2 dedup key
… against infeasible inputs
… against edge inputs
…s, and custom action security model
f37b2ba to
e5d49c2
Compare
|
Plant check of the mirror frames on the F461 (2.0.11), each frame sent twice (as command and as status request), virtual addresses 08 / 25 / 50 / 51:
Two smaller observations from the same session: the gateway relays the virtual-cover translation burst twice ( |
…, and skip WHO 13 sync when periodic_send=0 (#750)
|
Thank you @gdluck — this second live F461 ( Here is what we updated in 1. Default to
|
…cript and SCRIPT_SCHEMA (#750)
…rop OWNd shims (#750) OWNd 2.0.0b11 (with #129) declares the expose injection flags as bool | None, where None means no capture or oracle verdict exists. Only F461Profile sets them. Expose read them with bool(), and the shim's hasattr() patch never fired on b11, so WHO 4 and WHO 18 bindings silently sent nothing on every gateway except F461. - Gate WHO 4 and WHO 18 on _injection_block_reason(): frames are built only when the flag is exactly True (WHO 4 also accepts supports_dimension_response_injection=True). False, None and an unknown profile each block the binding. - async_sync_all() logs one warning per binding and reason, so a disabled binding is named at startup and again only if the reason changes (for example after a model correction). - _effective_profile() reads the gateway's .profile only. - Delete _ensure_ownd_expose_helpers and the profile-flag patches in where_grammar: the manifest pins OWNd 2.0.0b11, which ships them. - Read binding.who/where directly in the error logs instead of getattr(..., "where"), which test_where_usage_guard rejects. - Widen _iter_static_service_calls to list[Any] so mypy accepts the isinstance guard on nested choose/parallel items. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
) Bring in #742 (sweep gating, myhome.yaml copy in entry options) and #744 (#732 modularization of config_flow, gateway and platforms). Conflicts: - validate.py: import list; keep both CONF_STEP_*_ACTION and CONF_STRATEGY. - legacy_yaml.py: keep the base's _load_live_yaml/_apply_yaml_platforms structure and re-apply the expose handling on top: a top-level `expose:` key counts as single-gateway config, the where-filling loop skips `expose`, and the validated expose list is collected into configured_expose (threaded through _load_live_yaml). - config_flow.py: take the base's split module and move ExposeSubentryFlowHandler into config_flow_expose.py, next to config_flow_options.py; config_flow.py keeps async_get_supported_subentry_types and re-exports the handler. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What is the problem
In a BTicino / Legrand MyHOME SCS installation, users want to project Home Assistant entities and the system clock onto the OpenWebNet bus as virtual sensors, meters, and bidirectional virtual actuators (
expose, paired with OpenWebNet-HA/OWNd#129, merged into OWNdmaster; requires OWNd ≥ 2.0.0b11, whichmanifest.jsonalready pins):WHO=1&WHO=2):11..99,0101..0915,1001..1015, with optional#4#00..#4#15local-bus suffix) on a physical BTicino wall control or touchscreen to control any non-BTicino Home Assistant entity (media_player,light,switch,fan,cover,input_boolean,lock, etc.) or execute custom Home Assistant script actions (turn_on_action,turn_off_action,step_up_action,step_down_action), while mirroring HA state back onto the bus (*1*1*W##,*1*0*W##,*#1*W*#1*...##,*2*1/2/0*W##,*#2*W*#11#001*...##).WHO=4, Dimension0):°C, within[-99.9, +99.9]°C) onto probe addresses (000..099or101..999excludingN00forN >= 1, normalizing"0"to"000") via*#4*WHERE*#0*TTTT##(OWNHeatingCommand.write_temperaturewhensupports_who4_command_injection=True, or*#4*WHERE*0*TTTT##whensupports_dimension_response_injection=True). On a gateway whose profile does not set either flag toTrue(today every profile exceptF461), the binding is disabled and a warning names it at startup.WHO=18, Dimensions113,51..54&1200Streaming):dim=113, auto-convertingkWW) and energy counters (dim=51..54, auto-convertingkWhWh) at meter addresses51..5255or71#0..7255#0, and respond to Dimension1200active-power streaming handshakes (*#18*WHERE*#1200#1*DURATION##). Only on gateways whose profile setssupports_who18_response_injection=True(todayF461); elsewhere the binding is disabled with a startup warning.WHO=13, Dimensions0,1,22):0,1, and22without requiring anentity_id, answer*#13**0/1/22##queries whenrespond_to_read=True, and support a read-only query-reply mode whenperiodic_send: 0(suppressing proactive*#13**#22*...##writes on startup/reconnect for NTP-synced gateways).How is it solved
ExposeManager&ExposeBinding(custom_components/myhome/expose.py):#DIMWrite vs. Report Injection (_effective_profile,_build_frames,_async_handle_bus_message):bt_luciandbt_termo), incoming un-hashed dimension response frames (*#1*W*1*level*speed##,*#2*W*10*state*pos*001*0##,*#4*W*0*TTTT##) on the TCP command session are NACKed or parsed as dimension reads, whereas#DIMwrite frames (*#1*W*#1*level*speed##,*#2*W*#11#001*pos##,*#4*W*#0*TTTT##) are ACKed in ~83–126 ms and forwarded to the SCS bus. Conversely,WHO=18(bt_energy/bt_supervisione) does accept un-hashed*#18*W*DIM*VAL##response frames (113,51..54,1200#1).GatewayProfiledeclaressupports_dimension_response_injection,supports_who4_command_injectionandsupports_who18_response_injectionasbool | None = None, whereNonemeans unverified (no capture or oracle verdict). OnlyF461Profilesets them, from the live captures:False,True,True._injection_block_reasongatesWHO=4andWHO=18: frames are built only when the flag is exactlyTrue(forWHO=4,supports_dimension_response_injection=Truealso qualifies).False,Noneand a not-yet-known profile each block the binding.async_sync_all()(after initial discovery and on every reconnect) logs one warning per binding and reason, e.g. "Expose WHO=4 WHERE=050 dimension=0 (sensor.temp) is disabled: gateway MH202 is unverified for WHO 4 #0 temperature writes (supports_who4_command_injection is unset in OWNd). Nothing is sent for this binding." A later model correction to a verified profile re-enables the binding without a restart.supports_dimension_response_injectionnotTrue,WHO=1emitsOWNLightingCommand.set_brightness(*#1*W*#1*(100+pct)*0##, omitting a duplicate*1*0*W##whenbrightness_pct == 0) andWHO=2emitsOWNAutomationCommand.set_shutter_level(*#2*W*#11#001*pos##, answering both Dimension10and11read queries) on every gateway. Where enabled,WHO=4emitsOWNHeatingCommand.write_temperature(*#4*W*#0*TTTT##) andWHO=18emitsOWNEnergyCommand.report_*(*#18*W*113*W##,51..54,1200#1).ScriptRunner,cv.SCRIPT_SCHEMAValidation & Per-Binding Error Containment (validate.py,expose.py):turn_on_action,turn_off_action,step_up_action, andstep_down_actionat schema time viacv.SCRIPT_SCHEMA(_validate_expose_action/_coerce_action_list) and pre-compiles Home AssistantScriptrunners on startup viaasync_validate_actions_config(_async_prepare_action_scripts), unloading them cleanly viascript.async_unload()onasync_stop().data,target,service_templatewithwho,where,entity_id, andthisviaTemplateStateFromEntityId), conditions (condition: state/template),if/then/else,choose,repeat,parallel, andsequenceblocks.<domain>.<service>existence at startup (_warn_unknown_action_services, or onEVENT_HOMEASSISTANT_STARTEDwhenhass.stateis not yetCoreState.running) so mistyped service names are flagged immediately instead of only when someone presses the wall button.(HomeAssistantError, vol.Invalid)per binding in both_async_run_actionsand_async_call_service, ensuring a failing action on one binding never aborts subsequent bindings during a General (*1*0*0##) or Area (*2*2*9##) bus broadcast._EchoGuardEntry,_record_echo_guard,_is_echo_guarded):_EchoGuardEntryinstances (TX_ECHO_GUARD_WINDOW_S = 1.5s) bound toFrameSchedulerwrite futures (_attach_echo_guard_future). While a frame's write future is pending in the queue (not future.done()), incoming companion frames are guarded without popping the entry; once the socket write completes, the1.5sTTL starts fromfuturecompletion (and if cancelled before write, the guard is immediately removed).WHO=2pre-moveSTOPtranslation frame (*2*1000#0*W##before*2*1000#1/2*W##) via_pre_move_stop_guard, registers and suppresses the 6-gatewayWHO=2#11#001#11#001#1monitor translation (*#2*W*#11#001#1*LEVEL##emitted byF454,F459,F460,F461,MH202, andMyHomeServer1when*#2*W*#11#001*LEVEL##is sent oncmd), preserves sibling raw loopback guards (*WHO*WHAT*W##/*#2*W*#11#001*LEVEL##) when translation echoes arrive first, and tracks consumed gateway MACs (seen_sources) so genuine human repeat presses are never swallowed.custom_components/myhome/gateway_sessions.py):FrameSchedulersupersedes pending low-priority status frames for the same(who, where, dimension)target (_expose_replace_key) when a newer frame or high-priority confirmation is queued, cancelling the stale frame'swrittenfuture so an old mirror frame can never arrive after a wall-button press and undo it.CommandWorkerPoolmarks exposed targets (_is_exposed_target) so virtual addresses without physical bus hardware do not trigger command retries orERRORlogs on*#*0##(NACK) onMyHomeServer1/F460/F461while still resolving thewrittenfuture when the session is open.min_deltaDeadband &periodic_sendHeartbeat:last_sent_at + cooldown(_cooldown_timers) for eventual consistency, filters numeric jitter belowmin_delta, runsperiodic_sendheartbeats (900sdefault forWHO=4,86400sdefault forWHO=13in both YAML and UIExposeSubentryFlowHandler), and skips proactiveWHO=13clock writes inasync_sync_all()whenperiodic_send <= 0.validate.py,expose.py,where_grammar.py,config_flow.py):_finite_float,_non_bool_int,_non_bool_str,_validate_expose_action,_validate_expose_entry) rejects booleans disguised as integers/floats/strings, fractional ints (1.9),NaN/±infvalues (which otherwise bypass VoluptuousRange(min=..., max=...)),OverflowErrorfloats (10**400),*-containingwherestrings, invalidWHO=1/2point-to-point addresses, invalidWHO=4probe addresses (100,200, negative-105/-0, multi-####105, or 4+ digit9999) or out-of-range defaults (<-99.9or>99.9), and invalidWHO=18meter addresses (50,5256,70#0,7256#0) or negative defaults.where_grammar.py(zone_number,is_probe,normalize_expose_where,expose_message_where) uses.removeprefix("4-")and.removeprefix("#")(preventing-105or###105from normalizing into valid probes), and rejectsbooland±infwithValueError. It carries no OWNd shims for expose: the frame builders, query helpers (effective_what,is_status_request,is_dimension_writing, …) and profile flags all come from OWNd ≥ 2.0.0b11, so nothing in MyHOME patchesOWNMessageor the profiles for this feature.ExposeSubentryFlowHandler(config_flow.py) andExposeBinding.from_dict(expose.py) validatewho,where,cooldown,min_delta,periodic_send,brightness_step_pct, anddefaultagainst booleans,NaN/±inf, and malformed numeric strings without raising unhandledValueError/OverflowError._build_framesand_async_handle_bus_messageguard against booleans,NaN/±inf/overflow attributes and states,WHO=1missing/None/Falsebrightness attributes,WHO=13pre-NTP years outside2000..2099,kW/kWhmultiplication overflow, and out-of-range incoming bus dimension writes (WHO=1level>200,WHO=2level>100including255unknown sentinel,WHO=18energy_typeoutside1..4ordurationoutside0..255).gateway.py,discovery.py,button.py,config_flow.py,validate.py,where_grammar.py):myhome_message_{mac}signals fordelegated_away_whos(including late-starting secondaries) and deduplicates cross-gateway RX frames within0.5s(_last_rx_by_frame).climateentities.async_sync_all()after normal initial discovery completion (outsidefinally:) and on event-session reconnects.How is it tested
tests/test_expose.py(29end-to-end & regression tests) +tests/test_f461_live_shutter_expose_trace.py(5live F461 replay tests;100.0%statement coverage onexpose.py(1056 Stmts, 0 Miss),where_grammar.py, andExposeSubentryFlowHandlerinconfig_flow.py):WHO=1&WHO=2bidirectional state mirroring (*#1*W*#1*...##and*#2*W*#11#001*...##by default, plusreport_brightness/report_shutter_levelwhensupports_dimension_response_injection=True),attribute="brightness"OFFfallback (0%/*1*0*W##),#11#001#11#001#1translation echo suppression, customScriptactions with templates ({{ this.state }},{{ entity_id }},{{ who }},{{ where }}), conditions (condition: template,if/then/else,choose,repeat,parallel,sequence), startup unknown-service warnings, per-binding runtime error containment, dimmer hold stepping (1000#30/31), preset levels (2..10), and General ("0") / Area ("1".."9","00","100") broadcasts with#4#xxisolation.test_who2_oracle_40_step_echo_loop_simulation: 40-step oracle bus simulation verifying thatWHO=2UP(*2*1000#1*98##),STOP(*2*1000#0*98##), andDOWN(*2*1000#2*98##) presses followed by multi-frame gateway echoes (*2*1000#0*98##,*2*1000#1*98##,*2*1*98##) settle in exactly 1 service call each with zero oscillation.test_run4_expose_live_f461_protocol_and_echo_guard_replay(tests/test_f461_live_shutter_expose_trace.py): replays the2026-10-18and2026-10-20live physical BTicino F461 (2.0.11) captures (tests/fixtures/traces/f461_live_2026-10-18/myhome_trace_F461_live_2026-10-18.json, PR test(f461): add live F461 2026-10-18 trace for F411/4 lights, F411/2 5s shutter, and Expose bus verification #753, and@gdluck's2026-10-20follow-up matrix) throughExposeManager, verifying that:WHO=2event-session translation bursts (['*2*1000#0*25##', '*2*1000#1*25##']NACK) are absorbed with zero self-oscillation.*#2*25*#11#001*50##shutter position writes and their F461 event-session translation*#2*25*#11#001#1*50##are absorbed by_tx_echo_guardwithout looping back intocover.set_cover_position.test_expose_infeasible_inputs_hardening: verifies thatexpose_entry_schema,ExposeSubentryFlowHandler,ExposeBinding.from_dict,_build_frames,_async_handle_bus_message,where_grammar.py, andOWN*Commandbuilders reject booleans, fractional ints,NaN/±inf/10**400, negative/multi-#probe addresses, out-of-range numeric values, and malformedwhereaddresses.test_unverified_gateway_disables_who4_who18_with_one_warning: on anMH202profile (flags unset)WHO=4/WHO=18bindings send nothing whileWHO=1still syncs; exactly one warning per binding across repeated syncs; distinct reasons for aFalseflag and for a missing profile; and once the profile resolves toF461the same bindings emit*#4*050*#0*0215##/*#18*51*113*450##with no further warning. The test gateway helper defaults toF461Profile, the only profile with verified flags, and the shared-bus delegation test now delegatesWHO=18to anF461secondary.ONpress supersedes a still-queued status-tierOFFmirror frame so a staleOFFnever executes afterON, and that late startup/reconnect echoes are guarded until1.5safter the write future resolves.min_deltadeadband filtering,periodic_sendtimer firing,WHO=4positive/negative temperature encoding (*#4*W*#0*TTTT##write default +report_temperaturewhensupports_dimension_response_injection=True),WHO=18active power/energy +DIMENSION 1200streaming lifecycle,WHO=13clock sync (periodic_send > 0vsperiodic_send == 0read-only skip) and*#13**0/1/22##query replies, shared-bus secondary gateway delegation & RX deduplication, phantom entity/button suppression, YAML validation, andExposeSubentryFlowHandlerUI flows.master(0a82296, the b11 candidate, Features/add support open and close time #129 merged):test_expose.py,test_f461_live_shutter_expose_trace.pyandtest_where_usage_guard.pypass (38). The full suite gives 4319 passed, 3 failed; the 3 aretest_connection.py::TestOWNGatewayDiscoveryAndClassmethodstests that fail identically on the base branch with OWNdmaster, so they are OWNd drift, not this PR.python scripts/typing_ratchet.pymypy --strict: 0 error(s) in 0 module(s); baseline 0(as CI runs it, without OWNd type information; with OWNdmasterinstalled,expose.pyreports 16 errors becausecmd/confirm/framesmixOWNLightingCommand,OWNAutomationCommand,OWNEnergyCommandandOWNGatewayCommand, and the base branch already reports 11 in other modules)ruff check custom_components tests scriptsAll checks passed!python scripts/verify_ha_standards.pySUCCESS: All Home Assistant architectural standards passed!python scripts/sync_documentation.py --checkSUCCESS: All documentation is calibrated and in sync with the codebase!State Diagram
1. Write-Future-Bound Echo Guard & Virtual Actuator State Machine (
WHO=1/WHO=2)stateDiagram-v2 [*] --> Synced : async_sync_all() state "Synced (HA state == Bus state)" as Synced state "Queued in FrameScheduler (future pending)" as QueuedTX state "Written to Gateway Socket (1.5s TTL active)" as WrittenTX state "Bus Command Intercepted (*WHO*1000#WHAT*W##)" as BusCommand Synced --> QueuedTX : HA state_changed -> _async_dispatch_frames()\n1. Supersede any stale queued TX for (who, where, dim)\n2. Arm _EchoGuardEntry (raw + 1000#what + WHO=2 pre-move STOP + #11#001#1)\n3. Attach future callback QueuedTX --> Synced : Superseded or Cancelled before socket write\n-> Immediately remove _EchoGuardEntry QueuedTX --> WrittenTX : Socket write completes (future.done())\n-> Start 1.5s TTL from now WrittenTX --> WrittenTX : Monitor/Command echo arrives:\n• *2*1000#0*W## -> consume _pre_move_stop_guard\n• *WHO*1000#WHAT*W## or *#2*W*#11#001#1*L## -> consume trans_entry (keep sibling raw_entry!)\n• *WHO*WHAT*W## or *#2*W*#11#001*L## -> consume raw_entry WrittenTX --> Synced : All echo forms consumed or 1.5s TTL expires Synced --> BusCommand : Physical Button Press (*WHO*1000#WHAT*W## or *WHO*WHAT*W##) BusCommand --> QueuedTX : 1. Run HA Script / service (templates, conditions, error-contained)\n2. Queue virtual actuator confirmation (Priority.HIGH, no NACK retry)2. Trailing-Edge Cooldown,
min_deltaDeadband &periodic_sendState MachinestateDiagram-v2 [*] --> Idle : Binding Initialized state "Idle (Cooldown Elapsed)" as Idle state "Deadband Check (|val - last_sent| >= min_delta?)" as DeadbandCheck state "Cooldown Active (No Pending Value)" as CooldownActive state "Cooldown Active (Trailing-Edge Timer Armed)" as TrailingPending Idle --> DeadbandCheck : HA state_changed(new_val) DeadbandCheck --> Idle : |new_val - last_sent| < min_delta\n(Filtered out) DeadbandCheck --> CooldownActive : Delta >= min_delta\nDispatch Frame (last_sent_at = now) CooldownActive --> TrailingPending : state_changed(val) within cooldown\nSchedule _fire_trailing at (last_sent_at + cooldown) TrailingPending --> TrailingPending : Additional state_changed(latest_val)\nCoalesced until trailing-edge timer fires TrailingPending --> CooldownActive : Cooldown timer fires\nDispatch latest HA state (Eventual Consistency!) CooldownActive --> Idle : Cooldown window elapsed Idle --> CooldownActive : periodic_send timer fires\nRe-publish current stateWhere are the traces
MyHOMEown-firmware-oracle)2.0.11)Expose(WHO=1, 2, 4, 13, 18),F411/4Lights &F411/25 s Shutter (2026-10-18&2026-10-20)tests/fixtures/traces/f461_live_2026-10-18/myhome_trace_F461_live_2026-10-18.json(PR #753 &issuecomment-6277050571)run1_lights_group_and_general,run2_shutter_05_travel_and_reversal,run3_climate_zone2,run4_expose_virtual_bus_verificationWHO=1Command Translation (1000#30/31) & Dimmer Stepstests/fixtures/traces/issue_466/myhome_trace_MH202_f418u2_dimmer_2026-09-27T09-43-45.jsontests/fixtures/traces/issue_466/myhome_trace_H4890_all_2026-09-26T11-51-26.jsonresults/*/oracle/full/lights-level.tsv(*1*1*31##,MH202*1*1000#1*31##session response)WHO=2Shutter Command Translation, Pre-MoveSTOP#001#1Level Echotests/fixtures/traces/issue_645/myhome_trace_MyHomeServer1_cover_direction_then_stop_2026-10-11.jsontests/fixtures/traces/f461_live_2026-10-18/myhome_trace_F461_live_2026-10-18.jsonresults/*/oracle/full/trace-corpus.tsv(*2*1*31##*2*1000#0*31##, *2*1000#1*31##on 10 gateways;*#2*31*#11#001*40##*#2*31*#11#001#1*40##onF454/F459/F460/F461/MH202/MyHomeServer1)WHO=4Temperature ProbeDIMENSION 0tests/fixtures/traces/issue_429/myhome_trace_MyHomeServer1_who4_2026-09-23T18-13-49.jsontests/fixtures/traces/issue_619/myhome_trace_F453AV_who4_2026-10-11T10-29-57.jsonresults/*/oracle/full/thermo-ts10.tsv(*#4*1*0##$0499013000\r)WHO=18Active Power (113) & Streaming (1200)tests/fixtures/traces/issue_466/myhome_trace_F454_who18_2026-10-08T19-30-16.jsonresults/*/oracle/full/energy-ts10.tsv(*#18*51*113##$06D1A10233001D0000\r)NACKtests/fixtures/traces/issue_466/MyHomeServer1_dead_zone_nacks_2026-09-29T22-45.txttests/fixtures/traces/f461_live_2026-10-18/myhome_trace_F461_live_2026-10-18.jsonresults/MyHomeServer1/028206/oracle/full/lights-level.tsv(Reply: NACK (*#*0##),Verdict: OUT (Bus))What does the Oracle / Encyclopedia say
1. OpenWebNet Encyclopedia Machine KB Claims (
OpenWebNet-Encyclopedia/knowledge/claims)ownkb:claim:c000114–c000118corroborated_interpretation*WHO*WHAT*WHERE##(command/state),*#WHO*WHERE##(status request),*#WHO*WHERE*DIMENSION##(dimension request),*#WHO*WHERE*DIMENSION*VALUE...##(dimension response), and*#WHO*WHERE*#DIMENSION*VALUE...##(dimension write).ownkb:claim:c001440,c001442specifiedWHO=1Command Translation (1000#INNER_WHAT) wraps translated lighting/dimmer commands (*1*1000#WHAT*WHERE##).ownkb:claim:c002643,c002647specifiedWHO=2Command Translation (1000#INNER_WHAT) wraps translated automation commands (*2*1000#0/1/2*WHERE##).ownkb:claim:c001132,c001133,c003172,c003235,ownkb:claim:moc-c007713specified/observedWHO=4External probe class uses[PL_N]00;DIMENSION 0encodes measured temperature in 4-digit tenths of°C(0000..0500inc003235; valuesmoc-c007713).ownkb:claim:c002222,c002264,c002287,ownkb:claim:moc-c007644specified/observedWHO=18DIMENSION 113reports active power in Watts (*#18*W*113*WATT##) and is emitted duringDIMENSION 1200active-power streaming;DIMENSION 51..54report energy totalizers in Wh.ownkb:claim:c001598–c001604,c001674–c001676,c001742–c001743specifiedWHO=13DIMENSION 22(H*M*S*T*W*D*M*Y) combinesDIMENSION 0(time + timezone) andDIMENSION 1(weekday + date) in a single atomic R/W frame.2. Empirical Firmware Oracle & Live BTicino F461 (
2.0.11,2026-10-18&2026-10-20) Hardware Verdicts*#WHO*WHERE*DIM*VAL##) vs.#DIMWrite Form (*#WHO*WHERE*#DIM*VAL##) on Physical F461 (2.0.11):WHO=1:*#1*08*1*150*0##(report form) is rejected withNACKin 5 ms, whereas*#1*08*#1*150*0##(set_brightness#1write form) is ACKed in 88–126 ms.WHO=2:*#2*25*10*10*50*001*0##(report form) is rejected withNACKin 82 ms, whereas*#2*25*#11#001*50##(set_shutter_level#11#001write form) is ACKed in 83 ms and echoed onevtas*#2*25*#11#001#1*50##.WHO=4:*#4*2*0*0999##and*#4*50*0*0215##(report form without#0) are parsed bybt_termoas Dimension0read requests (*#4*2*0*0999##returns Zone 2's measured temperature*#4*2*0*0249##), whereas*#4*50*#0*0215##(write_temperature#0write form) is ACKed in 84–155 ms.WHO=18: Unlikebt_luciandbt_termo,bt_energy/bt_supervisionedoes accept un-hashed dimension response frames (*#18*51*113*450##,51..54,1200#1), which are ACKed in 140 ms – 1.7 s on F461.WHO=2Virtual Cover (WHERE="25", unpopulated vs physicalBT-F411/2atWHERE="05"):F411/2(WHERE="05", 5 s travel cutoff):*2*1*05##emits['*2*1000#0*05##', '*2*0*05##', '*2*1000#1*05##', '*2*1*05##']onevt, and a mid-flight reversal (*2*1*05##*2*2*05##) is followed 5.0 s later by the hardware relay's spontaneous end-of-travel*2*0*05##.WHERE="25", no physical actuator): sending*2*1*25##,*2*2*25##, or*2*0*25##on the live F461 immediately broadcasts['*2*1000#0*25##', '*2*1000#1*25##'](sometimes twice before the ~0.55 sNACK),['*2*1000#0*25##', '*2*1000#2*25##'], and['*2*1000#0*25##']on theevtmonitor session. Replaying these exactevtbursts throughExposeManagerconfirms that_pre_move_stop_guardand_tx_echo_guardabsorb all translation frames with zero self-oscillation.expose.py):WHEREand answeringrespond_to_readstatus queries from a TCP monitor session remain best-effort subject to gateway/control firmware.turn_on_action/turn_off_action/step_up_action/step_down_actionentries are validated viacv.SCRIPT_SCHEMA/async_validate_actions_configand executed via Home Assistant'sScripthelper (with sandboxedTemplateevaluation) in response to bus frames, and should only be configured on trusted physical/network SCS installations.