From 70df4ca461610afeadbb5a4c9d18cd9a8de5bb2a Mon Sep 17 00:00:00 2001
From: "Mr. Bubbles" <manni@zapto.de>
Date: Mon, 29 Jul 2024 11:44:01 +0200
Subject: [PATCH] Integration for IronOS (Pinecil V2) soldering irons (#120802)

* Add Pinecil integration

* Refactor with new library

* Add tests for config flow, remove unused code

* requested changes

* update requirements

* Move some sensor values to diagnostics, add tests for sensors

* User service uuid in discovery

* fix manufacturer name

* Bump pynecil to version 0.2.0

* Rename integration to IronOS

* Recreate snapshot

* Update strings

* type checking

* Update snapshot

* Add async_setup to coordinator

* Show device id with serial number

* Added missing boost to operation mode states

* remove super call

* Refactor

* tests
---
 CODEOWNERS                                    |   2 +
 homeassistant/components/iron_os/__init__.py  |  53 ++
 .../components/iron_os/config_flow.py         |  83 +++
 homeassistant/components/iron_os/const.py     |  10 +
 .../components/iron_os/coordinator.py         |  49 ++
 homeassistant/components/iron_os/entity.py    |  41 ++
 homeassistant/components/iron_os/icons.json   |  61 ++
 .../components/iron_os/manifest.json          |  17 +
 homeassistant/components/iron_os/sensor.py    | 199 +++++
 homeassistant/components/iron_os/strings.json |  84 +++
 homeassistant/generated/bluetooth.py          |   5 +
 homeassistant/generated/config_flows.py       |   1 +
 homeassistant/generated/integrations.json     |   6 +
 requirements_all.txt                          |   3 +
 requirements_test_all.txt                     |   3 +
 tests/components/iron_os/__init__.py          |   1 +
 tests/components/iron_os/conftest.py          | 141 ++++
 .../iron_os/snapshots/test_sensor.ambr        | 683 ++++++++++++++++++
 tests/components/iron_os/test_config_flow.py  |  66 ++
 tests/components/iron_os/test_sensor.py       |  73 ++
 20 files changed, 1581 insertions(+)
 create mode 100644 homeassistant/components/iron_os/__init__.py
 create mode 100644 homeassistant/components/iron_os/config_flow.py
 create mode 100644 homeassistant/components/iron_os/const.py
 create mode 100644 homeassistant/components/iron_os/coordinator.py
 create mode 100644 homeassistant/components/iron_os/entity.py
 create mode 100644 homeassistant/components/iron_os/icons.json
 create mode 100644 homeassistant/components/iron_os/manifest.json
 create mode 100644 homeassistant/components/iron_os/sensor.py
 create mode 100644 homeassistant/components/iron_os/strings.json
 create mode 100644 tests/components/iron_os/__init__.py
 create mode 100644 tests/components/iron_os/conftest.py
 create mode 100644 tests/components/iron_os/snapshots/test_sensor.ambr
 create mode 100644 tests/components/iron_os/test_config_flow.py
 create mode 100644 tests/components/iron_os/test_sensor.py

diff --git a/CODEOWNERS b/CODEOWNERS
index fc18be91239..bf93676f962 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -710,6 +710,8 @@ build.json @home-assistant/supervisor
 /homeassistant/components/iqvia/ @bachya
 /tests/components/iqvia/ @bachya
 /homeassistant/components/irish_rail_transport/ @ttroy50
+/homeassistant/components/iron_os/ @tr4nt0r
+/tests/components/iron_os/ @tr4nt0r
 /homeassistant/components/isal/ @bdraco
 /tests/components/isal/ @bdraco
 /homeassistant/components/islamic_prayer_times/ @engrbm87 @cpfair
diff --git a/homeassistant/components/iron_os/__init__.py b/homeassistant/components/iron_os/__init__.py
new file mode 100644
index 00000000000..bf3c6c34c83
--- /dev/null
+++ b/homeassistant/components/iron_os/__init__.py
@@ -0,0 +1,53 @@
+"""The IronOS integration."""
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING
+
+from pynecil import Pynecil
+
+from homeassistant.components import bluetooth
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_NAME, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+
+from .const import DOMAIN
+from .coordinator import IronOSCoordinator
+
+PLATFORMS: list[Platform] = [Platform.SENSOR]
+
+type IronOSConfigEntry = ConfigEntry[IronOSCoordinator]
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bool:
+    """Set up IronOS from a config entry."""
+    if TYPE_CHECKING:
+        assert entry.unique_id
+    ble_device = bluetooth.async_ble_device_from_address(
+        hass, entry.unique_id, connectable=True
+    )
+    if not ble_device:
+        raise ConfigEntryNotReady(
+            translation_domain=DOMAIN,
+            translation_key="setup_device_unavailable_exception",
+            translation_placeholders={CONF_NAME: entry.title},
+        )
+
+    device = Pynecil(ble_device)
+
+    coordinator = IronOSCoordinator(hass, device)
+    await coordinator.async_config_entry_first_refresh()
+
+    entry.runtime_data = coordinator
+    await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+    return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bool:
+    """Unload a config entry."""
+    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/iron_os/config_flow.py b/homeassistant/components/iron_os/config_flow.py
new file mode 100644
index 00000000000..444db79c926
--- /dev/null
+++ b/homeassistant/components/iron_os/config_flow.py
@@ -0,0 +1,83 @@
+"""Config flow for IronOS integration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from habluetooth import BluetoothServiceInfoBleak
+import voluptuous as vol
+
+from homeassistant.components.bluetooth.api import async_discovered_service_info
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_ADDRESS
+
+from .const import DISCOVERY_SVC_UUID, DOMAIN
+
+
+class IronOSConfigFlow(ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for IronOS."""
+
+    def __init__(self) -> None:
+        """Initialize the config flow."""
+        self._discovery_info: BluetoothServiceInfoBleak | None = None
+        self._discovered_devices: dict[str, str] = {}
+
+    async def async_step_bluetooth(
+        self, discovery_info: BluetoothServiceInfoBleak
+    ) -> ConfigFlowResult:
+        """Handle bluetooth discovery step."""
+        await self.async_set_unique_id(discovery_info.address)
+        self._abort_if_unique_id_configured()
+        self._discovery_info = discovery_info
+
+        return await self.async_step_bluetooth_confirm()
+
+    async def async_step_bluetooth_confirm(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Confirm discovery."""
+        assert self._discovery_info is not None
+        discovery_info = self._discovery_info
+        title = discovery_info.name
+
+        if user_input is not None:
+            return self.async_create_entry(title=title, data={})
+
+        self._set_confirm_only()
+        placeholders = {"name": title}
+        self.context["title_placeholders"] = placeholders
+        return self.async_show_form(
+            step_id="bluetooth_confirm", description_placeholders=placeholders
+        )
+
+    async def async_step_user(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle the user step to pick discovered device."""
+        if user_input is not None:
+            address = user_input[CONF_ADDRESS]
+            title = self._discovered_devices[address]
+            await self.async_set_unique_id(address, raise_on_progress=False)
+            self._abort_if_unique_id_configured()
+            return self.async_create_entry(title=title, data={})
+
+        current_addresses = self._async_current_ids()
+        for discovery_info in async_discovered_service_info(self.hass, True):
+            address = discovery_info.address
+            if (
+                DISCOVERY_SVC_UUID not in discovery_info.service_uuids
+                or address in current_addresses
+                or address in self._discovered_devices
+            ):
+                continue
+            self._discovered_devices[address] = discovery_info.name
+
+        if not self._discovered_devices:
+            return self.async_abort(reason="no_devices_found")
+
+        return self.async_show_form(
+            step_id="user",
+            data_schema=vol.Schema(
+                {vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)}
+            ),
+        )
diff --git a/homeassistant/components/iron_os/const.py b/homeassistant/components/iron_os/const.py
new file mode 100644
index 00000000000..86b7d401f4f
--- /dev/null
+++ b/homeassistant/components/iron_os/const.py
@@ -0,0 +1,10 @@
+"""Constants for the IronOS integration."""
+
+DOMAIN = "iron_os"
+
+MANUFACTURER = "PINE64"
+MODEL = "Pinecil V2"
+
+OHM = "Ω"
+
+DISCOVERY_SVC_UUID = "9eae1000-9d0d-48c5-aa55-33e27f9bc533"
diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py
new file mode 100644
index 00000000000..e8424478d86
--- /dev/null
+++ b/homeassistant/components/iron_os/coordinator.py
@@ -0,0 +1,49 @@
+"""Update coordinator for IronOS Integration."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+import logging
+
+from pynecil import CommunicationError, DeviceInfoResponse, LiveDataResponse, Pynecil
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=5)
+
+
+class IronOSCoordinator(DataUpdateCoordinator[LiveDataResponse]):
+    """IronOS coordinator."""
+
+    device_info: DeviceInfoResponse
+    config_entry: ConfigEntry
+
+    def __init__(self, hass: HomeAssistant, device: Pynecil) -> None:
+        """Initialize IronOS coordinator."""
+        super().__init__(
+            hass,
+            _LOGGER,
+            name=DOMAIN,
+            update_interval=SCAN_INTERVAL,
+        )
+        self.device = device
+
+    async def _async_update_data(self) -> LiveDataResponse:
+        """Fetch data from Device."""
+
+        try:
+            return await self.device.get_live_data()
+
+        except CommunicationError as e:
+            raise UpdateFailed("Cannot connect to device") from e
+
+    async def _async_setup(self) -> None:
+        """Set up the coordinator."""
+
+        self.device_info = await self.device.get_device_info()
diff --git a/homeassistant/components/iron_os/entity.py b/homeassistant/components/iron_os/entity.py
new file mode 100644
index 00000000000..5a24b0a5567
--- /dev/null
+++ b/homeassistant/components/iron_os/entity.py
@@ -0,0 +1,41 @@
+"""Base entity for IronOS integration."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
+from homeassistant.helpers.entity import EntityDescription
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import MANUFACTURER, MODEL
+from .coordinator import IronOSCoordinator
+
+
+class IronOSBaseEntity(CoordinatorEntity[IronOSCoordinator]):
+    """Base IronOS entity."""
+
+    _attr_has_entity_name = True
+
+    def __init__(
+        self,
+        coordinator: IronOSCoordinator,
+        entity_description: EntityDescription,
+    ) -> None:
+        """Initialize the sensor."""
+        super().__init__(coordinator)
+
+        self.entity_description = entity_description
+        self._attr_unique_id = (
+            f"{coordinator.config_entry.unique_id}_{entity_description.key}"
+        )
+        if TYPE_CHECKING:
+            assert coordinator.config_entry.unique_id
+        self.device_info = DeviceInfo(
+            connections={(CONNECTION_BLUETOOTH, coordinator.config_entry.unique_id)},
+            manufacturer=MANUFACTURER,
+            model=MODEL,
+            name="Pinecil",
+            sw_version=coordinator.device_info.build,
+            serial_number=f"{coordinator.device_info.device_sn} (ID:{coordinator.device_info.device_id})",
+        )
diff --git a/homeassistant/components/iron_os/icons.json b/homeassistant/components/iron_os/icons.json
new file mode 100644
index 00000000000..0d207607a4f
--- /dev/null
+++ b/homeassistant/components/iron_os/icons.json
@@ -0,0 +1,61 @@
+{
+  "entity": {
+    "sensor": {
+      "live_temperature": {
+        "default": "mdi:soldering-iron"
+      },
+      "setpoint_temperature": {
+        "default": "mdi:thermostat"
+      },
+      "voltage": {
+        "default": "mdi:current-dc"
+      },
+      "handle_temperature": {
+        "default": "mdi:grease-pencil"
+      },
+      "power_pwm_level": {
+        "default": "mdi:square-wave"
+      },
+      "power_source": {
+        "default": "mdi:power-plug",
+        "state": {
+          "dc": "mdi:record-circle-outline",
+          "qc": "mdi:usb-port",
+          "pd_vbus": "mdi:usb-c-port",
+          "pd": "mdi:usb-c-port"
+        }
+      },
+      "tip_resistance": {
+        "default": "mdi:omega"
+      },
+      "hall_sensor": {
+        "default": "mdi:leak"
+      },
+      "movement_time": {
+        "default": "mdi:clock-fast"
+      },
+      "max_tip_temp_ability": {
+        "default": "mdi:thermometer-chevron-up"
+      },
+      "uptime": {
+        "default": "mdi:progress-clock"
+      },
+      "tip_voltage": {
+        "default": "mdi:sine-wave"
+      },
+      "operating_mode": {
+        "default": "mdi:format-list-bulleted",
+        "state": {
+          "boost": "mdi:rocket-launch",
+          "soldering": "mdi:soldering-iron",
+          "sleeping": "mdi:sleep",
+          "settings": "mdi:menu-open",
+          "debug": "mdi:bug-play"
+        }
+      },
+      "estimated_power": {
+        "default": "mdi:flash"
+      }
+    }
+  }
+}
diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json
new file mode 100644
index 00000000000..cfaf36880f2
--- /dev/null
+++ b/homeassistant/components/iron_os/manifest.json
@@ -0,0 +1,17 @@
+{
+  "domain": "iron_os",
+  "name": "IronOS",
+  "bluetooth": [
+    {
+      "service_uuid": "9eae1000-9d0d-48c5-aa55-33e27f9bc533",
+      "connectable": true
+    }
+  ],
+  "codeowners": ["@tr4nt0r"],
+  "config_flow": true,
+  "dependencies": ["bluetooth_adapters"],
+  "documentation": "https://www.home-assistant.io/integrations/iron_os",
+  "iot_class": "local_polling",
+  "loggers": ["pynecil"],
+  "requirements": ["pynecil==0.2.0"]
+}
diff --git a/homeassistant/components/iron_os/sensor.py b/homeassistant/components/iron_os/sensor.py
new file mode 100644
index 00000000000..095ffd254df
--- /dev/null
+++ b/homeassistant/components/iron_os/sensor.py
@@ -0,0 +1,199 @@
+"""Sensor platform for IronOS integration."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from enum import StrEnum
+
+from pynecil import LiveDataResponse, OperatingMode, PowerSource
+
+from homeassistant.components.sensor import (
+    EntityCategory,
+    SensorDeviceClass,
+    SensorEntity,
+    SensorEntityDescription,
+    SensorStateClass,
+)
+from homeassistant.const import (
+    PERCENTAGE,
+    UnitOfElectricPotential,
+    UnitOfPower,
+    UnitOfTemperature,
+    UnitOfTime,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import StateType
+
+from . import IronOSConfigEntry
+from .const import OHM
+from .entity import IronOSBaseEntity
+
+
+class PinecilSensor(StrEnum):
+    """Pinecil Sensors."""
+
+    LIVE_TEMP = "live_temperature"
+    SETPOINT_TEMP = "setpoint_temperature"
+    DC_VOLTAGE = "voltage"
+    HANDLETEMP = "handle_temperature"
+    PWMLEVEL = "power_pwm_level"
+    POWER_SRC = "power_source"
+    TIP_RESISTANCE = "tip_resistance"
+    UPTIME = "uptime"
+    MOVEMENT_TIME = "movement_time"
+    MAX_TIP_TEMP_ABILITY = "max_tip_temp_ability"
+    TIP_VOLTAGE = "tip_voltage"
+    HALL_SENSOR = "hall_sensor"
+    OPERATING_MODE = "operating_mode"
+    ESTIMATED_POWER = "estimated_power"
+
+
+@dataclass(frozen=True, kw_only=True)
+class IronOSSensorEntityDescription(SensorEntityDescription):
+    """IronOS sensor entity descriptions."""
+
+    value_fn: Callable[[LiveDataResponse], StateType]
+
+
+PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = (
+    IronOSSensorEntityDescription(
+        key=PinecilSensor.LIVE_TEMP,
+        translation_key=PinecilSensor.LIVE_TEMP,
+        native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+        device_class=SensorDeviceClass.TEMPERATURE,
+        state_class=SensorStateClass.MEASUREMENT,
+        value_fn=lambda data: data.live_temp,
+    ),
+    IronOSSensorEntityDescription(
+        key=PinecilSensor.DC_VOLTAGE,
+        translation_key=PinecilSensor.DC_VOLTAGE,
+        native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+        device_class=SensorDeviceClass.VOLTAGE,
+        state_class=SensorStateClass.MEASUREMENT,
+        value_fn=lambda data: data.dc_voltage,
+        entity_category=EntityCategory.DIAGNOSTIC,
+    ),
+    IronOSSensorEntityDescription(
+        key=PinecilSensor.HANDLETEMP,
+        translation_key=PinecilSensor.HANDLETEMP,
+        native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+        device_class=SensorDeviceClass.TEMPERATURE,
+        state_class=SensorStateClass.MEASUREMENT,
+        value_fn=lambda data: data.handle_temp,
+    ),
+    IronOSSensorEntityDescription(
+        key=PinecilSensor.PWMLEVEL,
+        translation_key=PinecilSensor.PWMLEVEL,
+        native_unit_of_measurement=PERCENTAGE,
+        suggested_display_precision=0,
+        device_class=SensorDeviceClass.POWER_FACTOR,
+        state_class=SensorStateClass.MEASUREMENT,
+        value_fn=lambda data: data.pwm_level,
+        entity_category=EntityCategory.DIAGNOSTIC,
+    ),
+    IronOSSensorEntityDescription(
+        key=PinecilSensor.POWER_SRC,
+        translation_key=PinecilSensor.POWER_SRC,
+        device_class=SensorDeviceClass.ENUM,
+        options=[item.name.lower() for item in PowerSource],
+        value_fn=lambda data: data.power_src.name.lower() if data.power_src else None,
+        entity_category=EntityCategory.DIAGNOSTIC,
+    ),
+    IronOSSensorEntityDescription(
+        key=PinecilSensor.TIP_RESISTANCE,
+        translation_key=PinecilSensor.TIP_RESISTANCE,
+        native_unit_of_measurement=OHM,
+        value_fn=lambda data: data.tip_resistance,
+        entity_category=EntityCategory.DIAGNOSTIC,
+    ),
+    IronOSSensorEntityDescription(
+        key=PinecilSensor.UPTIME,
+        translation_key=PinecilSensor.UPTIME,
+        native_unit_of_measurement=UnitOfTime.SECONDS,
+        device_class=SensorDeviceClass.DURATION,
+        state_class=SensorStateClass.TOTAL_INCREASING,
+        value_fn=lambda data: data.uptime,
+        entity_category=EntityCategory.DIAGNOSTIC,
+    ),
+    IronOSSensorEntityDescription(
+        key=PinecilSensor.MOVEMENT_TIME,
+        translation_key=PinecilSensor.MOVEMENT_TIME,
+        native_unit_of_measurement=UnitOfTime.SECONDS,
+        device_class=SensorDeviceClass.DURATION,
+        state_class=SensorStateClass.MEASUREMENT,
+        value_fn=lambda data: data.movement_time,
+        entity_category=EntityCategory.DIAGNOSTIC,
+    ),
+    IronOSSensorEntityDescription(
+        key=PinecilSensor.MAX_TIP_TEMP_ABILITY,
+        translation_key=PinecilSensor.MAX_TIP_TEMP_ABILITY,
+        native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+        device_class=SensorDeviceClass.TEMPERATURE,
+        value_fn=lambda data: data.max_tip_temp_ability,
+        entity_category=EntityCategory.DIAGNOSTIC,
+    ),
+    IronOSSensorEntityDescription(
+        key=PinecilSensor.TIP_VOLTAGE,
+        translation_key=PinecilSensor.TIP_VOLTAGE,
+        native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
+        device_class=SensorDeviceClass.VOLTAGE,
+        state_class=SensorStateClass.MEASUREMENT,
+        suggested_display_precision=3,
+        value_fn=lambda data: data.tip_voltage,
+        entity_category=EntityCategory.DIAGNOSTIC,
+    ),
+    IronOSSensorEntityDescription(
+        key=PinecilSensor.HALL_SENSOR,
+        translation_key=PinecilSensor.HALL_SENSOR,
+        state_class=SensorStateClass.MEASUREMENT,
+        entity_registry_enabled_default=False,
+        value_fn=lambda data: data.hall_sensor,
+        entity_category=EntityCategory.DIAGNOSTIC,
+    ),
+    IronOSSensorEntityDescription(
+        key=PinecilSensor.OPERATING_MODE,
+        translation_key=PinecilSensor.OPERATING_MODE,
+        device_class=SensorDeviceClass.ENUM,
+        options=[item.name.lower() for item in OperatingMode],
+        value_fn=(
+            lambda data: data.operating_mode.name.lower()
+            if data.operating_mode
+            else None
+        ),
+    ),
+    IronOSSensorEntityDescription(
+        key=PinecilSensor.ESTIMATED_POWER,
+        translation_key=PinecilSensor.ESTIMATED_POWER,
+        native_unit_of_measurement=UnitOfPower.WATT,
+        device_class=SensorDeviceClass.POWER,
+        state_class=SensorStateClass.MEASUREMENT,
+        value_fn=lambda data: data.estimated_power,
+    ),
+)
+
+
+async def async_setup_entry(
+    hass: HomeAssistant,
+    entry: IronOSConfigEntry,
+    async_add_entities: AddEntitiesCallback,
+) -> None:
+    """Set up sensors from a config entry."""
+    coordinator = entry.runtime_data
+
+    async_add_entities(
+        IronOSSensorEntity(coordinator, description)
+        for description in PINECIL_SENSOR_DESCRIPTIONS
+    )
+
+
+class IronOSSensorEntity(IronOSBaseEntity, SensorEntity):
+    """Representation of a IronOS sensor entity."""
+
+    entity_description: IronOSSensorEntityDescription
+
+    @property
+    def native_value(self) -> StateType:
+        """Return sensor state."""
+        return self.entity_description.value_fn(self.coordinator.data)
diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json
new file mode 100644
index 00000000000..cb95330b768
--- /dev/null
+++ b/homeassistant/components/iron_os/strings.json
@@ -0,0 +1,84 @@
+{
+  "config": {
+    "step": {
+      "user": {
+        "description": "[%key:component::bluetooth::config::step::user::description%]",
+        "data": {
+          "address": "[%key:common::config_flow::data::device%]"
+        }
+      },
+      "bluetooth_confirm": {
+        "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
+      }
+    },
+    "abort": {
+      "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
+      "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+    }
+  },
+  "entity": {
+    "sensor": {
+      "live_temperature": {
+        "name": "Tip temperature"
+      },
+      "voltage": {
+        "name": "DC input voltage"
+      },
+      "handle_temperature": {
+        "name": "Handle temperature"
+      },
+      "power_pwm_level": {
+        "name": "Power level"
+      },
+      "power_source": {
+        "name": "Power source",
+        "state": {
+          "dc": "DC input",
+          "qc": "USB Quick Charge",
+          "pd_vbus": "USB PD VBUS",
+          "pd": "USB Power Delivery"
+        }
+      },
+      "tip_resistance": {
+        "name": "Tip resistance"
+      },
+      "uptime": {
+        "name": "Uptime"
+      },
+      "movement_time": {
+        "name": "Last movement time"
+      },
+      "max_tip_temp_ability": {
+        "name": "Max tip temperature"
+      },
+      "tip_voltage": {
+        "name": "Raw tip voltage"
+      },
+      "hall_sensor": {
+        "name": "Hall effect strength"
+      },
+      "operating_mode": {
+        "name": "Operating mode",
+        "state": {
+          "idle": "[%key:common::state::idle%]",
+          "soldering": "Soldering",
+          "sleeping": "Sleeping",
+          "settings": "Settings",
+          "debug": "Debug",
+          "boost": "Boost"
+        }
+      },
+      "estimated_power": {
+        "name": "Estimated power"
+      }
+    }
+  },
+  "exceptions": {
+    "setup_device_unavailable_exception": {
+      "message": "Device {name} is not reachable"
+    },
+    "setup_device_connection_error_exception": {
+      "message": "Connection to device {name} failed, try again later"
+    }
+  }
+}
diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py
index cda011d1bef..2ea604a91a2 100644
--- a/homeassistant/generated/bluetooth.py
+++ b/homeassistant/generated/bluetooth.py
@@ -321,6 +321,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
         "domain": "inkbird",
         "local_name": "tps",
     },
+    {
+        "connectable": True,
+        "domain": "iron_os",
+        "service_uuid": "9eae1000-9d0d-48c5-aa55-33e27f9bc533",
+    },
     {
         "connectable": False,
         "domain": "kegtron",
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index e7d5278dd89..d350a58f3c6 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -276,6 +276,7 @@ FLOWS = {
         "ipma",
         "ipp",
         "iqvia",
+        "iron_os",
         "islamic_prayer_times",
         "israel_rail",
         "iss",
diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json
index 8bfef6a9887..8b0225ed063 100644
--- a/homeassistant/generated/integrations.json
+++ b/homeassistant/generated/integrations.json
@@ -2899,6 +2899,12 @@
       "config_flow": false,
       "iot_class": "cloud_polling"
     },
+    "iron_os": {
+      "name": "IronOS",
+      "integration_type": "hub",
+      "config_flow": true,
+      "iot_class": "local_polling"
+    },
     "islamic_prayer_times": {
       "integration_type": "hub",
       "config_flow": true,
diff --git a/requirements_all.txt b/requirements_all.txt
index 3b599b00ce8..90ca4049d85 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -2025,6 +2025,9 @@ pymsteams==0.1.12
 # homeassistant.components.mysensors
 pymysensors==0.24.0
 
+# homeassistant.components.iron_os
+pynecil==0.2.0
+
 # homeassistant.components.netgear
 pynetgear==0.10.10
 
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 27d112fb4f4..7bdd1a910ef 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -1615,6 +1615,9 @@ pymonoprice==0.4
 # homeassistant.components.mysensors
 pymysensors==0.24.0
 
+# homeassistant.components.iron_os
+pynecil==0.2.0
+
 # homeassistant.components.netgear
 pynetgear==0.10.10
 
diff --git a/tests/components/iron_os/__init__.py b/tests/components/iron_os/__init__.py
new file mode 100644
index 00000000000..4e27f2c741c
--- /dev/null
+++ b/tests/components/iron_os/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Pinecil integration."""
diff --git a/tests/components/iron_os/conftest.py b/tests/components/iron_os/conftest.py
new file mode 100644
index 00000000000..b6983074441
--- /dev/null
+++ b/tests/components/iron_os/conftest.py
@@ -0,0 +1,141 @@
+"""Fixtures for Pinecil tests."""
+
+from collections.abc import Generator
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from bleak.backends.device import BLEDevice
+from habluetooth import BluetoothServiceInfoBleak
+from pynecil import DeviceInfoResponse, LiveDataResponse, OperatingMode, PowerSource
+import pytest
+
+from homeassistant.components.iron_os import DOMAIN
+from homeassistant.const import CONF_ADDRESS
+
+from tests.common import MockConfigEntry
+from tests.components.bluetooth import generate_advertisement_data, generate_ble_device
+
+USER_INPUT = {CONF_ADDRESS: "c0:ff:ee:c0:ff:ee"}
+DEFAULT_NAME = "Pinecil-C0FFEEE"
+PINECIL_SERVICE_INFO = BluetoothServiceInfoBleak(
+    name="Pinecil-C0FFEEE",
+    address="c0:ff:ee:c0:ff:ee",
+    device=generate_ble_device(
+        address="c0:ff:ee:c0:ff:ee",
+        name="Pinecil-C0FFEEE",
+    ),
+    rssi=-61,
+    manufacturer_data={},
+    service_data={},
+    service_uuids=["9eae1000-9d0d-48c5-aa55-33e27f9bc533"],
+    source="local",
+    advertisement=generate_advertisement_data(
+        manufacturer_data={},
+        service_uuids=["9eae1000-9d0d-48c5-aa55-33e27f9bc533"],
+    ),
+    connectable=True,
+    time=0,
+    tx_power=None,
+)
+
+UNKNOWN_SERVICE_INFO = BluetoothServiceInfoBleak(
+    name="",
+    address="c0:ff:ee:c0:ff:ee",
+    device=generate_ble_device(
+        address="c0:ff:ee:c0:ff:ee",
+        name="",
+    ),
+    rssi=-61,
+    manufacturer_data={},
+    service_data={},
+    service_uuids=[],
+    source="local",
+    advertisement=generate_advertisement_data(
+        manufacturer_data={},
+        service_uuids=[],
+    ),
+    connectable=True,
+    time=0,
+    tx_power=None,
+)
+
+
+@pytest.fixture(autouse=True)
+def mock_bluetooth(enable_bluetooth: None) -> None:
+    """Auto mock bluetooth."""
+
+
+@pytest.fixture
+def mock_setup_entry() -> Generator[AsyncMock]:
+    """Override async_setup_entry."""
+    with patch(
+        "homeassistant.components.iron_os.async_setup_entry", return_value=True
+    ) as mock_setup_entry:
+        yield mock_setup_entry
+
+
+@pytest.fixture(name="discovery")
+def mock_async_discovered_service_info() -> Generator[MagicMock]:
+    """Mock service discovery."""
+    with patch(
+        "homeassistant.components.iron_os.config_flow.async_discovered_service_info",
+        return_value=[PINECIL_SERVICE_INFO, UNKNOWN_SERVICE_INFO],
+    ) as discovery:
+        yield discovery
+
+
+@pytest.fixture(name="config_entry")
+def mock_config_entry() -> MockConfigEntry:
+    """Mock Pinecil configuration entry."""
+    return MockConfigEntry(
+        domain=DOMAIN,
+        title=DEFAULT_NAME,
+        data={},
+        unique_id="c0:ff:ee:c0:ff:ee",
+        entry_id="1234567890",
+    )
+
+
+@pytest.fixture(name="ble_device")
+def mock_ble_device() -> Generator[MagicMock]:
+    """Mock BLEDevice."""
+    with patch(
+        "homeassistant.components.bluetooth.async_ble_device_from_address",
+        return_value=BLEDevice(
+            address="c0:ff:ee:c0:ff:ee", name=DEFAULT_NAME, rssi=-50, details={}
+        ),
+    ) as ble_device:
+        yield ble_device
+
+
+@pytest.fixture
+def mock_pynecil() -> Generator[AsyncMock, None, None]:
+    """Mock Pynecil library."""
+    with patch(
+        "homeassistant.components.iron_os.Pynecil", autospec=True
+    ) as mock_client:
+        client = mock_client.return_value
+
+        client.get_device_info.return_value = DeviceInfoResponse(
+            build="v2.22",
+            device_id="c0ffeeC0",
+            address="c0:ff:ee:c0:ff:ee",
+            device_sn="0000c0ffeec0ffee",
+            name=DEFAULT_NAME,
+        )
+        client.get_live_data.return_value = LiveDataResponse(
+            live_temp=298,
+            setpoint_temp=300,
+            dc_voltage=20.6,
+            handle_temp=36.3,
+            pwm_level=41,
+            power_src=PowerSource.PD,
+            tip_resistance=6.2,
+            uptime=1671,
+            movement_time=10000,
+            max_tip_temp_ability=460,
+            tip_voltage=2212,
+            hall_sensor=0,
+            operating_mode=OperatingMode.SOLDERING,
+            estimated_power=24.8,
+        )
+        yield client
diff --git a/tests/components/iron_os/snapshots/test_sensor.ambr b/tests/components/iron_os/snapshots/test_sensor.ambr
new file mode 100644
index 00000000000..64cb951dacc
--- /dev/null
+++ b/tests/components/iron_os/snapshots/test_sensor.ambr
@@ -0,0 +1,683 @@
+# serializer version: 1
+# name: test_sensors[sensor.pinecil_dc_input_voltage-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'config_entry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
+    'entity_id': 'sensor.pinecil_dc_input_voltage',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+    }),
+    'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
+    'original_icon': None,
+    'original_name': 'DC input voltage',
+    'platform': 'iron_os',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': <PinecilSensor.DC_VOLTAGE: 'voltage'>,
+    'unique_id': 'c0:ff:ee:c0:ff:ee_voltage',
+    'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
+  })
+# ---
+# name: test_sensors[sensor.pinecil_dc_input_voltage-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'device_class': 'voltage',
+      'friendly_name': 'Pinecil DC input voltage',
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+      'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.pinecil_dc_input_voltage',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '20.6',
+  })
+# ---
+# name: test_sensors[sensor.pinecil_estimated_power-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'config_entry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': None,
+    'entity_id': 'sensor.pinecil_estimated_power',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+    }),
+    'original_device_class': <SensorDeviceClass.POWER: 'power'>,
+    'original_icon': None,
+    'original_name': 'Estimated power',
+    'platform': 'iron_os',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': <PinecilSensor.ESTIMATED_POWER: 'estimated_power'>,
+    'unique_id': 'c0:ff:ee:c0:ff:ee_estimated_power',
+    'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
+  })
+# ---
+# name: test_sensors[sensor.pinecil_estimated_power-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'device_class': 'power',
+      'friendly_name': 'Pinecil Estimated power',
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+      'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.pinecil_estimated_power',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '24.8',
+  })
+# ---
+# name: test_sensors[sensor.pinecil_hall_effect_strength-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'config_entry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
+    'entity_id': 'sensor.pinecil_hall_effect_strength',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+    }),
+    'original_device_class': None,
+    'original_icon': None,
+    'original_name': 'Hall effect strength',
+    'platform': 'iron_os',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': <PinecilSensor.HALL_SENSOR: 'hall_sensor'>,
+    'unique_id': 'c0:ff:ee:c0:ff:ee_hall_sensor',
+    'unit_of_measurement': None,
+  })
+# ---
+# name: test_sensors[sensor.pinecil_hall_effect_strength-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'friendly_name': 'Pinecil Hall effect strength',
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.pinecil_hall_effect_strength',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '0',
+  })
+# ---
+# name: test_sensors[sensor.pinecil_handle_temperature-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'config_entry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': None,
+    'entity_id': 'sensor.pinecil_handle_temperature',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+    }),
+    'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
+    'original_icon': None,
+    'original_name': 'Handle temperature',
+    'platform': 'iron_os',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': <PinecilSensor.HANDLETEMP: 'handle_temperature'>,
+    'unique_id': 'c0:ff:ee:c0:ff:ee_handle_temperature',
+    'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
+  })
+# ---
+# name: test_sensors[sensor.pinecil_handle_temperature-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'device_class': 'temperature',
+      'friendly_name': 'Pinecil Handle temperature',
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+      'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.pinecil_handle_temperature',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '36.3',
+  })
+# ---
+# name: test_sensors[sensor.pinecil_last_movement_time-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'config_entry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
+    'entity_id': 'sensor.pinecil_last_movement_time',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+    }),
+    'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
+    'original_icon': None,
+    'original_name': 'Last movement time',
+    'platform': 'iron_os',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': <PinecilSensor.MOVEMENT_TIME: 'movement_time'>,
+    'unique_id': 'c0:ff:ee:c0:ff:ee_movement_time',
+    'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
+  })
+# ---
+# name: test_sensors[sensor.pinecil_last_movement_time-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'device_class': 'duration',
+      'friendly_name': 'Pinecil Last movement time',
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+      'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.pinecil_last_movement_time',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '10000',
+  })
+# ---
+# name: test_sensors[sensor.pinecil_max_tip_temperature-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': None,
+    'config_entry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
+    'entity_id': 'sensor.pinecil_max_tip_temperature',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+    }),
+    'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
+    'original_icon': None,
+    'original_name': 'Max tip temperature',
+    'platform': 'iron_os',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': <PinecilSensor.MAX_TIP_TEMP_ABILITY: 'max_tip_temp_ability'>,
+    'unique_id': 'c0:ff:ee:c0:ff:ee_max_tip_temp_ability',
+    'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
+  })
+# ---
+# name: test_sensors[sensor.pinecil_max_tip_temperature-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'device_class': 'temperature',
+      'friendly_name': 'Pinecil Max tip temperature',
+      'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.pinecil_max_tip_temperature',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '460',
+  })
+# ---
+# name: test_sensors[sensor.pinecil_operating_mode-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'options': list([
+        'idle',
+        'soldering',
+        'boost',
+        'sleeping',
+        'settings',
+        'debug',
+      ]),
+    }),
+    'config_entry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': None,
+    'entity_id': 'sensor.pinecil_operating_mode',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+    }),
+    'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
+    'original_icon': None,
+    'original_name': 'Operating mode',
+    'platform': 'iron_os',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': <PinecilSensor.OPERATING_MODE: 'operating_mode'>,
+    'unique_id': 'c0:ff:ee:c0:ff:ee_operating_mode',
+    'unit_of_measurement': None,
+  })
+# ---
+# name: test_sensors[sensor.pinecil_operating_mode-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'device_class': 'enum',
+      'friendly_name': 'Pinecil Operating mode',
+      'options': list([
+        'idle',
+        'soldering',
+        'boost',
+        'sleeping',
+        'settings',
+        'debug',
+      ]),
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.pinecil_operating_mode',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': 'soldering',
+  })
+# ---
+# name: test_sensors[sensor.pinecil_power_level-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'config_entry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
+    'entity_id': 'sensor.pinecil_power_level',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+      'sensor': dict({
+        'suggested_display_precision': 0,
+      }),
+    }),
+    'original_device_class': <SensorDeviceClass.POWER_FACTOR: 'power_factor'>,
+    'original_icon': None,
+    'original_name': 'Power level',
+    'platform': 'iron_os',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': <PinecilSensor.PWMLEVEL: 'power_pwm_level'>,
+    'unique_id': 'c0:ff:ee:c0:ff:ee_power_pwm_level',
+    'unit_of_measurement': '%',
+  })
+# ---
+# name: test_sensors[sensor.pinecil_power_level-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'device_class': 'power_factor',
+      'friendly_name': 'Pinecil Power level',
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+      'unit_of_measurement': '%',
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.pinecil_power_level',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '41',
+  })
+# ---
+# name: test_sensors[sensor.pinecil_power_source-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'options': list([
+        'dc',
+        'qc',
+        'pd_vbus',
+        'pd',
+      ]),
+    }),
+    'config_entry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
+    'entity_id': 'sensor.pinecil_power_source',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+    }),
+    'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
+    'original_icon': None,
+    'original_name': 'Power source',
+    'platform': 'iron_os',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': <PinecilSensor.POWER_SRC: 'power_source'>,
+    'unique_id': 'c0:ff:ee:c0:ff:ee_power_source',
+    'unit_of_measurement': None,
+  })
+# ---
+# name: test_sensors[sensor.pinecil_power_source-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'device_class': 'enum',
+      'friendly_name': 'Pinecil Power source',
+      'options': list([
+        'dc',
+        'qc',
+        'pd_vbus',
+        'pd',
+      ]),
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.pinecil_power_source',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': 'pd',
+  })
+# ---
+# name: test_sensors[sensor.pinecil_raw_tip_voltage-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'config_entry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
+    'entity_id': 'sensor.pinecil_raw_tip_voltage',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+      'sensor': dict({
+        'suggested_display_precision': 3,
+      }),
+    }),
+    'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
+    'original_icon': None,
+    'original_name': 'Raw tip voltage',
+    'platform': 'iron_os',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': <PinecilSensor.TIP_VOLTAGE: 'tip_voltage'>,
+    'unique_id': 'c0:ff:ee:c0:ff:ee_tip_voltage',
+    'unit_of_measurement': <UnitOfElectricPotential.MILLIVOLT: 'mV'>,
+  })
+# ---
+# name: test_sensors[sensor.pinecil_raw_tip_voltage-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'device_class': 'voltage',
+      'friendly_name': 'Pinecil Raw tip voltage',
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+      'unit_of_measurement': <UnitOfElectricPotential.MILLIVOLT: 'mV'>,
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.pinecil_raw_tip_voltage',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '2212',
+  })
+# ---
+# name: test_sensors[sensor.pinecil_tip_resistance-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': None,
+    'config_entry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
+    'entity_id': 'sensor.pinecil_tip_resistance',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+    }),
+    'original_device_class': None,
+    'original_icon': None,
+    'original_name': 'Tip resistance',
+    'platform': 'iron_os',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': <PinecilSensor.TIP_RESISTANCE: 'tip_resistance'>,
+    'unique_id': 'c0:ff:ee:c0:ff:ee_tip_resistance',
+    'unit_of_measurement': 'Ω',
+  })
+# ---
+# name: test_sensors[sensor.pinecil_tip_resistance-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'friendly_name': 'Pinecil Tip resistance',
+      'unit_of_measurement': 'Ω',
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.pinecil_tip_resistance',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '6.2',
+  })
+# ---
+# name: test_sensors[sensor.pinecil_tip_temperature-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'config_entry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': None,
+    'entity_id': 'sensor.pinecil_tip_temperature',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+    }),
+    'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
+    'original_icon': None,
+    'original_name': 'Tip temperature',
+    'platform': 'iron_os',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': <PinecilSensor.LIVE_TEMP: 'live_temperature'>,
+    'unique_id': 'c0:ff:ee:c0:ff:ee_live_temperature',
+    'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
+  })
+# ---
+# name: test_sensors[sensor.pinecil_tip_temperature-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'device_class': 'temperature',
+      'friendly_name': 'Pinecil Tip temperature',
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+      'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.pinecil_tip_temperature',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '298',
+  })
+# ---
+# name: test_sensors[sensor.pinecil_uptime-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
+    }),
+    'config_entry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
+    'entity_id': 'sensor.pinecil_uptime',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+    }),
+    'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
+    'original_icon': None,
+    'original_name': 'Uptime',
+    'platform': 'iron_os',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': <PinecilSensor.UPTIME: 'uptime'>,
+    'unique_id': 'c0:ff:ee:c0:ff:ee_uptime',
+    'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
+  })
+# ---
+# name: test_sensors[sensor.pinecil_uptime-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'device_class': 'duration',
+      'friendly_name': 'Pinecil Uptime',
+      'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
+      'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.pinecil_uptime',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '1671',
+  })
+# ---
diff --git a/tests/components/iron_os/test_config_flow.py b/tests/components/iron_os/test_config_flow.py
new file mode 100644
index 00000000000..231ec6cc3d6
--- /dev/null
+++ b/tests/components/iron_os/test_config_flow.py
@@ -0,0 +1,66 @@
+"""Tests for the Pinecil config flow."""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock
+
+from homeassistant.components.iron_os import DOMAIN
+from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER
+from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import FlowResultType
+
+from .conftest import DEFAULT_NAME, PINECIL_SERVICE_INFO, USER_INPUT
+
+
+async def test_form(
+    hass: HomeAssistant, mock_setup_entry: AsyncMock, discovery: MagicMock
+) -> None:
+    """Test the user config flow."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": SOURCE_USER}
+    )
+    assert result["type"] is FlowResultType.FORM
+
+    result = await hass.config_entries.flow.async_configure(
+        result["flow_id"],
+        USER_INPUT,
+    )
+
+    assert result["type"] is FlowResultType.CREATE_ENTRY
+    assert result["title"] == DEFAULT_NAME
+    assert result["data"] == {}
+    assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_no_device_discovered(
+    hass: HomeAssistant,
+    mock_setup_entry: AsyncMock,
+    discovery: MagicMock,
+) -> None:
+    """Test setup with no device discoveries."""
+    discovery.return_value = []
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN,
+        context={"source": SOURCE_USER},
+    )
+    assert result["type"] is FlowResultType.ABORT
+    assert result["reason"] == "no_devices_found"
+
+
+async def test_async_step_bluetooth(hass: HomeAssistant) -> None:
+    """Test discovery via bluetooth.."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN,
+        context={"source": SOURCE_BLUETOOTH},
+        data=PINECIL_SERVICE_INFO,
+    )
+    assert result["type"] is FlowResultType.FORM
+    assert result["step_id"] == "bluetooth_confirm"
+
+    result = await hass.config_entries.flow.async_configure(
+        result["flow_id"], user_input={}
+    )
+    assert result["type"] is FlowResultType.CREATE_ENTRY
+    assert result["title"] == DEFAULT_NAME
+    assert result["data"] == {}
+    assert result["result"].unique_id == "c0:ff:ee:c0:ff:ee"
diff --git a/tests/components/iron_os/test_sensor.py b/tests/components/iron_os/test_sensor.py
new file mode 100644
index 00000000000..0c35193e400
--- /dev/null
+++ b/tests/components/iron_os/test_sensor.py
@@ -0,0 +1,73 @@
+"""Tests for the Pinecil Sensors."""
+
+from collections.abc import AsyncGenerator
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from freezegun.api import FrozenDateTimeFactory
+from pynecil import CommunicationError
+import pytest
+from syrupy.assertion import SnapshotAssertion
+
+from homeassistant.components.iron_os.coordinator import SCAN_INTERVAL
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.const import STATE_UNAVAILABLE, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
+
+
+@pytest.fixture(autouse=True)
+async def sensor_only() -> AsyncGenerator[None, None]:
+    """Enable only the sensor platform."""
+    with patch(
+        "homeassistant.components.iron_os.PLATFORMS",
+        [Platform.SENSOR],
+    ):
+        yield
+
+
+@pytest.mark.usefixtures("entity_registry_enabled_by_default")
+async def test_sensors(
+    hass: HomeAssistant,
+    config_entry: MockConfigEntry,
+    snapshot: SnapshotAssertion,
+    entity_registry: er.EntityRegistry,
+    mock_pynecil: AsyncMock,
+    ble_device: MagicMock,
+) -> None:
+    """Test the Pinecil sensor platform."""
+    config_entry.add_to_hass(hass)
+    await hass.config_entries.async_setup(config_entry.entry_id)
+    await hass.async_block_till_done()
+
+    assert config_entry.state is ConfigEntryState.LOADED
+
+    await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
+
+
+@pytest.mark.usefixtures("entity_registry_enabled_by_default")
+async def test_sensors_unavailable(
+    hass: HomeAssistant,
+    config_entry: MockConfigEntry,
+    entity_registry: er.EntityRegistry,
+    mock_pynecil: AsyncMock,
+    ble_device: MagicMock,
+    freezer: FrozenDateTimeFactory,
+) -> None:
+    """Test the sensors when device disconnects."""
+    config_entry.add_to_hass(hass)
+    await hass.config_entries.async_setup(config_entry.entry_id)
+    await hass.async_block_till_done()
+
+    assert config_entry.state is ConfigEntryState.LOADED
+
+    mock_pynecil.get_live_data.side_effect = CommunicationError
+    freezer.tick(SCAN_INTERVAL)
+    async_fire_time_changed(hass)
+
+    entity_entries = er.async_entries_for_config_entry(
+        entity_registry, config_entry.entry_id
+    )
+    for entity_entry in entity_entries:
+        assert hass.states.get(entity_entry.entity_id).state == STATE_UNAVAILABLE