Skip to content

Commit 326fddc

Browse files
authored
Merge pull request #838 from Disane87/feat/spool-extra-field-metadata
feat(sensors): expose Spoolman extra-field metadata on HA sensors
2 parents 228c368 + 12a4dec commit 326fddc

3 files changed

Lines changed: 95 additions & 4 deletions

File tree

custom_components/spoolman/classes/spoolman_api.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,31 @@ async def backup(self):
5959
_LOGGER.debug("SpoolmanAPI: backup response %s", response)
6060
return response
6161

62+
async def get_extra_fields(self, entity_type):
63+
"""Return extra-field metadata for the given entity type (e.g. ``spool``).
64+
65+
Returns a dict keyed by field key with ``name``, ``field_type``, ``unit``,
66+
``choices`` and ``multi_choice`` so the integration can set proper HA
67+
device classes / units / state classes on dynamic extra-field sensors.
68+
"""
69+
_LOGGER.debug("SpoolmanAPI: get_extra_fields(%s)", entity_type)
70+
url = f"{self.base_url}/field/{entity_type}"
71+
session = await self._get_session()
72+
async with session.get(url) as response:
73+
response.raise_for_status()
74+
payload = await response.json()
75+
_LOGGER.debug("SpoolmanAPI: get_extra_fields response %s", payload)
76+
return {
77+
item["key"]: {
78+
"name": item.get("name"),
79+
"field_type": item.get("field_type"),
80+
"unit": item.get("unit"),
81+
"choices": item.get("choices"),
82+
"multi_choice": item.get("multi_choice"),
83+
}
84+
for item in payload
85+
}
86+
6287
async def get_spools(self, params):
6388
"""Return a list of all spools."""
6489
_LOGGER.debug("SpoolmanAPI: get_spools")

custom_components/spoolman/coordinator.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ async def _async_update_data(self):
5858
{"allow_archived": show_archived}
5959
)
6060
filaments = await self.spoolman_api.get_filaments({})
61+
try:
62+
spool_extra_fields = await self.spoolman_api.get_extra_fields("spool")
63+
except Exception as exception:
64+
# Older Spoolman versions or transient errors: degrade gracefully.
65+
_LOGGER.debug("Could not fetch spool extra-field metadata: %s", exception)
66+
spool_extra_fields = {}
6167
except asyncio.CancelledError:
6268
# Task was cancelled (e.g., during shutdown), re-raise to let coordinator handle it
6369
_LOGGER.debug("Data update was cancelled")
@@ -98,7 +104,11 @@ async def _async_update_data(self):
98104
_LOGGER.error(f"Error processing Klipper API data: {exception}")
99105
# Continue returning spools even if Klipper processing fails
100106

101-
return {"spools": spools, "filaments": filaments}
107+
return {
108+
"spools": spools,
109+
"filaments": filaments,
110+
"extra_fields": {"spool": spool_extra_fields},
111+
}
102112

103113
async def async_cleanup_extra_fields(self):
104114
"""Cleanup orphaned extra field entities - can be called externally."""

custom_components/spoolman/sensors/spool_extra_field.py

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
import logging
66
from typing import Any
77

8-
from homeassistant.components.sensor import SensorEntity
8+
from homeassistant.components.sensor import (
9+
SensorDeviceClass,
10+
SensorEntity,
11+
SensorStateClass,
12+
)
913
from homeassistant.core import callback
1014
from homeassistant.helpers.device_registry import DeviceInfo
1115
from homeassistant.helpers.entity import generate_entity_id
@@ -15,6 +19,33 @@
1519

1620
_LOGGER = logging.getLogger(__name__)
1721

22+
_NUMERIC_FIELD_TYPES = {"integer", "float"}
23+
24+
# Map normalized Spoolman unit strings to (device_class, icon).
25+
# Matched on the unit reported by Spoolman, not on the user-chosen field name,
26+
# so it works regardless of locale.
27+
_UNIT_DEVICE_CLASS = {
28+
"%": (SensorDeviceClass.HUMIDITY, "mdi:water-percent"),
29+
"°c": (SensorDeviceClass.TEMPERATURE, "mdi:thermometer"),
30+
"°f": (SensorDeviceClass.TEMPERATURE, "mdi:thermometer"),
31+
"k": (SensorDeviceClass.TEMPERATURE, "mdi:thermometer"),
32+
"g": (SensorDeviceClass.WEIGHT, "mdi:weight-gram"),
33+
"kg": (SensorDeviceClass.WEIGHT, "mdi:weight-kilogram"),
34+
"mm": (SensorDeviceClass.DISTANCE, "mdi:ruler"),
35+
"m": (SensorDeviceClass.DISTANCE, "mdi:ruler"),
36+
}
37+
38+
39+
def _device_class_for(field_type: str | None, unit: str | None) -> tuple[Any, str]:
40+
"""Pick a device class + icon based on Spoolman field metadata."""
41+
if field_type == "datetime":
42+
return SensorDeviceClass.TIMESTAMP, "mdi:calendar"
43+
if unit:
44+
match = _UNIT_DEVICE_CLASS.get(unit.strip().lower())
45+
if match:
46+
return match
47+
return None, "mdi:tag-outline"
48+
1849

1950
class SpoolExtraField(CoordinatorEntity, SensorEntity):
2051
"""Sensor for spool extra field - dynamically created for each extra field."""
@@ -42,6 +73,19 @@ def __init__(
4273
else:
4374
spool_name = f"Spoolman Spool {self._spool['id']}"
4475

76+
# Pull metadata reported by Spoolman (`/api/v1/field/spool`) so we can
77+
# set proper device class / unit / state class. Falls back to a derived
78+
# display name and a plain icon when metadata isn't available.
79+
field_meta = (
80+
(coordinator.data or {})
81+
.get("extra_fields", {})
82+
.get("spool", {})
83+
.get(field_key, {})
84+
)
85+
display_name = field_meta.get("name") or field_key.replace("_", " ").title()
86+
field_type = field_meta.get("field_type")
87+
unit = field_meta.get("unit")
88+
4589
# Create entity ID with the field key
4690
# e.g., sensor.spoolman_spool_1_extra_humidity
4791
safe_field_key = field_key.lower().replace(" ", "_").replace("-", "_")
@@ -52,8 +96,19 @@ def __init__(
5296
)
5397
self._attr_unique_id = f"spoolman_{self._entry.entry_id}_spool_{spool_data['id']}_extra_{safe_field_key}"
5498
self._attr_has_entity_name = False
55-
self._attr_name = f"{spool_name} Extra {field_key.replace('_', ' ').title()}"
56-
self._attr_icon = "mdi:tag-outline"
99+
self._attr_name = f"{spool_name} Extra {display_name}"
100+
101+
device_class, icon = _device_class_for(field_type, unit)
102+
if device_class is not None:
103+
self._attr_device_class = device_class
104+
self._attr_icon = icon
105+
106+
if field_type in _NUMERIC_FIELD_TYPES:
107+
self._attr_state_class = SensorStateClass.MEASUREMENT
108+
109+
if unit:
110+
self._attr_native_unit_of_measurement = unit
111+
57112
self._attr_device_info = DeviceInfo(
58113
identifiers={(DOMAIN, self.config[CONF_URL], f"spool_{self._spool['id']}")} # type: ignore[arg-type]
59114
)
@@ -91,6 +146,7 @@ def state(self) -> Any:
91146
# Convert value to string if it's a complex type
92147
if isinstance(value, dict | list):
93148
import json
149+
94150
return json.dumps(value)
95151

96152
return value

0 commit comments

Comments
 (0)