fix(coordinator): retry a command once after a dead-session reconnect

async_send_command's _do_put caught any exception, logged it, and
returned -- no reconnect, no retry, no error the user could see. A
command landing on a session Samsung's firmware closed between polls
(the same 'known device behavior' _async_update_data already
reconnects around) was silently lost, with nothing to do about it but
a manual reload of the device (issue #294).

Mirror the poll path's own recovery: on failure, close the dead
session, pause, and retry the PUT once against a freshly reconnected
one. If that also fails, raise a HomeAssistantError instead of just
logging, so the user gets a visible error rather than a command that
quietly did nothing. The retry runs under the same session lock the
poll path uses, so a write landing mid-reconnect can't race a
concurrent poll cycle rebuilding the same session.
This commit is contained in:
Marc Billow committed 2026-08-06 00:44:35 +00:00
1 parent 252306838d
commit 77c2d7831e
8 files changed
+132 -7

No files matched your search

+31 -7
View File
@@ -15,7 +15,7 @@ from typing import Any
import cbor2
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -1026,18 +1026,42 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
def _do_put():
if self._session is None:
self._connect_session()
sess = self._session
if sess is None:
raise RuntimeError("no session")
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=self._POST_TIMEOUT_S)
self._log.info("PUT %s → code %#04x", write_href, code)
try:
await self.hass.async_add_executor_job(_do_put)
except Exception as e:
self._log.error("command failed for %s: %s", write_href, e)
else:
await self.async_request_refresh()
# A session Samsung's firmware closed between polls (the same "known
# device behavior" _async_update_data reconnects around) previously
# sank the PUT here with nothing but a log line -- no retry, no
# user-facing error, so the command was silently lost until the next
# manual reload (issue #294). Mirror the poll path's own recovery:
# one reconnect, one retry, and this time raise on final failure so
# the user gets a red toast instead of nothing.
#
# Locked the same as the poll path so a write landing mid-reconnect
# doesn't tear down a session _async_update_data is simultaneously
# rebuilding (or vice versa).
async with self._session_lock:
try:
await self.hass.async_add_executor_job(_do_put)
except Exception as e:
self._log.warning("command failed for %s, reconnecting: %s", write_href, e)
await self.hass.async_add_executor_job(self._close_session)
await asyncio.sleep(self._RECONNECT_PAUSE_S)
try:
await self.hass.async_add_executor_job(_do_put)
except Exception as e2:
self._log.error("command failed for %s after reconnect: %s", write_href, e2)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={"href": write_href, "error": str(e2)},
) from e2
await self.async_request_refresh()
# ------------------------------------------------------------------
# Debug raw write (issue #54): a power-user escape hatch for the
@@ -1356,6 +1356,9 @@
},
"intensive_unavailable_for_cycle": {
"message": "Intenzivní není u vybraného cyklu k dispozici."
},
"command_failed": {
"message": "Příkaz pro {href} selhal i po opětovném připojení: {error}"
}
}
}
@@ -1356,6 +1356,9 @@
},
"intensive_unavailable_for_cycle": {
"message": "Intensive isn't available on the selected cycle."
},
"command_failed": {
"message": "The command to {href} failed even after reconnecting: {error}"
}
}
}
@@ -119,6 +119,9 @@
},
"intensive_unavailable_for_cycle": {
"message": "El modo intensivo no está disponible en el ciclo seleccionado."
},
"command_failed": {
"message": "El comando para {href} falló incluso después de reconectar: {error}"
}
},
"entity": {
@@ -1356,6 +1356,9 @@
},
"intensive_unavailable_for_cycle": {
"message": "Intensivo non è disponibile per il ciclo selezionato."
},
"command_failed": {
"message": "Il comando per {href} è fallito anche dopo la riconnessione: {error}"
}
}
}
@@ -1356,6 +1356,9 @@
},
"intensive_unavailable_for_cycle": {
"message": "선택한 코스에서는 강력 세탁을 사용할 수 없습니다."
},
"command_failed": {
"message": "재연결 후에도 {href} 명령이 실패했습니다: {error}"
}
}
}
@@ -1356,6 +1356,9 @@
},
"intensive_unavailable_for_cycle": {
"message": "Intensief is niet beschikbaar voor het geselecteerde programma."
},
"command_failed": {
"message": "Het commando naar {href} is ook na opnieuw verbinden mislukt: {error}"
}
}
}