Skip to content

Commit

Permalink
feat: service integration to change a spool in Spoolman via API
Browse files Browse the repository at this point in the history
closed #119
  • Loading branch information
Disane87 committed Jul 1, 2024
1 parent 9363b6a commit 881a76b
Show file tree
Hide file tree
Showing 9 changed files with 169 additions and 42 deletions.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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 🎉):
Expand Down
32 changes: 20 additions & 12 deletions custom_components/spoolman/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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


Expand Down
38 changes: 19 additions & 19 deletions custom_components/spoolman/classes/klipper_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
37 changes: 31 additions & 6 deletions custom_components/spoolman/classes/spoolman_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import logging
_LOGGER = logging.getLogger(__name__)


class SpoolmanAPI:
"""Class for interacting with the Spoolman API."""

Expand Down Expand Up @@ -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):
Expand All @@ -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 = ""

Expand All @@ -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
2 changes: 2 additions & 0 deletions custom_components/spoolman/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@

KLIPPER_URL = "klipper_url"
KLIPPER_URL_DESC = "klipper_url_desc"

SPOOLMAN_PATCH_SPOOL_SERVICENAME = "patch_spool"
2 changes: 1 addition & 1 deletion custom_components/spoolman/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"domain": "spoolman",
"name": "Spoolman",
"codeowners": [
"@mfranke87"
"@disane87"
],
"config_flow": true,
"dependencies": [],
Expand Down
21 changes: 21 additions & 0 deletions custom_components/spoolman/schema_helper.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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."""
Expand Down
6 changes: 2 additions & 4 deletions custom_components/spoolman/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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

Expand Down
48 changes: 48 additions & 0 deletions custom_components/spoolman/services.yaml
Original file line number Diff line number Diff line change
@@ -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"

0 comments on commit 881a76b

Please sign in to comment.