diff --git a/.vscode/launch.json b/.vscode/launch.json index be6148f..b4fc1f5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,7 +3,7 @@ "version": "0.2.0", "configurations": [ { - "command": "/usr/local/bin/hass", + "command": "${workspaceFolder}/scripts/develop", "name": "Run Home Assistant", "request": "launch", "type": "node-terminal" @@ -37,4 +37,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/README.md b/README.md index 98a5380..b611db8 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Code template was mainly taken from [@Ludeeus](https://github.com/ludeeus)'s [in ## License -Copyright (C) 2022 Sam Steele. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at +Copyright (C) 2024 Sam Steele. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/config/configuration.yaml b/config/configuration.yaml index 4ca1e71..c85b429 100644 --- a/config/configuration.yaml +++ b/config/configuration.yaml @@ -10,3 +10,5 @@ logger: default: info logs: custom_components.medisafe: debug + +debugpy: diff --git a/custom_components/medisafe/__init__.py b/custom_components/medisafe/__init__.py index 2419169..6703581 100644 --- a/custom_components/medisafe/__init__.py +++ b/custom_components/medisafe/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022 Sam Steele +# Copyright (C) 2024 Sam Steele # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -10,14 +10,13 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -import asyncio import logging from datetime import timedelta +from dataclasses import dataclass from homeassistant.config_entries import ConfigEntry from homeassistant.core import Config from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import UpdateFailed @@ -45,37 +44,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data.setdefault(DOMAIN, {}) _LOGGER.info(STARTUP_MESSAGE) - username = entry.data.get(CONF_USERNAME) - password = entry.data.get(CONF_PASSWORD) + coordinator = MedisafeDataUpdateCoordinator(hass) - session = async_get_clientsession(hass) - client = MedisafeApiClient(username, password, session) - - coordinator = MedisafeDataUpdateCoordinator(hass, client=client) - await coordinator.async_refresh() - - if not coordinator.last_update_success: - raise ConfigEntryNotReady + entry.runtime_data = MedisafeData( + client=MedisafeApiClient( + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + session=async_get_clientsession(hass), + ), + coordinator=coordinator, + ) - hass.data[DOMAIN][entry.entry_id] = coordinator + await coordinator.async_config_entry_first_refresh() - for platform in PLATFORMS: - if entry.options.get(platform, True): - coordinator.platforms.append(platform) - hass.async_add_job( - hass.config_entries.async_forward_entry_setup(entry, platform) - ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - entry.add_update_listener(async_reload_entry) return True class MedisafeDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching data from the API.""" - def __init__(self, hass: HomeAssistant, client: MedisafeApiClient) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize.""" - self.api = client self.platforms = [] super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) @@ -83,30 +75,39 @@ def __init__(self, hass: HomeAssistant, client: MedisafeApiClient) -> None: async def _async_update_data(self): """Update data via library.""" try: - return await self.api.async_get_data() + return await self.config_entry.runtime_data.client.async_get_data() except Exception as exception: raise UpdateFailed() from exception + def get_medication(self, uuid): + if "medications" not in self.data: + _LOGGER.error("Medisafe has no data yet") + return None + + if "medications" in self.data: + for medication in self.data["medications"]: + if medication["uuid"] == uuid: + return medication + + _LOGGER.error(f"Medication not found for UUID: {uuid}") + + return None + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Handle removal of an entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - unloaded = all( - await asyncio.gather( - *[ - hass.config_entries.async_forward_entry_unload(entry, platform) - for platform in PLATFORMS - if platform in coordinator.platforms - ] - ) - ) - if unloaded: - hass.data[DOMAIN].pop(entry.entry_id) - - return unloaded + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Reload config entry.""" await async_unload_entry(hass, entry) await async_setup_entry(hass, entry) + + +@dataclass +class MedisafeData: + """Data for the Medisafe integration.""" + + client: MedisafeApiClient + coordinator: MedisafeDataUpdateCoordinator diff --git a/custom_components/medisafe/api.py b/custom_components/medisafe/api.py index a071232..8ff5d44 100644 --- a/custom_components/medisafe/api.py +++ b/custom_components/medisafe/api.py @@ -16,6 +16,8 @@ from datetime import datetime from datetime import timedelta +from homeassistant.exceptions import ConfigEntryAuthFailed + import aiohttp import async_timeout @@ -39,10 +41,16 @@ async def async_get_data(self) -> dict: {"username": self._username, "password": self._password}, ) if "error" in auth: - raise Exception(auth["error"]["message"]) + if "message" in auth["error"]: + _LOGGER.error(f"Authentication failed: {auth['error']['message']}") + raise ConfigEntryAuthFailed(auth["error"]["message"]) + else: + _LOGGER.error(f"Authentication failed: {auth['error']}") + raise ConfigEntryAuthFailed(auth["error"]) if "token" not in auth: - raise Exception("Authentication Failed") + _LOGGER.error(f"No token recieved") + raise ConfigEntryAuthFailed("Authentication Failed") start = int((datetime.today() - timedelta(days=1)).timestamp() * 1000) end = int((datetime.today() + timedelta(days=1)).timestamp() * 1000) diff --git a/custom_components/medisafe/const.py b/custom_components/medisafe/const.py index 6de9a4b..a873b36 100644 --- a/custom_components/medisafe/const.py +++ b/custom_components/medisafe/const.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022 Sam Steele +# Copyright (C) 2024 Sam Steele # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -14,7 +14,7 @@ NAME = "Medisafe" DOMAIN = "medisafe" DOMAIN_DATA = f"{DOMAIN}_data" -VERSION = "0.0.2" +VERSION = "0.0.4" ATTRIBUTION = "Data provided by https://medisafe.com/" ISSUE_URL = "https://github.com/c99koder/ha-medisafe/issues" diff --git a/custom_components/medisafe/manifest.json b/custom_components/medisafe/manifest.json index 92d35e2..0940bcd 100644 --- a/custom_components/medisafe/manifest.json +++ b/custom_components/medisafe/manifest.json @@ -3,10 +3,14 @@ "name": "Medisafe", "documentation": "https://github.com/c99koder/ha-medisafe", "issue_tracker": "https://github.com/c99koder/ha-medisafe/issues", - "dependencies": ["http"], + "dependencies": [ + "http" + ], "config_flow": true, - "codeowners": ["@c99koder"], + "codeowners": [ + "@c99koder" + ], "iot_class": "cloud_polling", "requirements": [], - "version": "0.0.3" -} + "version": "0.0.4" +} \ No newline at end of file diff --git a/custom_components/medisafe/sensor.py b/custom_components/medisafe/sensor.py index ca033a0..526e356 100644 --- a/custom_components/medisafe/sensor.py +++ b/custom_components/medisafe/sensor.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022 Sam Steele +# Copyright (C) 2024 Sam Steele # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -10,6 +10,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import logging + from datetime import date from homeassistant.components.sensor import SensorStateClass @@ -19,22 +21,32 @@ from .const import CONF_USERNAME from .const import DOMAIN +_LOGGER: logging.Logger = logging.getLogger(__package__) + async def async_setup_entry(hass, entry, async_add_devices): - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data.coordinator await coordinator.async_config_entry_first_refresh() entities = [] - for idx, ent in enumerate(coordinator.data["medications"]): - if ent["treatmentStatus"] == 1: - entities.append(MedisafeMedicationEntity(coordinator, entry, idx)) + if coordinator.data is not None and "medications" in coordinator.data: + _LOGGER.info(f"Got {len(coordinator.data['medications'])} medications") + for ent in coordinator.data["medications"]: + if "pillsLeft" not in ent: + _LOGGER.debug(f"Missing pillsLeft: {ent['name']} with UUID {ent['uuid']}") + elif ent["treatmentStatus"] != 1: + _LOGGER.debug(f"Inactive medication: {ent['name']} with UUID {ent['uuid']}") + else: + _LOGGER.debug(f"Adding: {ent['name']} with UUID {ent['uuid']}") + entities.append(MedisafeMedicationEntity(coordinator, entry, ent["uuid"])) entities.append(MedisafeStatusCountEntity(coordinator, entry, "taken")) entities.append(MedisafeStatusCountEntity(coordinator, entry, "missed")) entities.append(MedisafeStatusCountEntity(coordinator, entry, "dismissed")) entities.append(MedisafeStatusCountEntity(coordinator, entry, "pending")) + _LOGGER.info(f"Setting up {len(entities)} entities") async_add_devices(entities) @@ -42,13 +54,22 @@ class MedisafeStatusCountEntity(CoordinatorEntity): _attr_state_class = SensorStateClass.MEASUREMENT _attr_unit_of_measurement = "doses" _attr_icon = "mdi:pill" + _attr_suggested_display_precision = 0 + _attr_has_entity_name = True def __init__(self, coordinator, config_entry, status): super().__init__(coordinator) self.status = status self.config_entry = config_entry self._attr_unique_id = f"medication_{self.config_entry.entry_id}_{status}" - self._attr_name = f"medication {status}".title() + + @property + def name(self): + return f"medication {self.status}".title() + + @property + def available(self): + return "items" in self.coordinator.data @property def state(self): @@ -72,39 +93,46 @@ def extra_state_attributes(self): class MedisafeMedicationEntity(CoordinatorEntity): _attr_state_class = SensorStateClass.MEASUREMENT - _attr_device_class = "medication" _attr_unit_of_measurement = "pills" _attr_icon = "mdi:pill" + _attr_suggested_display_precision = 0 + _attr_has_entity_name = True - def __init__(self, coordinator, config_entry, idx): + def __init__(self, coordinator, config_entry, uuid): super().__init__(coordinator) - self.idx = idx + self.uuid = uuid self.config_entry = config_entry - self._attr_unique_id = f"medication_{self.config_entry.entry_id}_{self.coordinator.data['medications'][self.idx]['uuid']}" - self._attr_name = self.coordinator.data["medications"][self.idx]["name"] + self._attr_unique_id = f"medication_{self.config_entry.entry_id}_{uuid}" + + @property + def name(self): + return self.coordinator.get_medication(self.uuid)["name"] @property def state(self): - if "pillsLeft" in self.coordinator.data["medications"][self.idx]: - return self.coordinator.data["medications"][self.idx]["pillsLeft"] + medication = self.coordinator.get_medication(self.uuid) + + if "pillsLeft" in medication: + return medication["pillsLeft"] else: return None @property def available(self): - return ( - "medications" in self.coordinator.data - and "pillsLeft" in self.coordinator.data["medications"][self.idx] - ) + medication = self.coordinator.get_medication(self.uuid) + + return (medication is not None and "pillsLeft" in medication) @property def extra_state_attributes(self): - if "dose" in self.coordinator.data["medications"][self.idx]: + medication = self.coordinator.get_medication(self.uuid) + + if "dose" in medication: return { "account": self.config_entry.data.get(CONF_USERNAME), "attribution": ATTRIBUTION, "integration": DOMAIN, - "dose": self.coordinator.data["medications"][self.idx]["dose"], + "dose": medication["dose"], } else: return { @@ -115,8 +143,9 @@ def extra_state_attributes(self): @property def entity_picture(self): - med = self.coordinator.data["medications"][self.idx] - if med is None or med["shape"] == "capsule": + medication = self.coordinator.get_medication(self.uuid) + + if medication is None or medication["shape"] == "capsule": return None else: - return f"https://web.medisafe.com/medication-icons/pill_{med['shape']}_{med['color']}.png" + return f"https://web.medisafe.com/medication-icons/pill_{medication['shape']}_{medication['color']}.png" diff --git a/hacs.json b/hacs.json index eaf8c50..c000e0a 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,5 @@ { "name": "Medisafe", "hacs": "1.6.0", - "homeassistant": "2022.2.1" -} + "homeassistant": "2024.8.0" +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b126712..2417403 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,2 @@ colorlog==6.8.2 -homeassistant==2024.10.1 -pip>=21.3.1 -ruff==0.6.7 +homeassistant==2024.10.0