5656 find_capability as _find_capability ,
5757 merge_capability_register_word ,
5858)
59+ from .write_confirmation import (
60+ load_write_confirmation ,
61+ start_write_confirmation ,
62+ store_write_confirmation ,
63+ write_confirmation_diagnostics ,
64+ )
5965from .catalog_identity import (
6066 CatalogIdentityProbe ,
6167 InverterIdentityNoDataError ,
@@ -557,8 +563,18 @@ async def async_read_values(
557563 extra_blocks = await _read_out_of_block_capability_registers (
558564 session , inverter .capabilities , polled_blocks , already_read = aux_registers
559565 )
560- _apply_capability_read_back (values , inverter .capabilities , polled_blocks + extra_blocks )
561- return DriverReadResult (values = values , mode = DriverReadMode .FULL )
566+ all_polled_blocks = polled_blocks + extra_blocks
567+ _apply_capability_read_back (values , inverter .capabilities , all_polled_blocks )
568+ diagnostics = _observe_capability_write_full_poll (
569+ runtime_state ,
570+ inverter .capabilities ,
571+ all_polled_blocks ,
572+ )
573+ return DriverReadResult (
574+ values = values ,
575+ mode = DriverReadMode .FULL ,
576+ diagnostics = diagnostics ,
577+ )
562578
563579 async def async_write_capability (
564580 self ,
@@ -571,6 +587,7 @@ async def async_write_capability(
571587 ) -> Any :
572588 capability = _find_capability (capability_key , inverter .capabilities or self .write_capabilities )
573589 raw_words = _encode_capability_words (capability , value )
590+ native_value = _decode_capability_value (capability , raw_words )
574591
575592 session = self ._session (transport , inverter .probe_target )
576593 if capability .bitmask :
@@ -602,8 +619,49 @@ async def async_write_capability(
602619 else :
603620 await session .write_holding (capability .register , raw_words )
604621
605- native_value = _decode_capability_value (capability , raw_words )
606- inverter .details [capability .key ] = native_value
622+ # A Modbus write acknowledgement proves only that the request completed
623+ # on the wire. For setting-like capabilities, observe the exact target
624+ # register immediately as a separate fact. A mismatch is retained for
625+ # diagnostics but is not declared final here: some SMG firmwares apply
626+ # settings asynchronously, and the hub's mandatory full refresh remains
627+ # the user-visible confirmation authority.
628+ if capability .value_kind != "action" :
629+ trace = start_write_confirmation (
630+ runtime_state ,
631+ capability_key = capability .key ,
632+ value_key = capability .value_key ,
633+ requested_value = value ,
634+ expected_value = native_value ,
635+ requested_words = tuple (int (word ) for word in raw_words ),
636+ )
637+ try :
638+ observed_words = tuple (
639+ int (word )
640+ for word in await session .read_holding (
641+ capability .register ,
642+ capability .word_count ,
643+ )
644+ )
645+ comparable_words = _comparable_capability_words (
646+ capability ,
647+ observed_words ,
648+ )
649+ observed_value = _decode_capability_value (
650+ capability ,
651+ list (comparable_words ),
652+ )
653+ trace = trace .with_immediate_observation (
654+ value = observed_value ,
655+ words = comparable_words ,
656+ matched = (comparable_words == trace .requested_words ),
657+ )
658+ except Exception as exc : # the mandatory full poll may still confirm
659+ trace = trace .with_immediate_unavailable (error = exc )
660+ store_write_confirmation (runtime_state , trace )
661+
662+ # Never project a requested value through DetectedInverter.details.
663+ # That mapping is detection evidence, not runtime state. The immediate
664+ # exact read and subsequent full poll above are the only observations.
607665 return native_value
608666
609667 async def async_capture_support_evidence (
@@ -835,6 +893,93 @@ def _apply_capability_read_back(
835893 values [value_key ] = raw_value
836894
837895
896+ def _observe_capability_write_full_poll (
897+ runtime_state : dict [str , Any ] | None ,
898+ capabilities ,
899+ register_blocks : tuple [tuple [int , list [int ]], ...],
900+ ) -> dict [str , Any ]:
901+ """Fold one real SMG full poll into the retained write trace."""
902+
903+ trace = load_write_confirmation (runtime_state )
904+ if trace is None :
905+ return {}
906+ capability = next (
907+ (
908+ candidate
909+ for candidate in capabilities
910+ if getattr (candidate , "key" , "" ) == trace .capability_key
911+ ),
912+ None ,
913+ )
914+ if capability is None :
915+ trace = trace .with_poll_observation (value = None , matched = None )
916+ store_write_confirmation (runtime_state , trace )
917+ return write_confirmation_diagnostics (runtime_state )
918+
919+ try :
920+ observed_words = _capability_words_from_blocks (
921+ capability ,
922+ register_blocks ,
923+ )
924+ comparable_words = _comparable_capability_words (
925+ capability ,
926+ observed_words ,
927+ )
928+ observed_value = _decode_capability_value (
929+ capability ,
930+ list (comparable_words ),
931+ )
932+ except Exception :
933+ trace = trace .with_poll_observation (value = None , matched = None )
934+ else :
935+ trace = trace .with_poll_observation (
936+ value = observed_value ,
937+ matched = (comparable_words == trace .requested_words ),
938+ )
939+ store_write_confirmation (runtime_state , trace )
940+ return write_confirmation_diagnostics (runtime_state )
941+
942+
943+ def _capability_words_from_blocks (
944+ capability : WriteCapability ,
945+ register_blocks : tuple [tuple [int , list [int ]], ...],
946+ ) -> tuple [int , ...]:
947+ """Read one capability's exact raw words from already-polled blocks."""
948+
949+ register_map : dict [int , int ] = {}
950+ for start , block in register_blocks :
951+ for index , raw in enumerate (block ):
952+ register_map [start + index ] = int (raw )
953+ words : list [int ] = []
954+ for offset in range (capability .word_count ):
955+ register = capability .register + offset
956+ if register not in register_map :
957+ raise ValueError (
958+ f"capability_full_poll_readback_missing:{ capability .key } :{ register } "
959+ )
960+ words .append (register_map [register ])
961+ return tuple (words )
962+
963+
964+ def _comparable_capability_words (
965+ capability : WriteCapability ,
966+ observed_words : tuple [int , ...],
967+ ) -> tuple [int , ...]:
968+ """Return raw words in the same shape used by the capability encoder."""
969+
970+ if len (observed_words ) != capability .word_count :
971+ raise ValueError (
972+ f"capability_post_write_read_length:{ capability .key } :"
973+ f"expected={ capability .word_count } :observed={ len (observed_words )} "
974+ )
975+ if capability .bitmask :
976+ observed_field = (
977+ int (observed_words [0 ]) & capability .bitmask
978+ ) >> capability .bitmask_shift
979+ return (observed_field ,)
980+ return tuple (int (word ) for word in observed_words )
981+
982+
838983def _decode_block (
839984 start_register : int ,
840985 values : list [int ],
0 commit comments