diff --git a/README.md b/README.md index d708b15..adc13b5 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ This integration integrates Spoolman (https://github.com/Donkie/Spoolman/) into - Enable/disabled archived spools - Archived spools are grouped into one `Archived` device - If a Klipper url is configured, the active spool will have an attribute `klipper_active_spool` +- Creation of a service `spoolman.patch_spool` to enable you to change values of a spool from automations > [!NOTE] > If one of the threshold is exceeded the integration fires an event. The event is named `spoolman_spool_threshold_exceeded`. Currently there are three thresholds defined: `info`, `warning` and `critical`. @@ -194,6 +195,30 @@ A spool has this structure (according to the [OpenAPI description](https://donki "archived": false } ``` + +# Home Assistant services +This integration creates services to be used in automations: + +## `spoolman.patch_spool` +This service is used to change values and properties if a spool. The `data` must match the data for the [Spoolman API](https://donkie.github.io/Spoolman/#tag/spool/operation/Update_spool_spool__spool_id__patch) + +> [!IMPORTANT] +> You can't update `remaining_weight` and `used_weight` in one update. You can only set one of them. Spoolmann calculates the missing field by itself. + +```yaml +service: spoolman.patch_spool +data: + id: 45 + first_used: "2019-08-24T14:15:22.000Z" + last_used: "2019-08-24T14:15:22.000Z" + price: 20 + initial_weight: 200 + spool_weight: 200 + location: Shelf B + remaining_weight: 200 + lot_nr: 52342 +``` + # Contributing If you're developer and want to contribute to the project, please feel free to do a PR! But there are some contraints I want to enforce by convention (currently I evaluate the possibility to enforce this by rules. If you have a good hint, please let me know 🎉): diff --git a/custom_components/spoolman/__init__.py b/custom_components/spoolman/__init__.py index a25fcf7..f3ca992 100644 --- a/custom_components/spoolman/__init__.py +++ b/custom_components/spoolman/__init__.py @@ -1,26 +1,19 @@ """Spoolman home assistant integration.""" import logging -import homeassistant.helpers.config_validation as cv -import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError -from .const import CONF_URL, DEFAULT_NAME, DOMAIN, SPOOLMAN_API_WRAPPER +from custom_components.spoolman.schema_helper import SchemaHelper + +from .const import DOMAIN, SPOOLMAN_API_WRAPPER, SPOOLMAN_PATCH_SPOOL_SERVICENAME from .coordinator import SpoolManCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, # type: ignore - vol.Required(CONF_URL): cv.string, - } -) - async def async_setup_platform( hass: HomeAssistant, config, add_devices, discovery_info=None @@ -42,6 +35,21 @@ async def async_setup_entry(hass: HomeAssistant, entry): coordinator = SpoolManCoordinator(hass, entry) await coordinator.async_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + async def handle_spoolman_patch_spool(call): + spool_id = call.data.get('id') + data = {key: call.data[key] for key in call.data if key != 'id'} + _LOGGER.info(f"Patch spool called with id: {spool_id} and data: {data}") + + try: + await coordinator.spoolman_api.patch_spool(spool_id, data) + except Exception as e: + _LOGGER.error(f"Failed to patch spool: {e}") + raise HomeAssistantError(f"Failed to patch spool: {e}") + + + hass.services.async_register(DOMAIN, SPOOLMAN_PATCH_SPOOL_SERVICENAME, handle_spoolman_patch_spool, schema=SchemaHelper.get_spoolman_patch_spool_schema()) + return True diff --git a/custom_components/spoolman/classes/klipper_api.py b/custom_components/spoolman/classes/klipper_api.py index 72167a9..dd3f027 100644 --- a/custom_components/spoolman/classes/klipper_api.py +++ b/custom_components/spoolman/classes/klipper_api.py @@ -13,28 +13,28 @@ def __init__(self, base_url): """Initialize the Klipper API.""" self.base_url = add_trailing_slash(base_url) - async def get_active_spool_id(self) -> int | None: - """Get active spool from Klipper API.""" + async def _get_json(self, endpoint: str) -> dict: + """Convert the response to JSON.""" async with aiohttp.ClientSession() as session: - url = f"{self.base_url}server/spoolman/spool_id" + url = f"{self.base_url}server/{endpoint}" async with session.get(url) as response: - if response.status == 200: - data = await response.json() - # Extract the spool_id from the data dictionary - spool_id = data.get('result', {}).get("spool_id") + response.raise_for_status() + return await response.json() + + async def get_active_spool_id(self) -> int | None: + """Get active spool from Klipper API.""" + data = await self._get_json("spoolman/spool_id") + spool_id = data.get('result', {}).get("spool_id") + + if spool_id is not None: + try: + return int(spool_id) + except ValueError: + raise ValueError(f"Invalid spool_id: {spool_id}") - # Convert spool_id to an integer if it is not None; otherwise, return None - return int(spool_id) if spool_id is not None else None + return None - else: - return 0 async def api_version(self) -> str | None: """Get api version from Klipper API.""" - async with aiohttp.ClientSession() as session: - url = f"{self.base_url}server/info" - async with session.get(url) as response: - if response.status == 200: - data = await response.json() - return data.get('result').get("api_version_string") - else: - return None + data = await self._get_json("info") + return data.get('result', {}).get("api_version_string") diff --git a/custom_components/spoolman/classes/spoolman_api.py b/custom_components/spoolman/classes/spoolman_api.py index 19f11a2..59217d1 100644 --- a/custom_components/spoolman/classes/spoolman_api.py +++ b/custom_components/spoolman/classes/spoolman_api.py @@ -3,7 +3,6 @@ import logging _LOGGER = logging.getLogger(__name__) - class SpoolmanAPI: """Class for interacting with the Spoolman API.""" @@ -40,22 +39,20 @@ async def backup(self): url = f"{self.base_url}/backup" async with aiohttp.ClientSession() as session, session.post(url) as response: response.raise_for_status() - response = await response.json() _LOGGER.debug("SpoolmanAPI: backup response %s", response) return response async def get_spools(self, params): """Return a list of all spools.""" - _LOGGER.debug("SpoolmanAPI: get_spool") + _LOGGER.debug("SpoolmanAPI: get_spools") url = f"{self.base_url}/spool" if len(params) > 0: url = f"{url}?{self.string_from_dictionary(params)}" async with aiohttp.ClientSession() as session, session.get(url) as response: response.raise_for_status() - response = await response.json() - _LOGGER.debug("SpoolmanAPI: get_spool response %s", response) + _LOGGER.debug("SpoolmanAPI: get_spools response %s", response) return response async def get_spool_by_id(self, spool_id): @@ -69,7 +66,7 @@ async def get_spool_by_id(self, spool_id): return response def string_from_dictionary(self, params_dict): - """Initialize an empty string to hold the result.""" + """Generate a query string from a dictionary of parameters.""" _LOGGER.debug("SpoolmanAPI: string_from_dictionary") result_string = "" @@ -87,3 +84,31 @@ def string_from_dictionary(self, params_dict): # Return the result string return result_string + + async def patch_spool(self, spool_id, data): + """Update the spool with the specified ID.""" + _LOGGER.info(f"SpoolmanAPI: patch_spool {spool_id} with data {data}") + + if "remaining_weight" in data and "used_weight" in data: + if data["remaining_weight"] > 0 and data["used_weight"] > 0: + raise ValueError("remaining_weight and used_weight cannot be used together. Please use only one of them.") + + url = f"{self.base_url}/spool/{spool_id}" + try: + async with aiohttp.ClientSession() as session, session.patch(url, json=data) as response: + response.raise_for_status() + response_data = await response.json() + _LOGGER.debug("SpoolmanAPI: patch_spool response %s", response_data) + return response_data + except aiohttp.ClientResponseError as e: + _LOGGER.error(f"HTTP error occurred: {e.status} {e.message}") + raise + except aiohttp.ClientConnectionError as e: + _LOGGER.error(f"Connection error occurred: {e}") + raise + except aiohttp.ClientError as e: + _LOGGER.error(f"Client error occurred: {e}") + raise + except Exception as e: + _LOGGER.error(f"An unexpected error occurred: {e}") + raise diff --git a/custom_components/spoolman/const.py b/custom_components/spoolman/const.py index 96f88a7..6b40ab3 100644 --- a/custom_components/spoolman/const.py +++ b/custom_components/spoolman/const.py @@ -21,3 +21,5 @@ KLIPPER_URL = "klipper_url" KLIPPER_URL_DESC = "klipper_url_desc" + +SPOOLMAN_PATCH_SPOOL_SERVICENAME = "patch_spool" diff --git a/custom_components/spoolman/manifest.json b/custom_components/spoolman/manifest.json index c398eab..0fdd5cd 100644 --- a/custom_components/spoolman/manifest.json +++ b/custom_components/spoolman/manifest.json @@ -2,7 +2,7 @@ "domain": "spoolman", "name": "Spoolman", "codeowners": [ - "@mfranke87" + "@disane87" ], "config_flow": true, "dependencies": [], diff --git a/custom_components/spoolman/schema_helper.py b/custom_components/spoolman/schema_helper.py index 6c31507..3d8a823 100644 --- a/custom_components/spoolman/schema_helper.py +++ b/custom_components/spoolman/schema_helper.py @@ -1,6 +1,7 @@ """Schema helper.""" from typing import Any import voluptuous as vol +import homeassistant.helpers.config_validation as cv from .const import ( CONF_NOTIFICATION_THRESHOLD_CRITICAL, CONF_NOTIFICATION_THRESHOLD_INFO, @@ -17,6 +18,26 @@ class SchemaHelper: """Schema helper contains the config and options schema.""" + @staticmethod + def get_spoolman_patch_spool_schema(): + """Get the schema for the spoolman_patch_spool service.""" + return vol.Schema({ + vol.Required('id'): cv.string, + vol.Optional('first_used'): cv.string, + vol.Optional('last_used'): cv.string, + vol.Optional('filament_id'): cv.positive_int, + vol.Optional('price'): vol.Coerce(float), + vol.Optional('initial_weight'): vol.Coerce(float), + vol.Optional('spool_weight'): vol.Coerce(float), + vol.Optional('remaining_weight'): vol.Coerce(float), + vol.Optional('used_weight'): vol.Coerce(float), + vol.Optional('location'): cv.string, + vol.Optional('lot_nr'): cv.string, + vol.Optional('comment'): cv.string, + vol.Optional('archived'): cv.boolean, + vol.Optional('extra'): vol.Schema({}, extra=vol.ALLOW_EXTRA), + }) + @staticmethod def get_config_schema(get_values=False, config_data=None): """Get the form for config and options flows.""" diff --git a/custom_components/spoolman/sensor.py b/custom_components/spoolman/sensor.py index 45c2ca7..79db74d 100644 --- a/custom_components/spoolman/sensor.py +++ b/custom_components/spoolman/sensor.py @@ -110,8 +110,7 @@ def __init__( if vendor_name is None: spool_name = f"{self._filament['name']} {self._filament.get('material')}" else: - spool_name = f"{vendor_name} {self._filament['name']} { - self._filament.get('material')}" + spool_name = f"{vendor_name} {self._filament['name']} { self._filament.get('material')}" location_name = ( self._spool.get("location", "Unknown") @@ -135,8 +134,7 @@ def __init__( model="Spoolman", configuration_url=conf_url, suggested_area=location_name, - sw_version=f"{spoolman_info.get('version', 'unknown')} ({ - spoolman_info.get('git_commit', 'unknown')})", + sw_version=f"{spoolman_info.get('version', 'unknown')} ({ spoolman_info.get('git_commit', 'unknown')})", ) self.idx = idx diff --git a/custom_components/spoolman/services.yaml b/custom_components/spoolman/services.yaml new file mode 100644 index 0000000..485e97d --- /dev/null +++ b/custom_components/spoolman/services.yaml @@ -0,0 +1,48 @@ +patch_spool: + description: "A custom service to patch spool data." + fields: + id: + description: "The ID of the spool." + example: "spool_123" + required: true + first_used: + description: "The first time the spool was used." + example: "2019-08-24T14:15:22Z" + last_used: + description: "The last time the spool was used." + example: "2019-08-24T14:15:22Z" + filament_id: + description: "The ID of the filament used in the spool." + example: 0 + price: + description: "The price of the spool." + example: 20.0 + initial_weight: + description: "The initial weight of the spool including filament." + example: 200.0 + spool_weight: + description: "The weight of the empty spool." + example: 200.0 + remaining_weight: + description: "The remaining weight of the filament on the spool." + example: 800.0 + used_weight: + description: "The weight of the filament used from the spool." + example: 200.0 + location: + description: "The storage location of the spool." + example: "Shelf A" + lot_nr: + description: "The lot number of the filament." + example: "52342" + comment: + description: "Any comments about the spool." + example: "" + archived: + description: "Whether the spool is archived." + example: false + extra: + description: "Any extra properties for the spool." + example: + property1: "string" + property2: "string"