Add support for IronOS v2.23 (#139903)

Add support for IronOS 2.23
This commit is contained in:
Manu 2025-03-06 11:23:10 +01:00 committed by GitHub
parent 4f255439eb
commit f2b07ea886
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 273 additions and 11 deletions

View File

@ -8,6 +8,7 @@ from enum import Enum
import logging import logging
from typing import cast from typing import cast
from awesomeversion import AwesomeVersion
from pynecil import ( from pynecil import (
CharSetting, CharSetting,
CommunicationError, CommunicationError,
@ -34,6 +35,8 @@ SCAN_INTERVAL = timedelta(seconds=5)
SCAN_INTERVAL_GITHUB = timedelta(hours=3) SCAN_INTERVAL_GITHUB = timedelta(hours=3)
SCAN_INTERVAL_SETTINGS = timedelta(seconds=60) SCAN_INTERVAL_SETTINGS = timedelta(seconds=60)
V223 = AwesomeVersion("v2.23")
@dataclass @dataclass
class IronOSCoordinators: class IronOSCoordinators:
@ -72,6 +75,7 @@ class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
), ),
) )
self.device = device self.device = device
self.v223_features = False
async def _async_setup(self) -> None: async def _async_setup(self) -> None:
"""Set up the coordinator.""" """Set up the coordinator."""
@ -81,6 +85,8 @@ class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
except CommunicationError as e: except CommunicationError as e:
raise UpdateFailed("Cannot connect to device") from e raise UpdateFailed("Cannot connect to device") from e
self.v223_features = AwesomeVersion(self.device_info.build) >= V223
class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]): class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]):
"""IronOS coordinator.""" """IronOS coordinator."""

View File

@ -73,6 +73,9 @@
}, },
"power_limit": { "power_limit": {
"default": "mdi:flash-alert" "default": "mdi:flash-alert"
},
"hall_effect_sleep_time": {
"default": "mdi:timer-sand"
} }
}, },
"select": { "select": {
@ -105,6 +108,9 @@
}, },
"usb_pd_mode": { "usb_pd_mode": {
"default": "mdi:meter-electric-outline" "default": "mdi:meter-electric-outline"
},
"tip_type": {
"default": "mdi:pencil-outline"
} }
}, },
"sensor": { "sensor": {
@ -154,7 +160,16 @@
"soldering": "mdi:soldering-iron", "soldering": "mdi:soldering-iron",
"sleeping": "mdi:sleep", "sleeping": "mdi:sleep",
"settings": "mdi:menu-open", "settings": "mdi:menu-open",
"debug": "mdi:bug-play" "debug": "mdi:bug-play",
"soldering_profile": "mdi:chart-box-outline",
"temperature_adjust": "mdi:thermostat-box",
"usb_pd_debug": "mdi:bug-play",
"thermal_runaway": "mdi:fire-alert",
"startup_logo": "mdi:dots-circle",
"cjc_calibration": "mdi:tune-vertical",
"startup_warnings": "mdi:alert",
"initialisation_done": "mdi:check-circle",
"hibernating": "mdi:sleep"
} }
}, },
"estimated_power": { "estimated_power": {

View File

@ -65,6 +65,7 @@ class PinecilNumber(StrEnum):
VOLTAGE_DIV = "voltage_div" VOLTAGE_DIV = "voltage_div"
TEMP_INCREMENT_SHORT = "temp_increment_short" TEMP_INCREMENT_SHORT = "temp_increment_short"
TEMP_INCREMENT_LONG = "temp_increment_long" TEMP_INCREMENT_LONG = "temp_increment_long"
HALL_EFFECT_SLEEP_TIME = "hall_effect_sleep_time"
def multiply(value: float | None, multiplier: float) -> float | None: def multiply(value: float | None, multiplier: float) -> float | None:
@ -323,6 +324,23 @@ PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = (
), ),
) )
PINECIL_NUMBER_DESCRIPTIONS_V223: tuple[IronOSNumberEntityDescription, ...] = (
IronOSNumberEntityDescription(
key=PinecilNumber.HALL_EFFECT_SLEEP_TIME,
translation_key=PinecilNumber.HALL_EFFECT_SLEEP_TIME,
value_fn=(lambda _, settings: settings.get("hall_sleep_time")),
characteristic=CharSetting.HALL_SLEEP_TIME,
raw_value_fn=lambda value: value,
mode=NumberMode.BOX,
native_min_value=0,
native_max_value=60,
native_step=5,
entity_category=EntityCategory.CONFIG,
native_unit_of_measurement=UnitOfTime.SECONDS,
entity_registry_enabled_default=False,
),
)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -331,10 +349,13 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up number entities from a config entry.""" """Set up number entities from a config entry."""
coordinators = entry.runtime_data coordinators = entry.runtime_data
descriptions = PINECIL_NUMBER_DESCRIPTIONS
if coordinators.live_data.v223_features:
descriptions += PINECIL_NUMBER_DESCRIPTIONS_V223
async_add_entities( async_add_entities(
IronOSNumberEntity(coordinators, description) IronOSNumberEntity(coordinators, description) for description in descriptions
for description in PINECIL_NUMBER_DESCRIPTIONS
) )

View File

@ -17,6 +17,7 @@ from pynecil import (
ScrollSpeed, ScrollSpeed,
SettingsDataResponse, SettingsDataResponse,
TempUnit, TempUnit,
TipType,
USBPDMode, USBPDMode,
) )
@ -53,6 +54,7 @@ class PinecilSelect(StrEnum):
LOCKING_MODE = "locking_mode" LOCKING_MODE = "locking_mode"
LOGO_DURATION = "logo_duration" LOGO_DURATION = "logo_duration"
USB_PD_MODE = "usb_pd_mode" USB_PD_MODE = "usb_pd_mode"
TIP_TYPE = "tip_type"
def enum_to_str(enum: Enum | None) -> str | None: def enum_to_str(enum: Enum | None) -> str | None:
@ -138,6 +140,8 @@ PINECIL_SELECT_DESCRIPTIONS: tuple[IronOSSelectEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
), ),
)
PINECIL_SELECT_DESCRIPTIONS_V222: tuple[IronOSSelectEntityDescription, ...] = (
IronOSSelectEntityDescription( IronOSSelectEntityDescription(
key=PinecilSelect.USB_PD_MODE, key=PinecilSelect.USB_PD_MODE,
translation_key=PinecilSelect.USB_PD_MODE, translation_key=PinecilSelect.USB_PD_MODE,
@ -149,6 +153,27 @@ PINECIL_SELECT_DESCRIPTIONS: tuple[IronOSSelectEntityDescription, ...] = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
), ),
) )
PINECIL_SELECT_DESCRIPTIONS_V223: tuple[IronOSSelectEntityDescription, ...] = (
IronOSSelectEntityDescription(
key=PinecilSelect.USB_PD_MODE,
translation_key=PinecilSelect.USB_PD_MODE,
characteristic=CharSetting.USB_PD_MODE,
value_fn=lambda x: enum_to_str(x.get("usb_pd_mode")),
raw_value_fn=lambda value: USBPDMode[value.upper()],
options=[x.name.lower() for x in USBPDMode],
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
),
IronOSSelectEntityDescription(
key=PinecilSelect.TIP_TYPE,
translation_key=PinecilSelect.TIP_TYPE,
characteristic=CharSetting.TIP_TYPE,
value_fn=lambda x: enum_to_str(x.get("tip_type")),
raw_value_fn=lambda value: TipType[value.upper()],
options=[x.name.lower() for x in TipType],
entity_category=EntityCategory.CONFIG,
),
)
async def async_setup_entry( async def async_setup_entry(
@ -157,11 +182,17 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up select entities from a config entry.""" """Set up select entities from a config entry."""
coordinator = entry.runtime_data coordinators = entry.runtime_data
descriptions = PINECIL_SELECT_DESCRIPTIONS
descriptions += (
PINECIL_SELECT_DESCRIPTIONS_V223
if coordinators.live_data.v223_features
else PINECIL_SELECT_DESCRIPTIONS_V222
)
async_add_entities( async_add_entities(
IronOSSelectEntity(coordinator, description) IronOSSelectEntity(coordinators, description) for description in descriptions
for description in PINECIL_SELECT_DESCRIPTIONS
) )

View File

@ -94,6 +94,9 @@
}, },
"temp_increment_long": { "temp_increment_long": {
"name": "Long-press temperature step" "name": "Long-press temperature step"
},
"hall_effect_sleep_time": {
"name": "Hall sensor sleep timeout"
} }
}, },
"select": { "select": {
@ -173,6 +176,15 @@
"off": "[%key:common::state::off%]", "off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]" "on": "[%key:common::state::on%]"
} }
},
"tip_type": {
"name": "Soldering tip type",
"state": {
"auto": "Auto sense",
"ts100_long": "TS100 long/Hakko T12 tip",
"pine_short": "Pinecil short tip",
"pts200": "PTS200 short tip"
}
} }
}, },
"sensor": { "sensor": {
@ -223,7 +235,16 @@
"sleeping": "Sleeping", "sleeping": "Sleeping",
"settings": "Settings", "settings": "Settings",
"debug": "Debug", "debug": "Debug",
"boost": "Boost" "boost": "Boost",
"soldering_profile": "Soldering profile",
"temperature_adjust": "Temperature adjust",
"usb_pd_debug": "USB PD debug",
"thermal_runaway": "Thermal runaway",
"startup_logo": "Booting",
"cjc_calibration": "CJC calibration",
"startup_warnings": "Startup warnings",
"initialisation_done": "Initialisation done",
"hibernating": "Hibernating"
} }
}, },
"estimated_power": { "estimated_power": {

View File

@ -20,6 +20,7 @@ from pynecil import (
ScrollSpeed, ScrollSpeed,
SettingsDataResponse, SettingsDataResponse,
TempUnit, TempUnit,
TipType,
) )
import pytest import pytest
@ -164,7 +165,7 @@ def mock_pynecil() -> Generator[AsyncMock]:
client = mock_client.return_value client = mock_client.return_value
client.get_device_info.return_value = DeviceInfoResponse( client.get_device_info.return_value = DeviceInfoResponse(
build="v2.22", build="v2.23",
device_id="c0ffeeC0", device_id="c0ffeeC0",
address="c0:ff:ee:c0:ff:ee", address="c0:ff:ee:c0:ff:ee",
device_sn="0000c0ffeec0ffee", device_sn="0000c0ffeec0ffee",
@ -205,6 +206,8 @@ def mock_pynecil() -> Generator[AsyncMock]:
display_invert=True, display_invert=True,
calibrate_cjc=True, calibrate_cjc=True,
usb_pd_mode=True, usb_pd_mode=True,
hall_sleep_time=5,
tip_type=TipType.PINE_SHORT,
) )
client.get_live_data.return_value = LiveDataResponse( client.get_live_data.return_value = LiveDataResponse(
live_temp=298, live_temp=298,

View File

@ -6,7 +6,7 @@
}), }),
'device_info': dict({ 'device_info': dict({
'__type': "<class 'pynecil.types.DeviceInfoResponse'>", '__type': "<class 'pynecil.types.DeviceInfoResponse'>",
'repr': "DeviceInfoResponse(build='v2.22', device_id='c0ffeeC0', address='c0:ff:ee:c0:ff:ee', device_sn='0000c0ffeec0ffee', name='Pinecil-C0FFEEE', is_synced=False)", 'repr': "DeviceInfoResponse(build='v2.23', device_id='c0ffeeC0', address='c0:ff:ee:c0:ff:ee', device_sn='0000c0ffeec0ffee', name='Pinecil-C0FFEEE', is_synced=False)",
}), }),
'live_data': dict({ 'live_data': dict({
'__type': "<class 'pynecil.types.LiveDataResponse'>", '__type': "<class 'pynecil.types.LiveDataResponse'>",

View File

@ -226,6 +226,63 @@
'state': '7', 'state': '7',
}) })
# --- # ---
# name: test_state[number.pinecil_hall_sensor_sleep_timeout-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 60,
'min': 0,
'mode': <NumberMode.BOX: 'box'>,
'step': 5,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.pinecil_hall_sensor_sleep_timeout',
'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 sensor sleep timeout',
'platform': 'iron_os',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': <PinecilNumber.HALL_EFFECT_SLEEP_TIME: 'hall_effect_sleep_time'>,
'unique_id': 'c0:ff:ee:c0:ff:ee_hall_effect_sleep_time',
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
})
# ---
# name: test_state[number.pinecil_hall_sensor_sleep_timeout-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Pinecil Hall sensor sleep timeout',
'max': 60,
'min': 0,
'mode': <NumberMode.BOX: 'box'>,
'step': 5,
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
}),
'context': <ANY>,
'entity_id': 'number.pinecil_hall_sensor_sleep_timeout',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5',
})
# ---
# name: test_state[number.pinecil_keep_awake_pulse_delay-entry] # name: test_state[number.pinecil_keep_awake_pulse_delay-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': set({ 'aliases': set({

View File

@ -250,6 +250,7 @@
'options': list([ 'options': list([
'off', 'off',
'on', 'on',
'safe',
]), ]),
}), }),
'config_entry_id': <ANY>, 'config_entry_id': <ANY>,
@ -287,6 +288,7 @@
'options': list([ 'options': list([
'off', 'off',
'on', 'on',
'safe',
]), ]),
}), }),
'context': <ANY>, 'context': <ANY>,
@ -415,6 +417,66 @@
'state': 'fast', 'state': 'fast',
}) })
# --- # ---
# name: test_state[select.pinecil_soldering_tip_type-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'auto',
'ts100_long',
'pine_short',
'pts200',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.pinecil_soldering_tip_type',
'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': 'Soldering tip type',
'platform': 'iron_os',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': <PinecilSelect.TIP_TYPE: 'tip_type'>,
'unique_id': 'c0:ff:ee:c0:ff:ee_tip_type',
'unit_of_measurement': None,
})
# ---
# name: test_state[select.pinecil_soldering_tip_type-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Pinecil Soldering tip type',
'options': list([
'auto',
'ts100_long',
'pine_short',
'pts200',
]),
}),
'context': <ANY>,
'entity_id': 'select.pinecil_soldering_tip_type',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'pine_short',
})
# ---
# name: test_state[select.pinecil_start_up_behavior-entry] # name: test_state[select.pinecil_start_up_behavior-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': set({ 'aliases': set({

View File

@ -45,7 +45,7 @@
'entity_picture': 'https://brands.home-assistant.io/_/iron_os/icon.png', 'entity_picture': 'https://brands.home-assistant.io/_/iron_os/icon.png',
'friendly_name': 'Pinecil Firmware', 'friendly_name': 'Pinecil Firmware',
'in_progress': False, 'in_progress': False,
'installed_version': 'v2.22', 'installed_version': 'v2.23',
'latest_version': 'v2.22', 'latest_version': 'v2.22',
'release_summary': None, 'release_summary': None,
'release_url': 'https://github.com/Ralim/IronOS/releases/tag/v2.22', 'release_url': 'https://github.com/Ralim/IronOS/releases/tag/v2.22',

View File

@ -4,13 +4,15 @@ from datetime import timedelta
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
from pynecil import CommunicationError from pynecil import CommunicationError, DeviceInfoResponse
import pytest import pytest
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNKNOWN from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .conftest import DEFAULT_NAME
from tests.common import MockConfigEntry, async_fire_time_changed from tests.common import MockConfigEntry, async_fire_time_changed
@ -89,3 +91,35 @@ async def test_settings_exception(
assert (state := hass.states.get("number.pinecil_boost_temperature")) assert (state := hass.states.get("number.pinecil_boost_temperature"))
assert state.state == STATE_UNKNOWN assert state.state == STATE_UNKNOWN
@pytest.mark.usefixtures(
"entity_registry_enabled_by_default", "mock_pynecil", "ble_device"
)
async def test_v223_entities_not_loaded(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_pynecil: AsyncMock,
) -> None:
"""Test the new entities in IronOS v2.23 are not loaded on smaller versions."""
mock_pynecil.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,
)
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
assert hass.states.get("number.pinecil_hall_sensor_sleep_timeout") is None
assert hass.states.get("select.pinecil_soldering_tip_type") is None
assert (
state := hass.states.get("select.pinecil_power_delivery_3_1_epr")
) is not None
assert len(state.attributes["options"]) == 2

View File

@ -138,6 +138,12 @@ async def test_state(
("number.pinecil_sleep_temperature", CharSetting.SLEEP_TEMP, 150, 150), ("number.pinecil_sleep_temperature", CharSetting.SLEEP_TEMP, 150, 150),
("number.pinecil_sleep_timeout", CharSetting.SLEEP_TIMEOUT, 5, 5), ("number.pinecil_sleep_timeout", CharSetting.SLEEP_TIMEOUT, 5, 5),
("number.pinecil_voltage_divider", CharSetting.VOLTAGE_DIV, 600, 600), ("number.pinecil_voltage_divider", CharSetting.VOLTAGE_DIV, 600, 600),
(
"number.pinecil_hall_sensor_sleep_timeout",
CharSetting.HALL_SLEEP_TIME,
60,
60,
),
], ],
) )
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") @pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device")

View File

@ -16,6 +16,7 @@ from pynecil import (
ScreenOrientationMode, ScreenOrientationMode,
ScrollSpeed, ScrollSpeed,
TempUnit, TempUnit,
TipType,
USBPDMode, USBPDMode,
) )
import pytest import pytest
@ -111,6 +112,11 @@ async def test_state(
"on", "on",
(CharSetting.USB_PD_MODE, USBPDMode.ON), (CharSetting.USB_PD_MODE, USBPDMode.ON),
), ),
(
"select.pinecil_soldering_tip_type",
"auto",
(CharSetting.TIP_TYPE, TipType.AUTO),
),
], ],
) )
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") @pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device")