Add option to enable/disable active mode and make update_interval configurable (closes #11)

This commit is contained in:
freybene 2024-06-19 12:15:05 +02:00
parent 6f17edae14
commit 065163fdcc
7 changed files with 242 additions and 59 deletions

View file

@ -14,7 +14,6 @@ This integration does **not** allow you to perform actions based on button press
## ⚠️ Warning/Disclaimer ⚠️
- **Work in Progress**: This integration is still under development, and features may change without notice.
- **API Limitations**: Created by reverse engineering the SmartThings Find API, this integration might stop working at any time if changes occur on the SmartThings side.
- **Limited Testing**: The integration hasn't been thoroughly tested. If you encounter issues, please report them by creating an issue.
- **Feature Constraints**: The integration can only support features available on the [SmartThings Find website](https://smartthingsfind.samsung.com/). For instance, stopping a SmartTag from ringing is not possible due to API limitations (while other devices do support this; not yet implemented)
@ -27,6 +26,13 @@ Being able to let a SmartTag ring depends on a phone/tablet nearby which forward
If ringing your tag does not work, first try to let it ring from the [SmartThings Find website](https://smartthingsfind.samsung.com/). If it does not work from there, it can not work from Home Assistant too! Note that letting it ring with the SmartThings Mobile App is not the same as the website. Just because it does work in the App, does not mean it works on the web. So always use the web version to do your tests.
## Notes on active/passive mode
Starting with version 0.2.0, it is possible to configure whether to use the integration in an active or passive mode. In passive mode the integration only fetches the location from the server which was last reported to STF. In active mode the integration sends an actual "request location update" request. This will make the STF server try to connect to e.g. your phone, get the current location and send it back to the STF server from where the integration can then read it. This has quite a big impact on the devices battery and in some cases might also wake up the screen of the phone or tablet.
By default active mode is enabled for SmatrtTags but disabled for any other devices. You can change this behaviour on the integrations page by clicking on `Configure`. Here you can also set the update interval, which is set to 120 seconds by default.
## Installation Instructions
### Using HACS

View file

@ -9,7 +9,16 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.config_entries import ConfigEntry
from .const import DOMAIN, CONF_JSESSIONID
from .const import (
DOMAIN,
CONF_JSESSIONID,
CONF_ACTIVE_MODE_OTHERS,
CONF_ACTIVE_MODE_OTHERS_DEFAULT,
CONF_ACTIVE_MODE_SMARTTAGS,
CONF_ACTIVE_MODE_SMARTTAGS_DEFAULT,
CONF_UPDATE_INTERVAL,
CONF_UPDATE_INTERVAL_DEFAULT
)
from .utils import fetch_csrf, get_devices, get_device_location
_LOGGER = logging.getLogger(__name__)
@ -25,12 +34,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up SmartThings Find from a config entry."""
hass.data[DOMAIN][entry.entry_id] = {}
# Load the jsessionid from the config and create a session from it
jsessionid = entry.data[CONF_JSESSIONID]
session = async_get_clientsession(hass)
session.cookie_jar.update_cookies({"JSESSIONID": jsessionid})
active_smarttags = entry.options.get(CONF_ACTIVE_MODE_SMARTTAGS, CONF_ACTIVE_MODE_SMARTTAGS_DEFAULT)
active_others = entry.options.get(CONF_ACTIVE_MODE_OTHERS, CONF_ACTIVE_MODE_OTHERS_DEFAULT)
hass.data[DOMAIN][entry.entry_id].update({
CONF_ACTIVE_MODE_SMARTTAGS: active_smarttags,
CONF_ACTIVE_MODE_OTHERS: active_others,
})
# This raises ConfigEntryAuthFailed-exception if failed. So if we
# can continue after fetch_csrf, we know that authentication was ok
await fetch_csrf(hass, session, entry.entry_id)
@ -41,7 +59,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Create an update coordinator. This is responsible to regularly
# fetch data from STF and update the device_tracker and sensor
# entities
coordinator = SmartThingsFindCoordinator(hass, session, devices)
update_interval = entry.options.get(CONF_UPDATE_INTERVAL, CONF_UPDATE_INTERVAL_DEFAULT)
coordinator = SmartThingsFindCoordinator(hass, session, devices, 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
@ -73,7 +92,7 @@ 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):
def __init__(self, hass: HomeAssistant, session: aiohttp.ClientSession, devices, update_interval : int):
"""Initialize the coordinator."""
self.session = session
self.devices = devices
@ -82,7 +101,7 @@ class SmartThingsFindCoordinator(DataUpdateCoordinator):
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=2) # Update interval for all entities
update_interval=timedelta(seconds=update_interval) # Update interval for all entities
)
async def _async_update_data(self):

View file

@ -1,12 +1,27 @@
from typing import Any, Dict
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.config_entries import ConfigEntry
from .const import DOMAIN, CONF_JSESSIONID
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlowResult,
OptionsFlowWithConfigEntry
)
from .const import (
DOMAIN,
CONF_JSESSIONID,
CONF_UPDATE_INTERVAL,
CONF_UPDATE_INTERVAL_DEFAULT,
CONF_ACTIVE_MODE_SMARTTAGS,
CONF_ACTIVE_MODE_SMARTTAGS_DEFAULT,
CONF_ACTIVE_MODE_OTHERS,
CONF_ACTIVE_MODE_OTHERS_DEFAULT
)
from .utils import do_login_stage_one, do_login_stage_two, gen_qr_code_base64
import asyncio
import logging
_LOGGER = logging.getLogger(__name__)
class SmartThingsFindConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@ -119,4 +134,53 @@ class SmartThingsFindConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
step_id="user",
data_schema=vol.Schema({}),
)
return await self.async_step_user()
return await self.async_step_user()
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Create the options flow."""
return SmartThingsFindOptionsFlowHandler(config_entry)
class SmartThingsFindOptionsFlowHandler(OptionsFlowWithConfigEntry):
"""Handle an options flow."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle options flow."""
if user_input is not None:
res = self.async_create_entry(title="", data=user_input)
# Reload the integration entry to make sure the newly set options take effect
self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id)
return res
data_schema = vol.Schema(
{
vol.Optional(
CONF_UPDATE_INTERVAL,
default=self.options.get(
CONF_UPDATE_INTERVAL, CONF_UPDATE_INTERVAL_DEFAULT
),
): vol.All(vol.Coerce(int), vol.Clamp(min=30)),
vol.Optional(
CONF_ACTIVE_MODE_SMARTTAGS,
default=self.options.get(
CONF_ACTIVE_MODE_SMARTTAGS, CONF_ACTIVE_MODE_SMARTTAGS_DEFAULT
),
): bool,
vol.Optional(
CONF_ACTIVE_MODE_OTHERS,
default=self.options.get(
CONF_ACTIVE_MODE_OTHERS, CONF_ACTIVE_MODE_OTHERS_DEFAULT
),
): bool,
}
)
return self.async_show_form(step_id="init", data_schema=data_schema)

View file

@ -1,8 +1,19 @@
DOMAIN = "smartthings_find"
CONF_JSESSIONID = "jsessionid"
CONF_ACTIVE_MODE_SMARTTAGS = "active_mode_smarttags"
CONF_ACTIVE_MODE_OTHERS = "active_mode_others"
CONF_ACTIVE_MODE_SMARTTAGS_DEFAULT = True
CONF_ACTIVE_MODE_OTHERS_DEFAULT = False
CONF_UPDATE_INTERVAL = "update_interval"
CONF_UPDATE_INTERVAL_DEFAULT = 120
BATTERY_LEVELS = {
'FULL': 100,
'MEDIUM': 50,
'LOW': 15,
'VERY_LOW': 5
}
}

View file

@ -7,5 +7,16 @@
"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"
}
},
"options": {
"step": {
"init": {
"data": {
"update_interval": "Aktualisierungsintervall (in Sekunden)",
"active_mode_smarttags": "Aktiven Modus für SmartTags verwenden. Könnte Batterienutzung erhöhen",
"active_mode_others": "Aktiven Modus für andere Geräte (Handys, Uhren, ...) verwenden. Deutlich erhöhter Akkuverbrauch!"
}
}
}
}
}

View file

@ -7,5 +7,16 @@
"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"
}
},
"options": {
"step": {
"init": {
"data": {
"update_interval": "Update interval (seconds)",
"active_mode_smarttags": "Use active mode for SmartTags. Might increase battery consumption.",
"active_mode_others": "Use active mode for other devices (phones, watches, ...). Will heavily increase battery consumption."
}
}
}
}
}
}

View file

@ -17,7 +17,7 @@ from homeassistant.helpers.entity import DeviceInfo
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry
from .const import DOMAIN, BATTERY_LEVELS
from .const import DOMAIN, BATTERY_LEVELS, CONF_ACTIVE_MODE_SMARTTAGS, CONF_ACTIVE_MODE_OTHERS
_LOGGER = logging.getLogger(__name__)
@ -31,6 +31,7 @@ URL_DEVICE_LIST = "https://smartthingsfind.samsung.com/device/getDeviceList.do"
URL_REQUEST_LOC_UPDATE = "https://smartthingsfind.samsung.com/dm/addOperation.do"
URL_SET_LAST_DEVICE = "https://smartthingsfind.samsung.com/device/setLastSelect.do"
async def do_login_stage_one(hass: HomeAssistant) -> tuple:
"""
Perform the first stage of the login process.
@ -57,14 +58,16 @@ async def do_login_stage_one(hass: HomeAssistant) -> tuple:
# SmartThings find.
async with session.get(URL_PRE_SIGNIN.format(state=state)) as res:
if res.status != 200:
_LOGGER.error(f"Pre-login request failed with status {res.status}")
_LOGGER.error(
f"Pre-login request failed with status {res.status}")
return None
_LOGGER.debug(f"Step 1: Pre-Login: Status Code: {res.status}")
# Load the "Login with QR-Code"-page
async with session.get(URL_QR_CODE_SIGNIN) as res:
if res.status != 200:
_LOGGER.error(f"QR code URL request failed with status {res.status}, Response: {text[:250]}...")
_LOGGER.error(
f"QR code URL request failed with status {res.status}, Response: {text[:250]}...")
return None
text = await res.text()
_LOGGER.debug(f"Step 2: QR-Code URL: Status Code: {res.status}")
@ -81,9 +84,11 @@ async def do_login_stage_one(hass: HomeAssistant) -> tuple:
return session, qr_url
except Exception as e:
_LOGGER.error(f"An error occurred during the login process (stage 1): {e}", exc_info=True)
_LOGGER.error(
f"An error occurred during the login process (stage 1): {e}", exc_info=True)
return None
async def do_login_stage_two(session: aiohttp.ClientSession) -> str:
"""
Perform the second stage of the login process.
@ -107,10 +112,12 @@ async def do_login_stage_two(session: aiohttp.ClientSession) -> str:
# Fetch the _csrf token. This needs to be sent with each QR-Poll-Request
async with session.get(URL_SIGNIN_XHR) as res:
if res.status != 200:
_LOGGER.error(f"XHR login request failed with status {res.status}")
_LOGGER.error(
f"XHR login request failed with status {res.status}")
return None
json_res = await res.json()
_LOGGER.debug(f"Step 3: XHR Login: Status Code: {res.status}, Response: {json_res}")
_LOGGER.debug(
f"Step 3: XHR Login: Status Code: {res.status}, Response: {json_res}")
csrf_token = json_res.get('_csrf', {}).get('token')
if not csrf_token:
@ -135,10 +142,12 @@ async def do_login_stage_two(session: aiohttp.ClientSession) -> str:
await asyncio.sleep(2)
async with session.post(URL_QR_POLL, json={}, headers={'X-Csrf-Token': csrf_token}) as res:
if res.status != 200:
_LOGGER.error(f"QR check request failed with status {res.status}")
_LOGGER.error(
f"QR check request failed with status {res.status}")
continue
js = await res.json()
_LOGGER.debug(f"Step 4: QR CHECK: Status Code: {res.status}, Response: {js}")
_LOGGER.debug(
f"Step 4: QR CHECK: Status Code: {res.status}, Response: {js}")
if js.get('rtnCd') == "SUCCESS":
next_url = js.get('nextURL')
@ -156,7 +165,8 @@ async def do_login_stage_two(session: aiohttp.ClientSession) -> str:
# for SmartThings Find (which uses it's own JSESSIONID).
async with session.get(URL_SIGNIN_SUCCESS.format(next_url=next_url)) as res:
if res.status != 200:
_LOGGER.error(f"Login success URL request failed with status {res.status}")
_LOGGER.error(
f"Login success URL request failed with status {res.status}")
return None
text = await res.text()
_LOGGER.debug(f"Step 5: Login success: Status Code: {res.status}")
@ -166,9 +176,11 @@ async def do_login_stage_two(session: aiohttp.ClientSession) -> str:
# https://smartthingsfind.samsung.com/login.do?auth_server_url=eu-auth2.samsungosp.com
# &code=[...]&code_expires_in=300&state=[state we generated above]
# &api_server_url=eu-auth2.samsungosp.com
match = re.search(r'window\.location\.href\s*=\s*[\'"]([^\'"]+)[\'"]', text)
match = re.search(
r'window\.location\.href\s*=\s*[\'"]([^\'"]+)[\'"]', text)
if not match:
_LOGGER.error("Redirect URL not found in the login success response")
_LOGGER.error(
"Redirect URL not found in the login success response")
return None
redirect_url = match.group(1)
@ -178,11 +190,14 @@ async def do_login_stage_two(session: aiohttp.ClientSession) -> str:
# which is what actually authenticates us for SmartThings Find.
async with session.get(redirect_url) as res:
if res.status != 200:
_LOGGER.error(f"Redirect URL request failed with status {res.status}")
_LOGGER.error(
f"Redirect URL request failed with status {res.status}")
return None
_LOGGER.debug(f"Step 6: Follow redirect URL: Status Code: {res.status}")
_LOGGER.debug(
f"Step 6: Follow redirect URL: Status Code: {res.status}")
jsessionid = session.cookie_jar.filter_cookies('https://smartthingsfind.samsung.com').get('JSESSIONID')
jsessionid = session.cookie_jar.filter_cookies(
'https://smartthingsfind.samsung.com').get('JSESSIONID')
if not jsessionid:
_LOGGER.error("JSESSIONID not found in cookies")
return None
@ -191,9 +206,11 @@ async def do_login_stage_two(session: aiohttp.ClientSession) -> str:
return jsessionid.value
except Exception as e:
_LOGGER.error(f"An error occurred during the login process (stage 2): {e}", exc_info=True)
_LOGGER.error(
f"An error occurred during the login process (stage 2): {e}", exc_info=True)
return None
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.
@ -204,7 +221,7 @@ async def fetch_csrf(hass: HomeAssistant, session: aiohttp.ClientSession, entry_
Args:
hass (HomeAssistant): Home Assistant instance.
session (aiohttp.ClientSession): The current session.
Raises:
ConfigEntryAuthFailed: If the CSRF token is not found or if the authentication fails.
"""
@ -227,6 +244,7 @@ async def fetch_csrf(hass: HomeAssistant, session: aiohttp.ClientSession, entry_
raise ConfigEntryAuthFailed(err_msg)
async def get_devices(hass: HomeAssistant, session: aiohttp.ClientSession, entry_id: str) -> list:
"""
Sends a request to the SmartThings Find API to retrieve a list of devices associated with the user's account.
@ -239,12 +257,14 @@ async def get_devices(hass: HomeAssistant, session: aiohttp.ClientSession, entry
list: A list of devices if successful, empty list otherwise.
"""
url = f"{URL_DEVICE_LIST}?_csrf={hass.data[DOMAIN][entry_id]['_csrf']}"
async with session.post(url, headers = {'Accept': 'application/json'}, data={}) as response:
async with session.post(url, headers={'Accept': 'application/json'}, data={}) as response:
if response.status != 200:
_LOGGER.error(f"Failed to retrieve devices [{response.status}]: {await response.text()}")
if response.status == 404:
_LOGGER.warn(f"Received 404 while trying to fetch devices -> Triggering reauth")
raise ConfigEntryAuthFailed("Request to get device list failed: 404")
_LOGGER.warn(
f"Received 404 while trying to fetch devices -> Triggering reauth")
raise ConfigEntryAuthFailed(
"Request to get device list failed: 404")
return []
response_json = await response.json()
devices_data = response_json["deviceList"]
@ -252,11 +272,14 @@ async def get_devices(hass: HomeAssistant, session: aiohttp.ClientSession, entry
for device in devices_data:
# Double unescaping required. Example:
# "Benedev's S22" first becomes "Benedev's S22" and then "Benedev's S22"
device['modelName'] = html.unescape(html.unescape(device['modelName']))
device['modelName'] = html.unescape(
html.unescape(device['modelName']))
identifier = (DOMAIN, device['dvceID'])
ha_dev = device_registry.async_get(hass).async_get_device({identifier})
ha_dev = device_registry.async_get(
hass).async_get_device({identifier})
if ha_dev and ha_dev.disabled:
_LOGGER.debug(f"Ignoring disabled device: '{device['modelName']}' (disabled by {ha_dev.disabled_by})")
_LOGGER.debug(
f"Ignoring disabled device: '{device['modelName']}' (disabled by {ha_dev.disabled_by})")
continue
ha_dev_info = DeviceInfo(
identifiers={identifier},
@ -269,6 +292,7 @@ async def get_devices(hass: HomeAssistant, session: aiohttp.ClientSession, entry
_LOGGER.debug(f"Adding device: {device['modelName']}")
return devices
async def get_device_location(hass: HomeAssistant, session: aiohttp.ClientSession, dev_data: dict, entry_id: str) -> dict:
"""
Sends requests to update the device's location and retrieves the current location data for the specified device.
@ -298,12 +322,23 @@ async def get_device_location(hass: HomeAssistant, session: aiohttp.ClientSessio
csrf_token = hass.data[DOMAIN][entry_id]["_csrf"]
try:
async with session.post(f"{URL_REQUEST_LOC_UPDATE}?_csrf={csrf_token}", json=update_payload) as response:
# _LOGGER.debug(f"[{dev_name}] Update request response ({response.status}): {await response.text()}")
pass
active = (
(dev_data['deviceTypeCode'] == 'TAG' and hass.data[DOMAIN][entry_id][CONF_ACTIVE_MODE_SMARTTAGS]) or
(dev_data['deviceTypeCode'] != 'TAG' and hass.data[DOMAIN]
[entry_id][CONF_ACTIVE_MODE_OTHERS])
)
async with session.post(f"{URL_SET_LAST_DEVICE}?_csrf={csrf_token}", json=set_last_payload, headers = {'Accept': 'application/json'}) as response:
_LOGGER.debug(f"[{dev_name}] Location response ({response.status})")
if active:
_LOGGER.debug("Active mode; requesting location update now")
async with session.post(f"{URL_REQUEST_LOC_UPDATE}?_csrf={csrf_token}", json=update_payload) as response:
# _LOGGER.debug(f"[{dev_name}] Update request response ({response.status}): {await response.text()}")
pass
else:
_LOGGER.debug("Passive mode; not requesting location update")
async with session.post(f"{URL_SET_LAST_DEVICE}?_csrf={csrf_token}", json=set_last_payload, headers={'Accept': 'application/json'}) as response:
_LOGGER.debug(
f"[{dev_name}] Location response ({response.status})")
if response.status == 200:
data = await response.json()
res = {
@ -339,60 +374,74 @@ async def get_device_location(hass: HomeAssistant, session: aiohttp.ClientSessio
utcDate = None
if 'extra' in op and 'gpsUtcDt' in op['extra']:
utcDate = parse_stf_date(op['extra']['gpsUtcDt'])
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.error(
f"[{dev_name}] No UTC date found for operation '{op['oprnType']}', this should not happen! OP: {json.dumps(op)}")
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']})")
_LOGGER.debug(
f"[{dev_name}] Ignoring location older than the previous ({op['oprnType']})")
continue
locFound = False
if 'latitude' in op:
used_loc['latitude'] = float(op['latitude'])
used_loc['latitude'] = float(
op['latitude'])
locFound = True
if 'longitude' in op:
used_loc['longitude'] = float(op['longitude'])
used_loc['longitude'] = float(
op['longitude'])
locFound = True
if not locFound:
_LOGGER.warn(f"[{dev_name}] Found no coordinates in operation '{op['oprnType']}'")
_LOGGER.warn(
f"[{dev_name}] Found no coordinates in operation '{op['oprnType']}'")
else:
res['location_found'] = True
used_loc['gps_accuracy'] = calc_gps_accuracy(op.get('horizontalUncertainty'), op.get('verticalUncertainty'))
used_loc['gps_accuracy'] = calc_gps_accuracy(
op.get('horizontalUncertainty'), op.get('verticalUncertainty'))
used_loc['gps_date'] = utcDate
used_op = op
elif 'encLocation' in op:
loc = op['encLocation']
if 'encrypted' in loc and loc['encrypted']:
_LOGGER.info(f"[{dev_name}] Ignoring encrypted location ({op['oprnType']})")
_LOGGER.info(
f"[{dev_name}] Ignoring encrypted location ({op['oprnType']})")
continue
elif 'gpsUtcDt' not in loc:
_LOGGER.info(f"[{dev_name}] Ignoring location with missing date ({op['oprnType']})")
_LOGGER.info(
f"[{dev_name}] Ignoring location with missing date ({op['oprnType']})")
continue
else:
utcDate = parse_stf_date(loc['gpsUtcDt'])
if used_loc['gps_date'] and used_loc['gps_date'] >= utcDate:
_LOGGER.debug(f"[{dev_name}] Ignoring location older than the previous ({op['oprnType']})")
_LOGGER.debug(
f"[{dev_name}] Ignoring location older than the previous ({op['oprnType']})")
continue
else:
locFound = False
if 'latitude' in loc:
used_loc['latitude'] = float(loc['latitude'])
used_loc['latitude'] = float(
loc['latitude'])
locFound = True
if 'longitude' in loc:
used_loc['longitude'] = float(loc['longitude'])
used_loc['longitude'] = float(
loc['longitude'])
locFound = True
else:
res['location_found'] = True
if not locFound:
_LOGGER.warn(f"[{dev_name}] Found no coordinates in operation '{op['oprnType']}'")
_LOGGER.warn(
f"[{dev_name}] Found no coordinates in operation '{op['oprnType']}'")
used_loc['gps_accuracy'] = calc_gps_accuracy(loc.get('horizontalUncertainty'), loc.get('verticalUncertainty'))
used_loc['gps_accuracy'] = calc_gps_accuracy(
loc.get('horizontalUncertainty'), loc.get('verticalUncertainty'))
used_loc['gps_date'] = utcDate
used_op = op
continue
@ -401,16 +450,20 @@ async def get_device_location(hass: HomeAssistant, session: aiohttp.ClientSessio
res['used_op'] = used_op
res['used_loc'] = used_loc
else:
_LOGGER.warn(f"[{dev_name}] No useable location-operation found")
_LOGGER.warn(
f"[{dev_name}] No useable location-operation found")
_LOGGER.debug(f" --> {dev_name} used operation: {'NONE' if not used_op else used_op['oprnType']}")
_LOGGER.debug(
f" --> {dev_name} used operation: {'NONE' if not used_op else used_op['oprnType']}")
else:
_LOGGER.warn(f"[{dev_name}] No operation found in response; marking update failed")
_LOGGER.warn(
f"[{dev_name}] No operation found in response; marking update failed")
res['update_success'] = False
return res
else:
_LOGGER.error(f"[{dev_name}] Failed to fetch device data ({response.status})")
_LOGGER.error(
f"[{dev_name}] Failed to fetch device data ({response.status})")
res_text = await response.text()
_LOGGER.debug(f"[{dev_name}] Full response: '{res_text}'")
@ -418,15 +471,18 @@ async def get_device_location(hass: HomeAssistant, session: aiohttp.ClientSessio
# enough at this point. Instead we have to ask the user to go through
# the whole auth flow again
if res_text == 'Logout' or response.status == 401:
raise ConfigEntryAuthFailed(f"Session not valid anymore, received status_code of {response.status} with response '{res_text}'")
raise ConfigEntryAuthFailed(
f"Session not valid anymore, received status_code of {response.status} with response '{res_text}'")
except ConfigEntryAuthFailed as e:
raise
except Exception as e:
_LOGGER.error(f"[{dev_name}] Exception occurred while fetching location data for tag '{dev_name}': {e}", exc_info=True)
_LOGGER.error(
f"[{dev_name}] Exception occurred while fetching location data for tag '{dev_name}': {e}", exc_info=True)
return None
def calc_gps_accuracy(hu: float, vu: float) -> float:
"""
Calculate the GPS accuracy using the Pythagorean theorem.
@ -445,6 +501,7 @@ def calc_gps_accuracy(hu: float, vu: float) -> float:
except ValueError:
return None
def get_sub_location(ops: list, subDeviceName: str) -> tuple:
"""
Extracts sub-location data for devices that contain multiple
@ -471,6 +528,7 @@ def get_sub_location(ops: list, subDeviceName: str) -> tuple:
return op, sub_loc
return {}, {}
def parse_stf_date(datestr: str) -> datetime:
"""
Parses a date string in the format "%Y%m%d%H%M%S" to a datetime object.
@ -484,6 +542,7 @@ def parse_stf_date(datestr: str) -> datetime:
"""
return datetime.strptime(datestr, "%Y%m%d%H%M%S").replace(tzinfo=pytz.UTC)
def get_battery_level(dev_name: str, ops: list) -> int:
"""
Try to extract the device battery level from the received operation
@ -503,10 +562,12 @@ def get_battery_level(dev_name: str, ops: list) -> int:
try:
batt = int(batt_raw)
except ValueError:
_LOGGER.warn(f"[{dev_name}]: Received invalid battery level: {batt_raw}")
_LOGGER.warn(
f"[{dev_name}]: Received invalid battery level: {batt_raw}")
return batt
return None
def gen_qr_code_base64(data: str) -> str:
"""
Generates a QR code from the provided data and returns it as a base64-encoded string.