Sensors are not getting into my integration

Hi guys! I’m pretty new to Python and the development of new integrations. Yesterday I started digging into your dev docs and got an example project up and running. I’m a senior developer with overall 15 years of experience, so I’m not that new to software development, but I guess I’m missing something at the moment.

I want to integrate my spool manager for my 3d printer into HA by calling their API (GitHub - Donkie/Spoolman: Keep track of your inventory of 3D-printer filament spools.). That works flawlessly and all sensors are created. But, all sensors aren’t added to the integration. The integration itself is working.

What am I missing at the moment?

My __init__.py

import asyncio
import logging
import aiohttp
import websockets
import json
from homeassistant.const import Platform
from homeassistant.helpers.aiohttp_client import async_create_clientsession

from .const import DOMAIN
from .coordinator import SpoolManCoordinator
from .sensor import SpoolSensor  # Annahme: Dies ist die Sensor-Klasse

_LOGGER = logging.getLogger(__name__)

PLATFORMS = [Platform.SENSOR]


async def async_setup(hass, config):
    """Set up the Spoolman component."""
    _LOGGER.info("__init__.async_setup")
    return True


async def async_setup_entry(hass, entry):
    """Set up the Spoolman component from a config entry."""
    _LOGGER.info("__init__.async_setup_entry")
    session = async_create_clientsession(hass)
    coordinator = SpoolManCoordinator(hass, entry)
    await coordinator.async_refresh()
    await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
    return True


async def async_unload_entry(hass, entry):
    _LOGGER.info("__init__.async_unload_entry")
    """Unload a config entry."""
    unload_ok = await hass.config_entries.async_forward_entry_unload(entry, "sensor")
    if unload_ok:
        hass.data.pop(entry.domain)
    return unload_ok


async def async_get_data(hass):
    _LOGGER.info("__init__.async_get_data")
    """Get the latest data from the Spoolman API."""
    api_key = hass.data[DOMAIN]["api_key"]
    url = hass.data[DOMAIN]["url"]
    async with aiohttp.ClientSession() as session:
        try:
            async with session.get(
                url, headers={"Authorization": f"Bearer {api_key}"}
            ) as response:
                data = await response.json()
                return data
        except Exception as e:
            _LOGGER.error(f"Error fetching data from Spoolman API: {e}")


async def async_setup_websocket(hass, websocket_url, callback):
    _LOGGER.info("__init__.async_setup_websocket")
    """Set up a WebSocket connection for real-time updates."""
    while True:
        try:
            async with websockets.connect(websocket_url) as ws:
                while True:
                    data = await ws.recv()
                    data_json = json.loads(data)
                    await callback()
        except Exception as e:
            _LOGGER.error(f"WebSocket error: {e}")

My config_flow.py

"""Config flow for spoolman integration."""
from __future__ import annotations


from typing import Any

import voluptuous as vol

from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from homeassistant.exceptions import HomeAssistantError

from .const import (
    DOMAIN,
    CONF_API_KEY,
    CONF_UPDATE_INTERVAL,
    CONF_URL,
    CONF_WEBSOCKET_URL,
)

# TODO adjust the data schema to the data that you need
# STEP_USER_DATA_SCHEMA =


class PlaceholderHub:
    """Placeholder class to make tests pass.

    TODO Remove this placeholder class and replace with things from your PyPI package.
    """

    def __init__(self, host: str) -> None:
        """Initialize."""
        self.host = host

    async def authenticate(self, username: str, password: str) -> bool:
        """Test if we can authenticate with the host."""
        return True


async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
    """Validate the user input allows us to connect.

    Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
    """
    # TODO validate the data can be used to set up a connection.

    # If your PyPI package is not built with async, pass your methods
    # to the executor:
    # await hass.async_add_executor_job(
    #     your_validate_func, data["username"], data["password"]
    # )

    hub = PlaceholderHub(data["host"])

    if not await hub.authenticate(data["username"], data["password"]):
        raise InvalidAuth

    # If you cannot connect:
    # throw CannotConnect
    # If the authentication is wrong:
    # InvalidAuth

    # Return info that you want to store in the config entry.
    return {"title": "Name of the device"}


class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
    """Handle a config flow for spoolman."""

    VERSION = 1

    async def async_step_user(
        self, user_input: dict[str, Any] | None = None
    ) -> FlowResult:
        """Handle the initial step."""
        errors: dict[str, str] = {}
        if user_input is not None:
            api_key = user_input[CONF_API_KEY]
            url = user_input[CONF_URL]
            update_interval = user_input.get(CONF_UPDATE_INTERVAL)
            websocket_url = user_input.get(CONF_WEBSOCKET_URL) or f"wss://{url}"

            # Test the API key and URLs here if necessary
            # If valid, create an entry
            # If invalid, set errors

            if not errors:
                return self.async_create_entry(
                    title="Spoolman",
                    data={
                        CONF_API_KEY: api_key,
                        CONF_URL: url,
                        CONF_UPDATE_INTERVAL: update_interval,
                        CONF_WEBSOCKET_URL: websocket_url,
                    },
                )

        return self.async_show_form(
            step_id="user",
            data_schema=vol.Schema(
                {
                    vol.Required(CONF_API_KEY, "blabla", "sdfsdfdsdsf"): str,
                    vol.Required(
                        CONF_URL, "blabla", "https://spoolman.disane.dev"
                    ): str,
                    vol.Optional(CONF_UPDATE_INTERVAL, default=15): vol.All(
                        vol.Coerce(int), vol.Range(min=1)
                    ),  # In minutes
                    vol.Optional(
                        CONF_WEBSOCKET_URL, "Websocket", "wss://spoolman.disane.dev"
                    ): str,
                }
            ),
            errors=errors,
        )


class CannotConnect(HomeAssistantError):
    """Error to indicate we cannot connect."""


class InvalidAuth(HomeAssistantError):
    """Error to indicate there is invalid auth."""

My sensor.py

import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.components.sensor import (
    SensorDeviceClass,
    SensorEntity,
    SensorStateClass,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.const import LENGTH_MILLIMETERS
from PIL import Image
from .coordinator import SpoolManCoordinator
from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)
ICON = "mdi:printer-3d-nozzle"


async def async_setup_entry(
    hass: HomeAssistant,
    config_entry: ConfigEntry,
    async_add_entities: AddEntitiesCallback,
):
    _LOGGER.info("Sensor.async_setup_entry")
    """Set up the Spoolman sensor platform."""
    coordinator = hass.data[DOMAIN]["coordinator"]
    spool_data = coordinator.data

    if spool_data:
        sensors = [
            SpoolSensor(coordinator, spool, config_entry) for spool in spool_data
        ]
        async_add_entities(sensors)


class SpoolSensor(CoordinatorEntity[SpoolManCoordinator], SensorEntity):
    """Representation of a Spoolman Sensor."""

    def __init__(
        self, coordinator: SpoolManCoordinator, spool, config_entry: ConfigEntry
    ):
        _LOGGER.info("SpoolSensor.__init__")
        super().__init__(coordinator)
        self._spool = spool
        self._filament = self._spool["filament"]
        self._entry = config_entry

    @property
    def name(self):
        """Return the name of the sensor."""
        return f"{self._filament['name']}"

    @property
    def state(self):
        """Return the state of the sensor."""
        return round(self._spool["remaining_weight"], 2)

    @property
    def device_class(self):
        """Return the device class of the sensor."""
        return SensorDeviceClass.DISTANCE

    @property
    def state_class(self):
        """Return the state class of the sensor."""
        return SensorStateClass.MEASUREMENT

    @property
    def unit_of_measurement(self):
        """Return the unit of measurement of the sensor."""
        return LENGTH_MILLIMETERS

    @property
    def icon(self):
        """Return the icon for the sensor."""
        return ICON

    @property
    def device_info(self):
        """Return device information for the sensor."""
        return DeviceInfo(
            identifiers={(DOMAIN, self._entry.entry_id)},
            manufacturer="Spoolman",
            name=self.name,
        )

    @property
    def entity_picture(self):
        """Return the entity picture."""
        filament = self._spool["filament"]
        color_hex = filament["color_hex"]
        return self.generate_entity_picture(color_hex)

    def generate_entity_picture(self, color_hex):
        """Generate an entity picture with the specified color and save it to the www directory."""
        image = Image.new("RGB", (100, 100), f"#{color_hex}")
        image_name = f"spool_{self._spool['id']}.png"
        image_path = self.hass.config.path(f"www/spoolman_images/{image_name}")
        image.save(image_path)

        # Get the URL for the saved image
        image_url = f"/local/spoolman_images/{image_name}"

        return image_url

    @property
    def extra_state_attributes(self):
        """Return the attributes of the sensor."""
        spool = self._spool
        attributes = {}

        for key, value in spool.items():
            if isinstance(value, dict):
                # If the value is a dictionary, iterate over the keys and values
                for sub_key, sub_value in value.items():
                    attributes[f"{key}_{sub_key}"] = sub_value
            elif isinstance(value, str):
                # If the value is a string, trim it
                attributes[key] = value.strip()
            else:
                attributes[key] = value

        return attributes

    async def async_update(self):
        """Fetch the latest data from the coordinator."""
        await self.hass.data[DOMAIN]["coordinator"].async_request_refresh()

coordinator.py

from .const import (
    DOMAIN,
    CONF_API_KEY,
    CONF_UPDATE_INTERVAL,
    CONF_URL,
    CONF_WEBSOCKET_URL,
)

import aiohttp
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

import logging
from datetime import timedelta

_LOGGER = logging.getLogger(__name__)


class SpoolManCoordinator(DataUpdateCoordinator):
    """My custom coordinator."""

    def __init__(self, hass, entry):
        _LOGGER.info("SpoolManCoordinator.__init__")
        api_key = entry.data[CONF_API_KEY]
        url = f"{entry.data[CONF_URL]}/api/v1/spool"
        update_interval = entry.data[CONF_UPDATE_INTERVAL]
        websocket_url = entry.data.get(CONF_WEBSOCKET_URL) or f"wss://{url}"
        """Initialize my coordinator."""
        super().__init__(
            hass,
            _LOGGER,
            # Name of the data. For logging purposes.
            name="Spoolman data",
            # Polling interval. Will only be polled if there are subscribers.
            update_interval=timedelta(seconds=update_interval),
        )
        self.my_api = url
        self.hass = hass

        hass.data[DOMAIN] = {
            "api_key": api_key,
            "url": url,
            "coordinator": self,  # ,
        }

    async def _async_update_data(self):
        _LOGGER.info("SpoolManCoordinator._async_update_data")
        api_key = self.hass.data[DOMAIN]["api_key"]
        url = self.hass.data[DOMAIN]["url"]

        async with aiohttp.ClientSession() as session:
            try:
                async with session.get(
                    url, headers={"Authorization": f"Bearer {api_key}"}
                ) as response:
                    data = await response.json()
                    return data
            except Exception as e:
                raise UpdateFailed(f"Error fetching data from Spoolman API: {e}")

manifest.json

{
  "domain": "spoolman",
  "name": "spoolman",
  "codeowners": [
    "@mfranke87"
  ],
  "config_flow": true,
  "dependencies": [],
  "documentation": "https://www.home-assistant.io/integrations/spoolman",
  "homekit": {},
  "iot_class": "local_polling",
  "requirements": [],
  "ssdp": [],
  "zeroconf": []
}

But the integration is empty:

But the sensors are created:

Please don’t be mad at me, I’m actually learning python and the ecosystem of HA :slight_smile:

I guess this is a little thing I’ve forgot to set but I can’t find anything. Even a simple almost boilerplate sensor integration isn’t working.

Kind regards!