diff --git a/custom_components/smartthings_find/__init__.py b/custom_components/smartthings_find/__init__.py index e5a9024..375747e 100644 --- a/custom_components/smartthings_find/__init__.py +++ b/custom_components/smartthings_find/__init__.py @@ -60,7 +60,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # fetch data from STF and update the device_tracker and sensor # entities update_interval = entry.options.get(CONF_UPDATE_INTERVAL, CONF_UPDATE_INTERVAL_DEFAULT) - coordinator = SmartThingsFindCoordinator(hass, session, devices, update_interval) + coordinator = SmartThingsFindCoordinator(hass, session, entry.entry_id, update_interval) # This is what makes the whole integration slow to load (around 10-15 # seconds for my 15 devices) but it is the right way to do it. Only if @@ -92,10 +92,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class SmartThingsFindCoordinator(DataUpdateCoordinator): """Class to manage fetching SmartThings Find data.""" - def __init__(self, hass: HomeAssistant, session: aiohttp.ClientSession, devices, update_interval : int): + def __init__(self, hass: HomeAssistant, session: aiohttp.ClientSession, entry_id: str, update_interval : int): """Initialize the coordinator.""" self.session = session - self.devices = devices + self._entry_id = entry_id self.hass = hass super().__init__( hass, @@ -107,11 +107,12 @@ class SmartThingsFindCoordinator(DataUpdateCoordinator): async def _async_update_data(self): """Fetch data from SmartThings Find.""" try: + devices = self.hass.data[DOMAIN][self._entry_id]["devices"] tags = {} _LOGGER.debug(f"Updating locations...") - for device in self.devices: + for device in devices: dev_data = device['data'] - tag_data = await get_device_location(self.hass, self.session, dev_data, self.config_entry.entry_id) + tag_data = await get_device_location(self.hass, self.session, dev_data, self._entry_id) tags[dev_data['dvceID']] = tag_data _LOGGER.debug(f"Fetched {len(tags)} locations") return tags diff --git a/custom_components/smartthings_find/config_flow.py b/custom_components/smartthings_find/config_flow.py index 319ed95..23d1b68 100644 --- a/custom_components/smartthings_find/config_flow.py +++ b/custom_components/smartthings_find/config_flow.py @@ -1,11 +1,11 @@ -from typing import Any, Dict +from typing import Any import voluptuous as vol from homeassistant import config_entries from homeassistant.core import callback from homeassistant.config_entries import ( ConfigEntry, ConfigFlowResult, - OptionsFlowWithConfigEntry + OptionsFlowWithConfigEntry, ) from .const import ( DOMAIN, @@ -15,130 +15,65 @@ from .const import ( CONF_ACTIVE_MODE_SMARTTAGS, CONF_ACTIVE_MODE_SMARTTAGS_DEFAULT, CONF_ACTIVE_MODE_OTHERS, - CONF_ACTIVE_MODE_OTHERS_DEFAULT + CONF_ACTIVE_MODE_OTHERS_DEFAULT, ) -from .utils import do_login_stage_one, do_login_stage_two, gen_qr_code_base64 -import asyncio +from .utils import validate_jsessionid import logging _LOGGER = logging.getLogger(__name__) + class SmartThingsFindConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for SmartThings Find.""" + """Handle a config flow for SmartThings Find. + + Samsung's account.samsung.com login was rebuilt as a JS SPA (IAM/OAuth2 + with bot protection), so the original QR-code login flow no longer works. + Until that is reverse-engineered we fall back to letting the user paste + the JSESSIONID cookie they obtain from a normal browser session at + https://smartthingsfind.samsung.com/. + """ VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL reauth_entry: ConfigEntry | None = None - task_stage_one: asyncio.Task | None = None - task_stage_two: asyncio.Task | None = None - - qr_url = None - session = None - - jsessionid = None - - - error = None - - async def do_stage_one(self): - _LOGGER.debug("Running login stage 1") - try: - stage_one_res = await do_login_stage_one(self.hass) - if not stage_one_res is None: - self.session, self.qr_url = stage_one_res - else: - self.error = "Login stage 1 failed. Check logs for details." - _LOGGER.warn("Login stage 1 failed") - _LOGGER.debug("Login stage 1 done") - except Exception as e: - self.error = "Login stage 1 failed. Check logs for details." - _LOGGER.error(f"Exception in stage 1: {e}", exc_info=True) - - async def do_stage_two(self): - _LOGGER.debug("Running login stage 2") - try: - stage_two_res = await do_login_stage_two(self.session) - if not stage_two_res is None: - self.jsessionid = stage_two_res - _LOGGER.info("Login successful") - else: - self.error = "Login stage 2 failed. Check logs for details." - _LOGGER.warning("Login stage 2 failed") - _LOGGER.debug("Login stage 2 done") - except Exception as e: - self.error = "Login stage 2 failed. Check logs for details." - _LOGGER.error(f"Exception in stage 2: {e}", exc_info=True) - - # First step: Get QR Code login URL - async def async_step_user(self, user_input=None): - _LOGGER.debug("Entering login stage 1") - if not self.task_stage_one: - self.task_stage_one = self.hass.async_create_task(self.do_stage_one()) - if not self.task_stage_one.done(): - return self.async_show_progress( - progress_action="task_stage_one", - progress_task=self.task_stage_one - ) - # At this point stage 1 is completed - if self.error: - # An error occurred, cancel the flow by moving to the finish- - # form which shows the error - return self.async_show_progress_done(next_step_id="finish") - # No error -> Proceed to stage 2 - return self.async_show_progress_done(next_step_id="auth_stage_two") - - # Second step: Wait until QR scanned an log in - async def async_step_auth_stage_two(self, user_input=None): - if not self.task_stage_two: - self.task_stage_two = self.hass.async_create_task(self.do_stage_two()) - if not self.task_stage_two.done(): - return self.async_show_progress( - progress_action="task_stage_two", - progress_task=self.task_stage_two, - description_placeholders={ - "qr_code": gen_qr_code_base64(self.qr_url), - "url": self.qr_url, - "code": self.qr_url.split('/')[-1], - } - ) - return self.async_show_progress_done(next_step_id="finish") - - async def async_step_finish(self, user_input=None): - if self.error: - return self.async_show_form(step_id="finish", errors={'base': self.error}) - - data={CONF_JSESSIONID: self.jsessionid} - + async def _async_handle_jsessionid(self, jsessionid: str) -> ConfigFlowResult: + data = {CONF_JSESSIONID: jsessionid} if self.reauth_entry: - # Finish step was called by reauth-flow. Do not create a new entry, - # instead update the existing entry - return self.async_update_reload_and_abort( - self.reauth_entry, - data=data - ) - + return self.async_update_reload_and_abort(self.reauth_entry, data=data) return self.async_create_entry(title="SmartThings Find", data=data) + async def async_step_user(self, user_input=None): + errors: dict[str, str] = {} + if user_input is not None: + jsessionid = (user_input.get(CONF_JSESSIONID) or "").strip() + if not jsessionid: + errors["base"] = "empty_cookie" + elif await validate_jsessionid(self.hass, jsessionid): + return await self._async_handle_jsessionid(jsessionid) + else: + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_JSESSIONID): str}), + errors=errors, + ) + async def async_step_reauth(self, user_input=None): self.reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) - return await self.async_step_reauth_confirm() - - async def async_step_reauth_confirm(self, user_input=None): - if user_input is None: - return self.async_show_form( - step_id="user", - data_schema=vol.Schema({}), - ) return await self.async_step_user() - + async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None): - return await self.async_step_reauth_confirm(self) - + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_user() + @staticmethod @callback def async_get_options_flow( @@ -146,8 +81,8 @@ class SmartThingsFindConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> config_entries.OptionsFlow: """Create the options flow.""" return SmartThingsFindOptionsFlowHandler(config_entry) - - + + class SmartThingsFindOptionsFlowHandler(OptionsFlowWithConfigEntry): """Handle an options flow.""" @@ -186,4 +121,4 @@ class SmartThingsFindOptionsFlowHandler(OptionsFlowWithConfigEntry): ): bool, } ) - return self.async_show_form(step_id="init", data_schema=data_schema) \ No newline at end of file + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/custom_components/smartthings_find/device_tracker.py b/custom_components/smartthings_find/device_tracker.py index 4fcea27..d47a230 100644 --- a/custom_components/smartthings_find/device_tracker.py +++ b/custom_components/smartthings_find/device_tracker.py @@ -43,7 +43,7 @@ class SmartThingsDeviceTracker(DeviceTrackerEntity): if 'icons' in device['data'] and 'coloredIcon' in device['data']['icons']: self._attr_entity_picture = device['data']['icons']['coloredIcon'] - self.async_update = coordinator.async_add_listener(self.async_write_ha_state) + self.async_on_update(coordinator.async_add_listener(self.async_write_ha_state)) def async_write_ha_state(self): if not self.enabled: diff --git a/custom_components/smartthings_find/manifest.json b/custom_components/smartthings_find/manifest.json index 9f36133..9810d3d 100644 --- a/custom_components/smartthings_find/manifest.json +++ b/custom_components/smartthings_find/manifest.json @@ -2,13 +2,13 @@ "domain": "smartthings_find", "name": "SmartThings Find", "after_dependencies": ["http"], - "version": "0.2.1", - "documentation": "https://github.com/Vedeneb/HA-SmartThings-Find", - "issue_tracker": "https://github.com/Vedeneb/HA-SmartThings-Find/issues", + "version": "0.3.0", + "documentation": "https://git.jeena.net/jeena/HA-SmartThings-Find", + "issue_tracker": "https://git.jeena.net/jeena/HA-SmartThings-Find/issues", "integration_type": "hub", "dependencies": [], - "codeowners": ["@Vedeneb"], - "requirements": ["requests", "qrcode"], + "codeowners": ["@jeena"], + "requirements": ["requests", "qrcode[pil]", "pillow", "pytz"], "iot_class": "cloud_polling", "config_flow": true } diff --git a/custom_components/smartthings_find/translations/de.json b/custom_components/smartthings_find/translations/de.json index 3846589..36258d8 100644 --- a/custom_components/smartthings_find/translations/de.json +++ b/custom_components/smartthings_find/translations/de.json @@ -3,9 +3,18 @@ "abort": { "reauth_successful": "Erfolgreich authentifiziert." }, - "progress": { - "task_stage_one": "Vorbereitung läuft, bitte warten...", - "task_stage_two": "Bitte scanne den QR Code mit deinem Galaxy-Gerät, um dich anzumelden. Alternativ kannst du dich direkt im Browser anmelden, indum du [hier]({url}) klickst. \n\n![QR Code](data:image/png;base64,{qr_code})\n\nCode kann nicht gescannt werden? Gehe auf [signin.samsung.com/key](https://signin.samsung.com/key/) und gib den folgenden Code ein:\n\n## ```{code}```\n" + "error": { + "invalid_auth": "Das Cookie wurde von SmartThings Find abgelehnt. Stelle sicher, dass du nur den Wert des JSESSIONID-Cookies (nicht den gesamten Cookie-Header) aus einer angemeldeten Sitzung kopiert hast.", + "empty_cookie": "Bitte einen JSESSIONID-Wert einfügen." + }, + "step": { + "user": { + "title": "JSESSIONID-Cookie einfügen", + "description": "Samsungs QR-Code-Login funktioniert nicht mehr (account.samsung.com wurde umgebaut). Bis das ersetzt ist, musst du das Session-Cookie manuell besorgen:\n\n1. Öffne [smartthingsfind.samsung.com](https://smartthingsfind.samsung.com/) in einem normalen Browser und melde dich an.\n2. Öffne DevTools → Anwendung (Chrome) bzw. Speicher (Firefox) → Cookies → smartthingsfind.samsung.com.\n3. Kopiere den Wert des **JSESSIONID**-Cookies und füge ihn unten ein.\n\nDas Cookie bleibt meist mehrere Wochen gültig. Läuft es ab, fordert Home Assistant eine erneute Authentifizierung an – dann diese Schritte einfach wiederholen.", + "data": { + "jsessionid": "JSESSIONID" + } + } } }, "options": { diff --git a/custom_components/smartthings_find/translations/en.json b/custom_components/smartthings_find/translations/en.json index 9a04e79..49a0aa3 100644 --- a/custom_components/smartthings_find/translations/en.json +++ b/custom_components/smartthings_find/translations/en.json @@ -3,9 +3,18 @@ "abort": { "reauth_successful": "Reauthentication successful." }, - "progress": { - "task_stage_one": "Preparing, please wait...", - "task_stage_two": "To login please scan the following QR Code with your Galaxy device. You can also login using this browser by clicking [here]({url}). \n\n![QR Code](data:image/png;base64,{qr_code})\n\nUnable to scan the code? Go to [signin.samsung.com/key](https://signin.samsung.com/key/) and enter the following code:\n\n## ```{code}```\n" + "error": { + "invalid_auth": "Cookie was rejected by SmartThings Find. Make sure you copied the JSESSIONID value (not the whole cookie header) from a tab where you are logged in.", + "empty_cookie": "Please paste a JSESSIONID value." + }, + "step": { + "user": { + "title": "Paste JSESSIONID cookie", + "description": "Samsung's QR code login flow no longer works (account.samsung.com was rebuilt). Until it is replaced you have to obtain the session cookie manually:\n\n1. Open [smartthingsfind.samsung.com](https://smartthingsfind.samsung.com/) in a regular browser and log in.\n2. Open DevTools → Application (Chrome) or Storage (Firefox) → Cookies → smartthingsfind.samsung.com.\n3. Copy the value of the **JSESSIONID** cookie and paste it below.\n\nThe cookie usually stays valid for several weeks. When it expires Home Assistant will trigger a reauth and you repeat these steps.", + "data": { + "jsessionid": "JSESSIONID" + } + } } }, "options": { @@ -19,4 +28,4 @@ } } } -} \ No newline at end of file +} diff --git a/custom_components/smartthings_find/utils.py b/custom_components/smartthings_find/utils.py index f95ec7c..8ebcb09 100644 --- a/custom_components/smartthings_find/utils.py +++ b/custom_components/smartthings_find/utils.py @@ -211,6 +211,28 @@ async def do_login_stage_two(session: aiohttp.ClientSession) -> str: return None +async def validate_jsessionid(hass: HomeAssistant, jsessionid: str) -> bool: + """Check whether a JSESSIONID cookie is currently valid for SmartThings Find. + + Performs a single GET against the CSRF endpoint with the cookie applied; + a valid session returns 200 with a `_csrf` response header. + """ + session = async_get_clientsession(hass) + session.cookie_jar.update_cookies({"JSESSIONID": jsessionid}) + try: + async with session.get(URL_GET_CSRF) as response: + if response.status == 200 and response.headers.get("_csrf"): + return True + body = (await response.text())[:200] + _LOGGER.warning( + f"JSESSIONID validation failed: status={response.status}, body='{body}'" + ) + return False + except Exception as e: + _LOGGER.error(f"JSESSIONID validation error: {e}", exc_info=True) + return False + + async def fetch_csrf(hass: HomeAssistant, session: aiohttp.ClientSession, entry_id: str): """ Retrieves the _csrf-Token which needs to be sent with each following request. @@ -377,8 +399,13 @@ async def get_device_location(hass: HomeAssistant, session: aiohttp.ClientSessio utcDate = parse_stf_date( op['extra']['gpsUtcDt']) else: - _LOGGER.error( - f"[{dev_name}] No UTC date found for operation '{op['oprnType']}', this should not happen! OP: {json.dumps(op)}") + _LOGGER.warning( + f"[{dev_name}] No UTC date found for operation '{op['oprnType']}'. OP: {json.dumps(op)}") + continue + + if utcDate is None: + _LOGGER.warning( + f"[{dev_name}] Could not parse UTC date for operation '{op['oprnType']}'") continue if used_loc['gps_date'] and used_loc['gps_date'] >= utcDate: @@ -419,6 +446,10 @@ async def get_device_location(hass: HomeAssistant, session: aiohttp.ClientSessio continue else: utcDate = parse_stf_date(loc['gpsUtcDt']) + if utcDate is None: + _LOGGER.warning( + f"[{dev_name}] Could not parse UTC date for encrypted location ('{op['oprnType']}')") + continue if used_loc['gps_date'] and used_loc['gps_date'] >= utcDate: _LOGGER.debug( f"[{dev_name}] Ignoring location older than the previous ({op['oprnType']})") @@ -519,17 +550,18 @@ def get_sub_location(ops: list, subDeviceName: str) -> tuple: for op in ops: if subDeviceName in op.get('encLocation', {}): loc = op['encLocation'][subDeviceName] + gps_date = parse_stf_date(loc.get('gpsUtcDt', '')) sub_loc = { - "latitude": float(loc['latitude']), - "longitude": float(loc['longitude']), + "latitude": float(loc['latitude']) if loc.get('latitude') is not None else None, + "longitude": float(loc['longitude']) if loc.get('longitude') is not None else None, "gps_accuracy": calc_gps_accuracy(loc.get('horizontalUncertainty'), loc.get('verticalUncertainty')), - "gps_date": parse_stf_date(loc['gpsUtcDt']) + "gps_date": gps_date } return op, sub_loc return {}, {} -def parse_stf_date(datestr: str) -> datetime: +def parse_stf_date(datestr: str) -> datetime | None: """ Parses a date string in the format "%Y%m%d%H%M%S" to a datetime object. This is the format, the SmartThings Find API uses. @@ -538,9 +570,16 @@ def parse_stf_date(datestr: str) -> datetime: datestr (str): The date string in the format "%Y%m%d%H%M%S". Returns: - datetime: A datetime object representing the input date string. + datetime: A datetime object representing the input date string, + or None if parsing fails. """ - return datetime.strptime(datestr, "%Y%m%d%H%M%S").replace(tzinfo=pytz.UTC) + if not datestr: + return None + try: + return datetime.strptime(datestr, "%Y%m%d%H%M%S").replace(tzinfo=pytz.UTC) + except ValueError: + _LOGGER.warning(f"Failed to parse date string: '{datestr}'") + return None def get_battery_level(dev_name: str, ops: list) -> int: