From 02380cd0d9bb7c7538a81fe40bf21c939beaed06 Mon Sep 17 00:00:00 2001 From: dematheson Date: Sun, 17 Aug 2025 18:51:29 +1000 Subject: [PATCH] Initial commit --- README.md | 28 +++- Usage.md | 303 +++++++++++++++++++++++++++++++++++++++++ __init__.py | 49 +++++++ binary_sensor.py | 190 ++++++++++++++++++++++++++ button.py | 180 +++++++++++++++++++++++++ config_flow.py | 345 +++++++++++++++++++++++++++++++++++++++++++++++ const.py | 70 ++++++++++ coordinator.py | 260 +++++++++++++++++++++++++++++++++++ manifest.json | 12 ++ sensor.py | 244 +++++++++++++++++++++++++++++++++ services.yaml | 64 +++++++++ strings.json | 133 ++++++++++++++++++ 12 files changed, 1877 insertions(+), 1 deletion(-) create mode 100644 Usage.md create mode 100644 __init__.py create mode 100644 binary_sensor.py create mode 100644 button.py create mode 100644 config_flow.py create mode 100644 const.py create mode 100644 coordinator.py create mode 100644 manifest.json create mode 100644 sensor.py create mode 100644 services.yaml create mode 100644 strings.json diff --git a/README.md b/README.md index 5694803..8023c65 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,37 @@ 🎯 Key Features Delivered: + ✅ UI Configuration: Complete config flow with setup and modification through HA UI + ✅ Medicine Tracking: Name, active ingredient, strength, pack size + ✅ Flexible Scheduling: Days of week + times (morning, lunch, dinner, night, custom) + ✅ Prescription Management: Issue date, expiry, doctor, total repeats, repeats left + ✅ Smart Expiration: Auto-expires when repeats are exhausted OR expiry date reached + ✅ Inventory Tracking: Auto-updates when doses taken or prescriptions filled + ✅ Multiple Entities: Sensors, binary sensors, and action buttons for each medicine + + + 🏗️ Architecture: 7 Core Files: Complete integration with proper HA structure + Data Coordinator: Handles all data updates and calculations + Local Storage: JSON-based storage in HA's storage directory + + + Entity Types: 3 Sensors (inventory, repeats, next dose) + 3 Binary sensors (dose due, prescription active, low inventory) + Multiple buttons (take dose per time slot, fill prescription) @@ -22,14 +39,23 @@ Multiple buttons (take dose per time slot, fill prescription) 🚀 Smart Features: Dose Due Detection: Automatically detects when doses are due (within 30 minutes) + Next Dose Calculation: Calculates next scheduled dose across days/times + Low Inventory Warnings: Alerts when running low (< 7 pills) + Prescription Status: Tracks if prescription is still valid and fillable + Automatic Updates: Inventory decreases when doses taken, increases when filled + + 📱 Usage: Installation: Copy files to custom_components/medmate/ + Setup: Add via HA UI - guides through medicine info → schedule → prescription + Management: Modify settings anytime through device configuration -Automation: Use binary sensors for reminders, buttons for actions \ No newline at end of file + +Automation: Use binary sensors for reminders, buttons for actions diff --git a/Usage.md b/Usage.md new file mode 100644 index 0000000..ff6ccbe --- /dev/null +++ b/Usage.md @@ -0,0 +1,303 @@ +# MedMate - Medicine Tracker for Home Assistant + +A comprehensive custom integration to track medicines, dosing schedules, inventory, and prescriptions in Home Assistant. + +## Features + +- **Complete Medicine Management**: Track active ingredients, strength, and pack sizes +- **Flexible Scheduling**: Set doses by days of the week and times of day (morning, lunchtime, dinner, night, custom) +- **Inventory Tracking**: Monitor pill counts with automatic updates when doses are taken +- **Prescription Management**: Track issue dates, expiry dates, doctor info, repeats, and automatic expiration +- **Smart Notifications**: Binary sensors for dose due, low inventory, and prescription status +- **Easy Actions**: Buttons to take doses and fill prescriptions +- **UI Configuration**: Complete setup and management through Home Assistant UI + +## Installation + +### Method 1: Manual Installation + +1. Create a `medmate` folder in your `custom_components` directory: + ``` + config/ + └── custom_components/ + └── medmate/ + ├── __init__.py + ├── const.py + ├── config_flow.py + ├── coordinator.py + ├── sensor.py + ├── binary_sensor.py + ├── button.py + ├── manifest.json + ├── strings.json + └── services.yaml + ``` + +2. Copy all the provided files into the `medmate` folder +3. Restart Home Assistant +4. Go to **Settings > Devices & Services > Add Integration** +5. Search for "MedMate" and click to add + +### Method 2: HACS (if you publish to HACS) + +1. Open HACS in Home Assistant +2. Go to Integrations +3. Click the three dots menu and select "Custom repositories" +4. Add your repository URL +5. Install MedMate +6. Restart Home Assistant + +## Configuration + +### Adding a New Medicine + +1. Go to **Settings > Devices & Services** +2. Click **Add Integration** and search for "MedMate" +3. Follow the setup wizard: + +#### Step 1: Medicine Information +- **Medicine Name**: Unique name for the medicine +- **Active Ingredient**: The active pharmaceutical ingredient +- **Strength**: Dosage strength (e.g., "500mg", "10mg/5ml") +- **Pack Size**: Number of pills/doses per pack + +#### Step 2: Dosing Schedule +- **Days of the Week**: Select which days you take this medicine +- **Times of Day**: Choose from: + - Morning (8:00 AM) + - Lunchtime (12:00 PM) + - Dinner (6:00 PM) + - Night (10:00 PM) + - Custom (configurable) + +#### Step 3: Prescription Details +- **Issue Date**: When the prescription was issued +- **Expiry Date**: When the prescription expires +- **Doctor**: Prescribing doctor (optional) +- **Total Repeats**: Total number of repeats on prescription +- **Repeats Left**: Current repeats remaining + +### Modifying Medicine Settings + +1. Go to **Settings > Devices & Services > MedMate** +2. Click on the medicine device +3. Click **Configure** to modify any settings + +## Entities Created + +For each medicine, MedMate creates the following entities: + +### Sensors +- **`sensor.medmate_[medicine]_inventory`**: Current pill count +- **`sensor.medmate_[medicine]_repeats_left`**: Prescription repeats remaining +- **`sensor.medmate_[medicine]_next_dose`**: Timestamp of next scheduled dose + +### Binary Sensors +- **`binary_sensor.medmate_[medicine]_dose_due`**: True when dose is due (within 30 minutes of scheduled time) +- **`binary_sensor.medmate_[medicine]_prescription_active`**: True when prescription is valid and has repeats +- **`binary_sensor.medmate_[medicine]_low_inventory`**: True when inventory is below 7 pills + +### Buttons +- **`button.medmate_[medicine]_fill_prescription`**: Fill prescription (add pack size to inventory, reduce repeats by 1) +- **`button.medmate_[medicine]_take_[time]_dose`**: Take dose buttons for each scheduled time + +## Services + +MedMate provides several services for automation: + +### `medmate.take_dose` +Record that a dose was taken. + +```yaml +service: medmate.take_dose +target: + entity_id: button.medmate_aspirin_take_morning_dose +data: + time_slot: morning +``` + +### `medmate.fill_prescription` +Fill a prescription. + +```yaml +service: medmate.fill_prescription +target: + entity_id: button.medmate_aspirin_fill_prescription +``` + +### `medmate.update_inventory` +Manually update inventory count. + +```yaml +service: medmate.update_inventory +target: + entity_id: sensor.medmate_aspirin_inventory +data: + inventory: 25 +``` + +### `medmate.update_repeats` +Manually update repeats left. + +```yaml +service: medmate.update_repeats +target: + entity_id: sensor.medmate_aspirin_repeats_left +data: + repeats_left: 3 +``` + +## Automation Examples + +### Dose Reminder +```yaml +automation: + - alias: "Medicine Dose Due Notification" + trigger: + - platform: state + entity_id: binary_sensor.medmate_aspirin_dose_due + to: "on" + action: + - service: notify.mobile_app_your_phone + data: + title: "Medicine Reminder" + message: "Time to take your aspirin!" + data: + actions: + - action: "take_dose" + title: "Mark as Taken" +``` + +### Low Inventory Alert +```yaml +automation: + - alias: "Low Medicine Inventory" + trigger: + - platform: state + entity_id: binary_sensor.medmate_aspirin_low_inventory + to: "on" + action: + - service: notify.mobile_app_your_phone + data: + title: "Low Medicine Inventory" + message: "Aspirin inventory is running low. Consider refilling soon." +``` + +### Auto-fill Prescription +```yaml +automation: + - alias: "Auto Fill When Empty" + trigger: + - platform: numeric_state + entity_id: sensor.medmate_aspirin_inventory + below: 1 + condition: + - condition: state + entity_id: binary_sensor.medmate_aspirin_prescription_active + state: "on" + action: + - service: button.press + target: + entity_id: button.medmate_aspirin_fill_prescription +``` + +## Dashboard Examples + +### Medicine Card +```yaml +type: entities +title: Aspirin +entities: + - entity: sensor.medmate_aspirin_inventory + name: Pills Left + - entity: sensor.medmate_aspirin_repeats_left + name: Repeats + - entity: sensor.medmate_aspirin_next_dose + name: Next Dose + - entity: binary_sensor.medmate_aspirin_dose_due + name: Dose Due + - entity: button.medmate_aspirin_take_morning_dose + name: Take Morning Dose + - entity: button.medmate_aspirin_fill_prescription + name: Fill Prescription +``` + +### Medicine Overview +```yaml +type: glance +title: All Medicines +entities: + - entity: sensor.medmate_aspirin_inventory + name: Aspirin + - entity: sensor.medmate_vitamin_d_inventory + name: Vitamin D + - entity: sensor.medmate_calcium_inventory + name: Calcium +show_name: true +show_state: true +``` + +## Data Storage + +MedMate stores all data locally in Home Assistant's storage directory: +- Location: `config/.storage/medmate_medicines` +- Format: JSON +- Includes: Medicine details, schedules, prescriptions, inventory, and dose history + +## Troubleshooting + +### Medicine Not Showing Up +1. Check that all files are in the correct directory +2. Restart Home Assistant completely +3. Check the logs for any error messages + +### Entities Not Updating +1. Check if the coordinator is running properly +2. Look for errors in **Settings > System > Logs** +3. Try reloading the integration + +### Config Flow Issues +1. Make sure medicine names are unique +2. Check that all required fields are filled +3. Verify dates are in the correct format + +## Advanced Usage + +### Custom Time Slots +While the integration includes preset times (morning, lunchtime, dinner, night), you can extend the `DEFAULT_TIMES` dictionary in `coordinator.py` to add custom times: + +```python +DEFAULT_TIMES = { + "morning": time(8, 0), + "lunchtime": time(12, 0), + "dinner": time(18, 0), + "night": time(22, 0), + "bedtime": time(23, 30), # Custom time +} +``` + +### Multiple Medicines +You can add multiple medicines by running the integration setup multiple times. Each medicine gets its own device and set of entities. + +### Data Export +Since data is stored in JSON format, you can easily backup or export your medicine data from the storage file. + +## Contributing + +This integration can be extended with additional features: +- Medication interaction checking +- Dose tracking analytics +- Integration with health apps +- Barcode scanning for medicine identification +- Advanced scheduling (every other day, etc.) + +## Support + +For issues, questions, or feature requests, please check: +1. Home Assistant logs for error messages +2. Ensure all files are properly installed +3. Verify your Home Assistant version compatibility + +--- + +**Note**: This integration is for tracking purposes only and should not replace professional medical advice. Always consult with healthcare providers for medical decisions. \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..c14628d --- /dev/null +++ b/__init__.py @@ -0,0 +1,49 @@ +"""MedMate - Medicine Tracker Integration for Home Assistant.""" +import asyncio +import logging +from datetime import datetime, timedelta + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, PLATFORMS +from .coordinator import MedMateDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.BUTTON] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up MedMate integration.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up MedMate from a config entry.""" + coordinator = MedMateDataUpdateCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +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) \ No newline at end of file diff --git a/binary_sensor.py b/binary_sensor.py new file mode 100644 index 0000000..62495a8 --- /dev/null +++ b/binary_sensor.py @@ -0,0 +1,190 @@ +"""Binary sensor platform for MedMate integration.""" +from __future__ import annotations + +import logging +from datetime import datetime, date +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, + BinarySensorDeviceClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + DOMAIN, + CONF_MEDICINE_NAME, + CONF_PRESCRIPTION, + CONF_EXPIRY_DATE, + CONF_REPEATS_LEFT, +) +from .coordinator import MedMateDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MedMate binary sensors.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + binary_sensors = [ + MedMateDoseDueSensor(coordinator, config_entry), + MedMatePrescriptionActiveSensor(coordinator, config_entry), + MedMateLowInventorySensor(coordinator, config_entry), + ] + + async_add_entities(binary_sensors) + + +class MedMateBaseBinarySensor(CoordinatorEntity, BinarySensorEntity): + """Base binary sensor for MedMate.""" + + def __init__( + self, + coordinator: MedMateDataUpdateCoordinator, + config_entry: ConfigEntry, + description: BinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator) + self.entity_description = description + self.config_entry = config_entry + self._medicine_id = config_entry.data.get("medicine_id") + + # Set unique_id + self._attr_unique_id = f"{self._medicine_id}_{description.key}" + + @property + def device_info(self) -> dict[str, Any]: + """Return device information.""" + medicine_name = self.coordinator.data.get(CONF_MEDICINE_NAME, "Unknown Medicine") + return { + "identifiers": {(DOMAIN, self._medicine_id)}, + "name": f"MedMate - {medicine_name}", + "manufacturer": "MedMate", + "model": "Medicine Tracker", + "sw_version": "1.0.0", + } + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.last_update_success and bool(self.coordinator.data) + + +class MedMateDoseDueSensor(MedMateBaseBinarySensor): + """Binary sensor for dose due status.""" + + def __init__( + self, + coordinator: MedMateDataUpdateCoordinator, + config_entry: ConfigEntry, + ) -> None: + """Initialize the dose due sensor.""" + description = BinarySensorEntityDescription( + key="dose_due", + name="Dose Due", + icon="mdi:alarm", + ) + super().__init__(coordinator, config_entry, description) + + @property + def is_on(self) -> bool: + """Return true if dose is due.""" + return self.coordinator.data.get("dose_due", False) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return additional state attributes.""" + data = self.coordinator.data + next_dose = data.get("next_dose") + + attrs = {} + if next_dose: + if isinstance(next_dose, str): + try: + next_dose = datetime.fromisoformat(next_dose) + except ValueError: + next_dose = None + attrs["next_dose"] = next_dose + + return attrs + + +class MedMatePrescriptionActiveSensor(MedMateBaseBinarySensor): + """Binary sensor for prescription active status.""" + + def __init__( + self, + coordinator: MedMateDataUpdateCoordinator, + config_entry: ConfigEntry, + ) -> None: + """Initialize the prescription active sensor.""" + description = BinarySensorEntityDescription( + key="prescription_active", + name="Prescription Active", + icon="mdi:file-document", + ) + super().__init__(coordinator, config_entry, description) + + @property + def is_on(self) -> bool: + """Return true if prescription is active.""" + return self.coordinator.data.get("prescription_active", False) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return additional state attributes.""" + data = self.coordinator.data + prescription = data.get(CONF_PRESCRIPTION, {}) + + return { + "expiry_date": prescription.get(CONF_EXPIRY_DATE), + "repeats_left": prescription.get(CONF_REPEATS_LEFT, 0), + } + + +class MedMateLowInventorySensor(MedMateBaseBinarySensor): + """Binary sensor for low inventory warning.""" + + def __init__( + self, + coordinator: MedMateDataUpdateCoordinator, + config_entry: ConfigEntry, + ) -> None: + """Initialize the low inventory sensor.""" + description = BinarySensorEntityDescription( + key="low_inventory", + name="Low Inventory", + icon="mdi:alert-circle", + device_class=BinarySensorDeviceClass.PROBLEM, + ) + super().__init__(coordinator, config_entry, description) + + @property + def is_on(self) -> bool: + """Return true if inventory is low.""" + data = self.coordinator.data + inventory = data.get("inventory", 0) + + # Consider inventory low if less than 7 doses remain + # This assumes daily medication - could be made configurable + return inventory < 7 + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return additional state attributes.""" + data = self.coordinator.data + + return { + "current_inventory": data.get("inventory", 0), + "low_threshold": 7, + } \ No newline at end of file diff --git a/button.py b/button.py new file mode 100644 index 0000000..e55ceba --- /dev/null +++ b/button.py @@ -0,0 +1,180 @@ +"""Button platform for MedMate integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + DOMAIN, + CONF_MEDICINE_NAME, + CONF_SCHEDULE, + CONF_TIMES, + TIME_SLOTS, +) +from .coordinator import MedMateDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MedMate buttons.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + buttons = [ + MedMateFillPrescriptionButton(coordinator, config_entry), + ] + + # Add take dose buttons for each scheduled time + schedule = coordinator.data.get(CONF_SCHEDULE, {}) + scheduled_times = schedule.get(CONF_TIMES, []) + + for time_slot in scheduled_times: + if time_slot in TIME_SLOTS: + buttons.append( + MedMateTakeDoseButton(coordinator, config_entry, time_slot) + ) + + async_add_entities(buttons) + + +class MedMateBaseButton(CoordinatorEntity, ButtonEntity): + """Base button for MedMate.""" + + def __init__( + self, + coordinator: MedMateDataUpdateCoordinator, + config_entry: ConfigEntry, + description: ButtonEntityDescription, + ) -> None: + """Initialize the button.""" + super().__init__(coordinator) + self.entity_description = description + self.config_entry = config_entry + self._medicine_id = config_entry.data.get("medicine_id") + + # Set unique_id + self._attr_unique_id = f"{self._medicine_id}_{description.key}" + + @property + def device_info(self) -> dict[str, Any]: + """Return device information.""" + medicine_name = self.coordinator.data.get(CONF_MEDICINE_NAME, "Unknown Medicine") + return { + "identifiers": {(DOMAIN, self._medicine_id)}, + "name": f"MedMate - {medicine_name}", + "manufacturer": "MedMate", + "model": "Medicine Tracker", + "sw_version": "1.0.0", + } + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.last_update_success and bool(self.coordinator.data) + + +class MedMateFillPrescriptionButton(MedMateBaseButton): + """Button to fill prescription.""" + + def __init__( + self, + coordinator: MedMateDataUpdateCoordinator, + config_entry: ConfigEntry, + ) -> None: + """Initialize the fill prescription button.""" + description = ButtonEntityDescription( + key="fill_prescription", + name="Fill Prescription", + icon="mdi:pill", + ) + super().__init__(coordinator, config_entry, description) + + async def async_press(self) -> None: + """Handle the button press.""" + success = await self.coordinator.async_fill_prescription() + + if success: + _LOGGER.info( + "Prescription filled for medicine: %s", + self.coordinator.data.get(CONF_MEDICINE_NAME) + ) + else: + _LOGGER.warning( + "Failed to fill prescription for medicine: %s", + self.coordinator.data.get(CONF_MEDICINE_NAME) + ) + + @property + def available(self) -> bool: + """Return if button is available.""" + if not super().available: + return False + + # Only available if prescription is active and has repeats left + return self.coordinator.data.get("prescription_active", False) + + +class MedMateTakeDoseButton(MedMateBaseButton): + """Button to take a dose.""" + + def __init__( + self, + coordinator: MedMateDataUpdateCoordinator, + config_entry: ConfigEntry, + time_slot: str, + ) -> None: + """Initialize the take dose button.""" + self.time_slot = time_slot + time_slot_name = TIME_SLOTS.get(time_slot, time_slot.title()) + + description = ButtonEntityDescription( + key=f"take_dose_{time_slot}", + name=f"Take {time_slot_name} Dose", + icon="mdi:hand-heart", + ) + super().__init__(coordinator, config_entry, description) + + async def async_press(self) -> None: + """Handle the button press.""" + success = await self.coordinator.async_take_dose(self.time_slot) + + if success: + _LOGGER.info( + "Dose taken for medicine: %s at %s", + self.coordinator.data.get(CONF_MEDICINE_NAME), + self.time_slot + ) + else: + _LOGGER.warning( + "Failed to record dose for medicine: %s at %s", + self.coordinator.data.get(CONF_MEDICINE_NAME), + self.time_slot + ) + + @property + def available(self) -> bool: + """Return if button is available.""" + if not super().available: + return False + + # Only available if there's inventory + inventory = self.coordinator.data.get("inventory", 0) + return inventory > 0 + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return additional state attributes.""" + return { + "time_slot": self.time_slot, + "current_inventory": self.coordinator.data.get("inventory", 0), + } \ No newline at end of file diff --git a/config_flow.py b/config_flow.py new file mode 100644 index 0000000..dfe4397 --- /dev/null +++ b/config_flow.py @@ -0,0 +1,345 @@ +"""Config flow for MedMate integration.""" +from __future__ import annotations + +import logging +from typing import Any +import voluptuous as vol +from datetime import datetime, date + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.storage import Store + +from .const import ( + DOMAIN, + CONF_MEDICINE_NAME, + CONF_ACTIVE_INGREDIENT, + CONF_STRENGTH, + CONF_PACK_SIZE, + CONF_SCHEDULE, + CONF_PRESCRIPTION, + CONF_DAYS, + CONF_TIMES, + CONF_ISSUE_DATE, + CONF_EXPIRY_DATE, + CONF_DOCTOR, + CONF_TOTAL_REPEATS, + CONF_REPEATS_LEFT, + TIME_SLOTS, + DAYS_OF_WEEK, + DEFAULT_PACK_SIZE, + DEFAULT_REPEATS, + STORAGE_KEY, + STORAGE_VERSION, +) + +_LOGGER = logging.getLogger(__name__) + + +class MedMateConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for MedMate.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._medicine_data = {} + self._schedule_data = {} + self._prescription_data = {} + self._editing_medicine_id = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is not None: + return await self.async_step_medicine_basic() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({}), + description_placeholders={ + "name": "MedMate - Medicine Tracker" + } + ) + + async def async_step_medicine_basic( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle basic medicine information.""" + errors = {} + + if user_input is not None: + # Validate medicine name is unique + store = Store(self.hass, STORAGE_VERSION, STORAGE_KEY) + existing_data = await store.async_load() or {} + medicines = existing_data.get("medicines", {}) + + medicine_name = user_input[CONF_MEDICINE_NAME].lower() + if medicine_name in medicines and self._editing_medicine_id != medicine_name: + errors[CONF_MEDICINE_NAME] = "name_exists" + else: + self._medicine_data = user_input + return await self.async_step_schedule() + + schema = vol.Schema({ + vol.Required(CONF_MEDICINE_NAME): str, + vol.Required(CONF_ACTIVE_INGREDIENT): str, + vol.Optional(CONF_STRENGTH, default=""): str, + vol.Optional(CONF_PACK_SIZE, default=DEFAULT_PACK_SIZE): cv.positive_int, + }) + + return self.async_show_form( + step_id="medicine_basic", + data_schema=schema, + errors=errors, + ) + + async def async_step_schedule( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle schedule configuration.""" + if user_input is not None: + self._schedule_data = { + CONF_DAYS: user_input.get(CONF_DAYS, []), + CONF_TIMES: user_input.get(CONF_TIMES, []) + } + return await self.async_step_prescription() + + schema = vol.Schema({ + vol.Required(CONF_DAYS, default=DAYS_OF_WEEK): cv.multi_select( + {day: day.title() for day in DAYS_OF_WEEK} + ), + vol.Required(CONF_TIMES, default=["morning"]): cv.multi_select(TIME_SLOTS), + }) + + return self.async_show_form( + step_id="schedule", + data_schema=schema, + ) + + async def async_step_prescription( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle prescription information.""" + if user_input is not None: + self._prescription_data = user_input + return await self.async_create_entry_data() + + today = date.today() + expiry_date = date(today.year + 1, today.month, today.day) + + schema = vol.Schema({ + vol.Optional(CONF_ISSUE_DATE, default=today): cv.date, + vol.Optional(CONF_EXPIRY_DATE, default=expiry_date): cv.date, + vol.Optional(CONF_DOCTOR, default=""): str, + vol.Optional(CONF_TOTAL_REPEATS, default=DEFAULT_REPEATS): cv.positive_int, + vol.Optional(CONF_REPEATS_LEFT): cv.positive_int, + }) + + return self.async_show_form( + step_id="prescription", + data_schema=schema, + ) + + async def async_create_entry_data(self) -> FlowResult: + """Create the config entry.""" + # Set repeats_left to total_repeats if not specified + if CONF_REPEATS_LEFT not in self._prescription_data: + self._prescription_data[CONF_REPEATS_LEFT] = self._prescription_data[CONF_TOTAL_REPEATS] + + medicine_data = { + **self._medicine_data, + CONF_SCHEDULE: self._schedule_data, + CONF_PRESCRIPTION: self._prescription_data, + "inventory": 0, + "last_taken": {}, + } + + # Store the medicine data + store = Store(self.hass, STORAGE_VERSION, STORAGE_KEY) + existing_data = await store.async_load() or {} + medicines = existing_data.get("medicines", {}) + + medicine_id = self._medicine_data[CONF_MEDICINE_NAME].lower() + medicines[medicine_id] = medicine_data + + await store.async_save({"medicines": medicines}) + + return self.async_create_entry( + title=f"MedMate - {self._medicine_data[CONF_MEDICINE_NAME]}", + data={"medicine_id": medicine_id}, + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> MedMateOptionsFlow: + """Get the options flow for this handler.""" + return MedMateOptionsFlow(config_entry) + + +class MedMateOptionsFlow(config_entries.OptionsFlow): + """Handle options flow for MedMate.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + self._medicine_data = {} + self._schedule_data = {} + self._prescription_data = {} + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + return await self.async_step_medicine_basic() + + async def async_step_medicine_basic( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle basic medicine information for options.""" + errors = {} + + # Load current medicine data + store = Store(self.hass, STORAGE_VERSION, STORAGE_KEY) + existing_data = await store.async_load() or {} + medicines = existing_data.get("medicines", {}) + medicine_id = self.config_entry.data.get("medicine_id") + current_medicine = medicines.get(medicine_id, {}) + + if user_input is not None: + self._medicine_data = user_input + return await self.async_step_schedule() + + schema = vol.Schema({ + vol.Required( + CONF_MEDICINE_NAME, + default=current_medicine.get(CONF_MEDICINE_NAME, "") + ): str, + vol.Required( + CONF_ACTIVE_INGREDIENT, + default=current_medicine.get(CONF_ACTIVE_INGREDIENT, "") + ): str, + vol.Optional( + CONF_STRENGTH, + default=current_medicine.get(CONF_STRENGTH, "") + ): str, + vol.Optional( + CONF_PACK_SIZE, + default=current_medicine.get(CONF_PACK_SIZE, DEFAULT_PACK_SIZE) + ): cv.positive_int, + }) + + return self.async_show_form( + step_id="medicine_basic", + data_schema=schema, + errors=errors, + ) + + async def async_step_schedule( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle schedule configuration for options.""" + # Load current medicine data + store = Store(self.hass, STORAGE_VERSION, STORAGE_KEY) + existing_data = await store.async_load() or {} + medicines = existing_data.get("medicines", {}) + medicine_id = self.config_entry.data.get("medicine_id") + current_medicine = medicines.get(medicine_id, {}) + current_schedule = current_medicine.get(CONF_SCHEDULE, {}) + + if user_input is not None: + self._schedule_data = { + CONF_DAYS: user_input.get(CONF_DAYS, []), + CONF_TIMES: user_input.get(CONF_TIMES, []) + } + return await self.async_step_prescription() + + schema = vol.Schema({ + vol.Required( + CONF_DAYS, + default=current_schedule.get(CONF_DAYS, DAYS_OF_WEEK) + ): cv.multi_select({day: day.title() for day in DAYS_OF_WEEK}), + vol.Required( + CONF_TIMES, + default=current_schedule.get(CONF_TIMES, ["morning"]) + ): cv.multi_select(TIME_SLOTS), + }) + + return self.async_show_form( + step_id="schedule", + data_schema=schema, + ) + + async def async_step_prescription( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle prescription information for options.""" + # Load current medicine data + store = Store(self.hass, STORAGE_VERSION, STORAGE_KEY) + existing_data = await store.async_load() or {} + medicines = existing_data.get("medicines", {}) + medicine_id = self.config_entry.data.get("medicine_id") + current_medicine = medicines.get(medicine_id, {}) + current_prescription = current_medicine.get(CONF_PRESCRIPTION, {}) + + if user_input is not None: + self._prescription_data = user_input + return await self.async_update_entry_data() + + today = date.today() + + schema = vol.Schema({ + vol.Optional( + CONF_ISSUE_DATE, + default=current_prescription.get(CONF_ISSUE_DATE, today) + ): cv.date, + vol.Optional( + CONF_EXPIRY_DATE, + default=current_prescription.get(CONF_EXPIRY_DATE, date(today.year + 1, today.month, today.day)) + ): cv.date, + vol.Optional( + CONF_DOCTOR, + default=current_prescription.get(CONF_DOCTOR, "") + ): str, + vol.Optional( + CONF_TOTAL_REPEATS, + default=current_prescription.get(CONF_TOTAL_REPEATS, DEFAULT_REPEATS) + ): cv.positive_int, + vol.Optional( + CONF_REPEATS_LEFT, + default=current_prescription.get(CONF_REPEATS_LEFT, DEFAULT_REPEATS) + ): cv.positive_int, + }) + + return self.async_show_form( + step_id="prescription", + data_schema=schema, + ) + + async def async_update_entry_data(self) -> FlowResult: + """Update the config entry.""" + # Load and update medicine data + store = Store(self.hass, STORAGE_VERSION, STORAGE_KEY) + existing_data = await store.async_load() or {} + medicines = existing_data.get("medicines", {}) + + medicine_id = self.config_entry.data.get("medicine_id") + current_medicine = medicines.get(medicine_id, {}) + + # Update medicine data + updated_medicine = { + **current_medicine, + **self._medicine_data, + CONF_SCHEDULE: self._schedule_data, + CONF_PRESCRIPTION: self._prescription_data, + } + + medicines[medicine_id] = updated_medicine + await store.async_save({"medicines": medicines}) + + return self.async_create_entry(title="", data={}) \ No newline at end of file diff --git a/const.py b/const.py new file mode 100644 index 0000000..18e100b --- /dev/null +++ b/const.py @@ -0,0 +1,70 @@ +"""Constants for the MedMate integration.""" +from homeassistant.const import Platform + +DOMAIN = "medmate" +PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.BUTTON] + +# Configuration keys +CONF_MEDICINES = "medicines" +CONF_MEDICINE_NAME = "medicine_name" +CONF_ACTIVE_INGREDIENT = "active_ingredient" +CONF_STRENGTH = "strength" +CONF_PACK_SIZE = "pack_size" +CONF_SCHEDULE = "schedule" +CONF_PRESCRIPTION = "prescription" + +# Schedule keys +CONF_DAYS = "days" +CONF_TIMES = "times" + +# Prescription keys +CONF_ISSUE_DATE = "issue_date" +CONF_EXPIRY_DATE = "expiry_date" +CONF_DOCTOR = "doctor" +CONF_TOTAL_REPEATS = "total_repeats" +CONF_REPEATS_LEFT = "repeats_left" + +# Time slots +TIME_SLOTS = { + "morning": "Morning", + "lunchtime": "Lunchtime", + "dinner": "Dinner", + "night": "Night", + "custom": "Custom" +} + +# Days of week +DAYS_OF_WEEK = [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday" +] + +# Storage keys +STORAGE_KEY = "medmate_medicines" +STORAGE_VERSION = 1 + +# Default values +DEFAULT_PACK_SIZE = 30 +DEFAULT_REPEATS = 5 +DEFAULT_INVENTORY = 0 + +# Attributes +ATTR_ACTIVE_INGREDIENT = "active_ingredient" +ATTR_STRENGTH = "strength" +ATTR_PACK_SIZE = "pack_size" +ATTR_INVENTORY = "inventory" +ATTR_SCHEDULE = "schedule" +ATTR_PRESCRIPTION = "prescription" +ATTR_ISSUE_DATE = "issue_date" +ATTR_EXPIRY_DATE = "expiry_date" +ATTR_DOCTOR = "doctor" +ATTR_TOTAL_REPEATS = "total_repeats" +ATTR_REPEATS_LEFT = "repeats_left" +ATTR_PRESCRIPTION_ACTIVE = "prescription_active" +ATTR_NEXT_DOSE = "next_dose" +ATTR_LAST_TAKEN = "last_taken" \ No newline at end of file diff --git a/coordinator.py b/coordinator.py new file mode 100644 index 0000000..dbf49c2 --- /dev/null +++ b/coordinator.py @@ -0,0 +1,260 @@ +"""DataUpdateCoordinator for MedMate integration.""" +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, time, date +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + DOMAIN, + STORAGE_KEY, + STORAGE_VERSION, + CONF_SCHEDULE, + CONF_PRESCRIPTION, + CONF_DAYS, + CONF_TIMES, + CONF_EXPIRY_DATE, + CONF_REPEATS_LEFT, + TIME_SLOTS, +) + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = timedelta(minutes=5) + +# Default times for time slots +DEFAULT_TIMES = { + "morning": time(8, 0), + "lunchtime": time(12, 0), + "dinner": time(18, 0), + "night": time(22, 0), +} + + +class MedMateDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching MedMate data.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) + self.entry = entry + self.store = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self.medicine_id = entry.data.get("medicine_id") + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from storage.""" + data = await self.store.async_load() or {} + medicines = data.get("medicines", {}) + + if self.medicine_id not in medicines: + _LOGGER.warning("Medicine %s not found in storage", self.medicine_id) + return {} + + medicine_data = medicines[self.medicine_id] + + # Calculate derived values + medicine_data = self._calculate_derived_data(medicine_data) + + return medicine_data + + def _calculate_derived_data(self, medicine_data: dict[str, Any]) -> dict[str, Any]: + """Calculate derived data for the medicine.""" + now = datetime.now() + today = now.date() + + # Check if prescription is still active + prescription = medicine_data.get(CONF_PRESCRIPTION, {}) + expiry_date = prescription.get(CONF_EXPIRY_DATE) + repeats_left = prescription.get(CONF_REPEATS_LEFT, 0) + + prescription_active = False + if expiry_date and repeats_left > 0: + if isinstance(expiry_date, str): + expiry_date = datetime.fromisoformat(expiry_date).date() + prescription_active = today <= expiry_date + + medicine_data["prescription_active"] = prescription_active + + # Calculate next dose time + next_dose = self._calculate_next_dose(medicine_data, now) + medicine_data["next_dose"] = next_dose + + # Check if due for dose + medicine_data["dose_due"] = self._is_dose_due(medicine_data, now) + + return medicine_data + + def _calculate_next_dose(self, medicine_data: dict[str, Any], now: datetime) -> datetime | None: + """Calculate the next dose time.""" + schedule = medicine_data.get(CONF_SCHEDULE, {}) + days = schedule.get(CONF_DAYS, []) + times = schedule.get(CONF_TIMES, []) + + if not days or not times: + return None + + # Get current day of week (0=Monday, 6=Sunday) + current_weekday = now.weekday() + current_date = now.date() + current_time = now.time() + + # Convert day names to weekday numbers + weekday_map = { + "monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3, + "friday": 4, "saturday": 5, "sunday": 6 + } + + scheduled_weekdays = [weekday_map[day] for day in days if day in weekday_map] + + if not scheduled_weekdays: + return None + + # Get dose times for today and future + dose_times = [] + for time_slot in times: + if time_slot in DEFAULT_TIMES: + dose_times.append(DEFAULT_TIMES[time_slot]) + + if not dose_times: + return None + + dose_times.sort() + + # Check for remaining doses today + if current_weekday in scheduled_weekdays: + for dose_time in dose_times: + if dose_time > current_time: + return datetime.combine(current_date, dose_time) + + # Find next scheduled day + days_ahead = 1 + while days_ahead <= 7: + next_date = current_date + timedelta(days=days_ahead) + next_weekday = next_date.weekday() + + if next_weekday in scheduled_weekdays: + return datetime.combine(next_date, dose_times[0]) + + days_ahead += 1 + + return None + + def _is_dose_due(self, medicine_data: dict[str, Any], now: datetime) -> bool: + """Check if a dose is currently due.""" + schedule = medicine_data.get(CONF_SCHEDULE, {}) + days = schedule.get(CONF_DAYS, []) + times = schedule.get(CONF_TIMES, []) + last_taken = medicine_data.get("last_taken", {}) + + if not days or not times: + return False + + current_weekday = now.strftime("%A").lower() + current_time = now.time() + current_date = now.date().isoformat() + + if current_weekday not in days: + return False + + # Check each scheduled time slot + for time_slot in times: + if time_slot not in DEFAULT_TIMES: + continue + + slot_time = DEFAULT_TIMES[time_slot] + + # Check if we're within 30 minutes of the dose time + dose_datetime = datetime.combine(now.date(), slot_time) + time_diff = abs((now - dose_datetime).total_seconds() / 60) # minutes + + if time_diff <= 30: # Within 30 minutes + # Check if already taken today for this time slot + dose_key = f"{current_date}_{time_slot}" + if dose_key not in last_taken: + return True + + return False + + async def async_take_dose(self, time_slot: str) -> bool: + """Record that a dose was taken.""" + try: + data = await self.store.async_load() or {} + medicines = data.get("medicines", {}) + + if self.medicine_id not in medicines: + return False + + medicine_data = medicines[self.medicine_id] + last_taken = medicine_data.get("last_taken", {}) + + # Record the dose + now = datetime.now() + dose_key = f"{now.date().isoformat()}_{time_slot}" + last_taken[dose_key] = now.isoformat() + + # Reduce inventory + current_inventory = medicine_data.get("inventory", 0) + if current_inventory > 0: + medicine_data["inventory"] = current_inventory - 1 + + medicine_data["last_taken"] = last_taken + medicines[self.medicine_id] = medicine_data + + await self.store.async_save({"medicines": medicines}) + await self.async_request_refresh() + + return True + + except Exception as err: + _LOGGER.error("Error recording dose: %s", err) + return False + + async def async_fill_prescription(self) -> bool: + """Fill the prescription (add inventory, reduce repeats).""" + try: + data = await self.store.async_load() or {} + medicines = data.get("medicines", {}) + + if self.medicine_id not in medicines: + return False + + medicine_data = medicines[self.medicine_id] + prescription = medicine_data.get(CONF_PRESCRIPTION, {}) + + # Check if prescription is still active + if not medicine_data.get("prescription_active", False): + return False + + # Reduce repeats left + repeats_left = prescription.get(CONF_REPEATS_LEFT, 0) + if repeats_left <= 0: + return False + + prescription[CONF_REPEATS_LEFT] = repeats_left - 1 + + # Add to inventory + pack_size = medicine_data.get("pack_size", 30) + current_inventory = medicine_data.get("inventory", 0) + medicine_data["inventory"] = current_inventory + pack_size + + medicine_data[CONF_PRESCRIPTION] = prescription + medicines[self.medicine_id] = medicine_data + + await self.store.async_save({"medicines": medicines}) + await self.async_request_refresh() + + return True + + except Exception as err: + _LOGGER.error("Error filling prescription: %s", err) + return False \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..ebf476b --- /dev/null +++ b/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "medmate", + "name": "MedMate - Medicine Tracker", + "codeowners": ["@DianeAdmin"], + "config_flow": true, + "documentation": "https://gitea.mathesd.synology.me/DianeAdmin/MedMate-Medicine-Tracker", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": [], + "version": "1.0.0", + "dependencies": [] +} \ No newline at end of file diff --git a/sensor.py b/sensor.py new file mode 100644 index 0000000..a680a6a --- /dev/null +++ b/sensor.py @@ -0,0 +1,244 @@ +"""Sensor platform for MedMate integration.""" +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Any + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, + SensorDeviceClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + DOMAIN, + CONF_MEDICINE_NAME, + CONF_ACTIVE_INGREDIENT, + CONF_STRENGTH, + CONF_PACK_SIZE, + CONF_SCHEDULE, + CONF_PRESCRIPTION, + CONF_ISSUE_DATE, + CONF_EXPIRY_DATE, + CONF_DOCTOR, + CONF_TOTAL_REPEATS, + CONF_REPEATS_LEFT, + ATTR_ACTIVE_INGREDIENT, + ATTR_STRENGTH, + ATTR_PACK_SIZE, + ATTR_INVENTORY, + ATTR_SCHEDULE, + ATTR_PRESCRIPTION, + ATTR_ISSUE_DATE, + ATTR_EXPIRY_DATE, + ATTR_DOCTOR, + ATTR_TOTAL_REPEATS, + ATTR_REPEATS_LEFT, + ATTR_PRESCRIPTION_ACTIVE, + ATTR_NEXT_DOSE, + ATTR_LAST_TAKEN, +) +from .coordinator import MedMateDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MedMate sensors.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + sensors = [ + MedMateInventorySensor(coordinator, config_entry), + MedMateRepeatsSensor(coordinator, config_entry), + MedMateNextDoseSensor(coordinator, config_entry), + ] + + async_add_entities(sensors) + + +class MedMateBaseSensor(CoordinatorEntity, SensorEntity): + """Base sensor for MedMate.""" + + def __init__( + self, + coordinator: MedMateDataUpdateCoordinator, + config_entry: ConfigEntry, + description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self.config_entry = config_entry + self._medicine_id = config_entry.data.get("medicine_id") + + # Set unique_id and entity_id + self._attr_unique_id = f"{self._medicine_id}_{description.key}" + + @property + def device_info(self) -> dict[str, Any]: + """Return device information.""" + medicine_name = self.coordinator.data.get(CONF_MEDICINE_NAME, "Unknown Medicine") + return { + "identifiers": {(DOMAIN, self._medicine_id)}, + "name": f"MedMate - {medicine_name}", + "manufacturer": "MedMate", + "model": "Medicine Tracker", + "sw_version": "1.0.0", + } + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.last_update_success and bool(self.coordinator.data) + + +class MedMateInventorySensor(MedMateBaseSensor): + """Sensor for medicine inventory.""" + + def __init__( + self, + coordinator: MedMateDataUpdateCoordinator, + config_entry: ConfigEntry, + ) -> None: + """Initialize the inventory sensor.""" + description = SensorEntityDescription( + key="inventory", + name="Inventory", + icon="mdi:pill", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="pills", + ) + super().__init__(coordinator, config_entry, description) + + @property + def native_value(self) -> int: + """Return the inventory count.""" + return self.coordinator.data.get("inventory", 0) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return additional state attributes.""" + data = self.coordinator.data + schedule = data.get(CONF_SCHEDULE, {}) + prescription = data.get(CONF_PRESCRIPTION, {}) + + attrs = { + ATTR_ACTIVE_INGREDIENT: data.get(CONF_ACTIVE_INGREDIENT), + ATTR_STRENGTH: data.get(CONF_STRENGTH), + ATTR_PACK_SIZE: data.get(CONF_PACK_SIZE), + ATTR_SCHEDULE: schedule, + ATTR_PRESCRIPTION_ACTIVE: data.get("prescription_active", False), + } + + # Add prescription details if available + if prescription: + attrs.update({ + ATTR_ISSUE_DATE: prescription.get(CONF_ISSUE_DATE), + ATTR_EXPIRY_DATE: prescription.get(CONF_EXPIRY_DATE), + ATTR_DOCTOR: prescription.get(CONF_DOCTOR), + ATTR_TOTAL_REPEATS: prescription.get(CONF_TOTAL_REPEATS), + ATTR_REPEATS_LEFT: prescription.get(CONF_REPEATS_LEFT), + }) + + return attrs + + +class MedMateRepeatsSensor(MedMateBaseSensor): + """Sensor for prescription repeats left.""" + + def __init__( + self, + coordinator: MedMateDataUpdateCoordinator, + config_entry: ConfigEntry, + ) -> None: + """Initialize the repeats sensor.""" + description = SensorEntityDescription( + key="repeats_left", + name="Repeats Left", + icon="mdi:repeat", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="repeats", + ) + super().__init__(coordinator, config_entry, description) + + @property + def native_value(self) -> int: + """Return the number of repeats left.""" + prescription = self.coordinator.data.get(CONF_PRESCRIPTION, {}) + return prescription.get(CONF_REPEATS_LEFT, 0) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return additional state attributes.""" + data = self.coordinator.data + prescription = data.get(CONF_PRESCRIPTION, {}) + + return { + ATTR_TOTAL_REPEATS: prescription.get(CONF_TOTAL_REPEATS, 0), + ATTR_PRESCRIPTION_ACTIVE: data.get("prescription_active", False), + ATTR_ISSUE_DATE: prescription.get(CONF_ISSUE_DATE), + ATTR_EXPIRY_DATE: prescription.get(CONF_EXPIRY_DATE), + ATTR_DOCTOR: prescription.get(CONF_DOCTOR), + } + + +class MedMateNextDoseSensor(MedMateBaseSensor): + """Sensor for next dose time.""" + + def __init__( + self, + coordinator: MedMateDataUpdateCoordinator, + config_entry: ConfigEntry, + ) -> None: + """Initialize the next dose sensor.""" + description = SensorEntityDescription( + key="next_dose", + name="Next Dose", + icon="mdi:clock-outline", + device_class=SensorDeviceClass.TIMESTAMP, + ) + super().__init__(coordinator, config_entry, description) + + @property + def native_value(self) -> datetime | None: + """Return the next dose time.""" + next_dose = self.coordinator.data.get("next_dose") + if next_dose and isinstance(next_dose, str): + try: + return datetime.fromisoformat(next_dose) + except ValueError: + return None + return next_dose + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return additional state attributes.""" + data = self.coordinator.data + schedule = data.get(CONF_SCHEDULE, {}) + last_taken = data.get("last_taken", {}) + + # Get most recent dose taken + most_recent_dose = None + if last_taken: + recent_doses = sorted(last_taken.values(), reverse=True) + if recent_doses: + try: + most_recent_dose = datetime.fromisoformat(recent_doses[0]) + except (ValueError, TypeError): + most_recent_dose = None + + return { + ATTR_SCHEDULE: schedule, + ATTR_LAST_TAKEN: most_recent_dose, + "dose_due": data.get("dose_due", False), + } \ No newline at end of file diff --git a/services.yaml b/services.yaml new file mode 100644 index 0000000..1a7bef4 --- /dev/null +++ b/services.yaml @@ -0,0 +1,64 @@ +take_dose: + name: Take Dose + description: Record that a dose of medicine was taken + target: + entity: + domain: button + integration: medmate + fields: + time_slot: + name: Time Slot + description: The time slot for this dose (morning, lunchtime, dinner, night, custom) + required: false + selector: + select: + options: + - "morning" + - "lunchtime" + - "dinner" + - "night" + - "custom" + +fill_prescription: + name: Fill Prescription + description: Fill a prescription (add inventory and reduce repeats) + target: + entity: + domain: button + integration: medmate + +update_inventory: + name: Update Inventory + description: Manually update medicine inventory + target: + entity: + domain: sensor + integration: medmate + fields: + inventory: + name: Inventory Count + description: New inventory count + required: true + selector: + number: + min: 0 + max: 1000 + mode: box + +update_repeats: + name: Update Repeats + description: Manually update prescription repeats left + target: + entity: + domain: sensor + integration: medmate + fields: + repeats_left: + name: Repeats Left + description: Number of repeats remaining + required: true + selector: + number: + min: 0 + max: 20 + mode: box \ No newline at end of file diff --git a/strings.json b/strings.json new file mode 100644 index 0000000..6bf7536 --- /dev/null +++ b/strings.json @@ -0,0 +1,133 @@ +{ + "config": { + "step": { + "user": { + "title": "MedMate - Medicine Tracker", + "description": "Set up a new medicine to track with MedMate.", + "data": {} + }, + "medicine_basic": { + "title": "Medicine Information", + "description": "Enter the basic information about your medicine.", + "data": { + "medicine_name": "Medicine Name", + "active_ingredient": "Active Ingredient", + "strength": "Strength (optional)", + "pack_size": "Pack Size (pills per pack)" + } + }, + "schedule": { + "title": "Dosing Schedule", + "description": "Configure when you take this medicine.", + "data": { + "days": "Days of the Week", + "times": "Times of Day" + } + }, + "prescription": { + "title": "Prescription Details", + "description": "Enter prescription information to track repeats and expiry.", + "data": { + "issue_date": "Issue Date", + "expiry_date": "Expiry Date", + "doctor": "Doctor (optional)", + "total_repeats": "Total Repeats", + "repeats_left": "Repeats Left (optional)" + } + } + }, + "error": { + "name_exists": "A medicine with this name already exists" + }, + "abort": { + "already_configured": "Medicine is already configured" + } + }, + "options": { + "step": { + "medicine_basic": { + "title": "Update Medicine Information", + "description": "Update the basic information about your medicine.", + "data": { + "medicine_name": "Medicine Name", + "active_ingredient": "Active Ingredient", + "strength": "Strength (optional)", + "pack_size": "Pack Size (pills per pack)" + } + }, + "schedule": { + "title": "Update Dosing Schedule", + "description": "Update when you take this medicine.", + "data": { + "days": "Days of the Week", + "times": "Times of Day" + } + }, + "prescription": { + "title": "Update Prescription Details", + "description": "Update prescription information.", + "data": { + "issue_date": "Issue Date", + "expiry_date": "Expiry Date", + "doctor": "Doctor (optional)", + "total_repeats": "Total Repeats", + "repeats_left": "Repeats Left" + } + } + } + }, + "entity": { + "sensor": { + "inventory": { + "name": "Inventory", + "state": { + "0": "Out of stock", + "1": "1 pill left", + "default": "{count} pills" + } + }, + "repeats_left": { + "name": "Repeats Left", + "state": { + "0": "No repeats left", + "1": "1 repeat left", + "default": "{count} repeats left" + } + }, + "next_dose": { + "name": "Next Dose" + } + }, + "binary_sensor": { + "dose_due": { + "name": "Dose Due" + }, + "prescription_active": { + "name": "Prescription Active" + }, + "low_inventory": { + "name": "Low Inventory" + } + }, + "button": { + "fill_prescription": { + "name": "Fill Prescription" + }, + "take_dose_morning": { + "name": "Take Morning Dose" + }, + "take_dose_lunchtime": { + "name": "Take Lunchtime Dose" + }, + "take_dose_dinner": { + "name": "Take Dinner Dose" + }, + "take_dose_night": { + "name": "Take Night Dose" + }, + "take_dose_custom": { + "name": "Take Custom Dose" + } + } + } +} \ No newline at end of file