Skip to content

Commit a0f9a28

Browse files
authored
Merge branch 'bramstroker:master' into master
2 parents 8e13045 + 33c77ad commit a0f9a28

23 files changed

Lines changed: 1136 additions & 885 deletions

File tree

.github/workflows/release-drafter.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,5 @@ jobs:
3737
with:
3838
footer: ${{ steps.supporters.outputs.value }}
3939
config-name: release-drafter.yml
40-
41-
# Mark beta as prerelease and auto-increment beta.N
42-
prerelease: ${{ github.ref_name == 'beta' }}
43-
prerelease-identifier: beta
4440
env:
4541
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,5 @@ custom_components/test
1818

1919
# Visual Studio settings
2020
/.vs
21+
22+
.omx

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ Configured via `pyproject.toml`:
7575
- Test files: `test_*.py`, async functions: `async def test_*`
7676
- Fixtures in `conftest.py` files
7777
- Use `@pytest.mark.parametrize` for test variants
78+
- We strive to 100% test coverage in the project
7879

7980
## Pull Requests
8081

custom_components/powercalc/common.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import math
34
import re
45
from typing import NamedTuple
56

@@ -8,7 +9,6 @@
89
from homeassistant.core import HomeAssistant, split_entity_id
910
import homeassistant.helpers.device_registry as dr
1011
import homeassistant.helpers.entity_registry as er
11-
from homeassistant.helpers.template import is_number
1212
import voluptuous as vol
1313

1414
from .const import (
@@ -37,6 +37,15 @@ class SourceEntity(NamedTuple):
3737
device_entry: dr.DeviceEntry | None = None
3838

3939

40+
def is_number(value: str) -> bool:
41+
"""Return whether the value can be converted to a finite float."""
42+
try:
43+
fvalue = float(value)
44+
except (TypeError, ValueError):
45+
return False
46+
return math.isfinite(fvalue)
47+
48+
4049
async def create_source_entity(entity_id: str, hass: HomeAssistant) -> SourceEntity:
4150
"""Create object containing all information about the source entity."""
4251

@@ -162,6 +171,6 @@ def validate_name_pattern(value: str) -> str:
162171

163172
def validate_is_number(value: str) -> str:
164173
"""Validate value is a number."""
165-
if is_number(value): # type: ignore[no-untyped-call]
174+
if is_number(value):
166175
return value
167176
raise vol.Invalid("Value is not a number")

custom_components/powercalc/flow_helper/flows/library.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
CONF_SELF_USAGE_INCLUDED,
2222
CONF_SUB_PROFILE,
2323
CONF_VARIABLES,
24+
DUMMY_ENTITY_ID,
2425
LIBRARY_URL,
2526
CalculationStrategy,
2627
)
@@ -72,10 +73,12 @@ async def async_step_manufacturer(
7273
async def _create_schema() -> vol.Schema:
7374
"""Create manufacturer schema."""
7475
library = await ProfileLibrary.factory(self.flow.hass)
75-
device_types = DOMAIN_DEVICE_TYPE_MAPPING.get(self.flow.source_entity.domain, set()) if self.flow.source_entity else None
7676
manufacturers = [
7777
selector.SelectOptionDict(value=manufacturer[0], label=manufacturer[1])
78-
for manufacturer in await library.get_manufacturer_listing(device_types)
78+
for manufacturer in await library.get_manufacturer_listing(
79+
self._get_library_device_types(),
80+
self._get_library_discovery_by(),
81+
)
7982
]
8083
return vol.Schema(
8184
{
@@ -104,6 +107,11 @@ async def async_step_model(
104107
) -> FlowResult:
105108
"""Ask the user to select the model."""
106109

110+
def _build_model_label(model_id: str, model_name: str) -> str:
111+
if not model_name or model_name == model_id:
112+
return model_id
113+
return f"{model_id} ({model_name})"
114+
107115
async def _validate(user_input: dict[str, Any]) -> dict[str, str]:
108116
library = await ProfileLibrary.factory(self.flow.hass)
109117
profile = await library.get_profile(
@@ -123,8 +131,14 @@ async def _create_schema() -> vol.Schema:
123131
"""Create model schema."""
124132
manufacturer = str(self.flow.sensor_config.get(CONF_MANUFACTURER))
125133
library = await ProfileLibrary.factory(self.flow.hass)
126-
device_types = DOMAIN_DEVICE_TYPE_MAPPING.get(self.flow.source_entity.domain, set()) if self.flow.source_entity else None
127-
models = [selector.SelectOptionDict(value=model, label=model) for model in await library.get_model_listing(manufacturer, device_types)]
134+
models = [
135+
selector.SelectOptionDict(value=model_id, label=_build_model_label(model_id, model_name))
136+
for model_id, model_name in await library.get_model_listing(
137+
manufacturer,
138+
self._get_library_device_types(),
139+
self._get_library_discovery_by(),
140+
)
141+
]
128142
model = self.flow.selected_profile.model if self.flow.selected_profile else self.flow.sensor_config.get(CONF_MODEL)
129143
return vol.Schema(
130144
{
@@ -338,6 +352,22 @@ def _resolve_availability_entity(self) -> str | None:
338352
return entity
339353
return None
340354

355+
def _get_library_device_types(self) -> set[DeviceType] | None:
356+
"""Determine which device types should be shown in the library selectors."""
357+
if self._get_library_discovery_by() == DiscoveryBy.DEVICE:
358+
return None
359+
360+
if self.flow.source_entity:
361+
return DOMAIN_DEVICE_TYPE_MAPPING.get(self.flow.source_entity.domain, set())
362+
363+
return None # pragma: no cover
364+
365+
def _get_library_discovery_by(self) -> DiscoveryBy | None:
366+
"""Determine whether listing should be filtered by discovery mode."""
367+
if self.flow.source_entity and self.flow.source_entity.entity_id == DUMMY_ENTITY_ID:
368+
return DiscoveryBy.DEVICE
369+
return None
370+
341371

342372
class LibraryConfigFlow(LibraryFlow):
343373
def __init__(self, flow: PowercalcConfigFlow) -> None:

custom_components/powercalc/power_profile/library.py

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from .loader.local import LocalLoader
2323
from .loader.protocol import Loader
2424
from .loader.remote import RemoteLoader
25-
from .power_profile import DeviceType, PowerProfile
25+
from .power_profile import DeviceType, DiscoveryBy, PowerProfile
2626

2727
LEGACY_CUSTOM_DATA_DIRECTORY = "powercalc-custom-models"
2828
CUSTOM_DATA_DIRECTORY = "powercalc/profiles"
@@ -33,7 +33,7 @@ def __init__(self, hass: HomeAssistant, loader: Loader) -> None:
3333
self._hass = hass
3434
self._loader = loader
3535
self._profiles: dict[str, list[PowerProfile]] = {}
36-
self._manufacturer_models: dict[str, set[str]] = {}
36+
self._manufacturer_models: dict[str, set[tuple[str, str]]] = {}
3737
self._manufacturer_device_types: dict[str, list] = {}
3838

3939
async def initialize(self) -> None:
@@ -70,29 +70,39 @@ def create_loader(hass: HomeAssistant, skip_remote_loader: bool = False) -> Load
7070

7171
return CompositeLoader(loaders)
7272

73-
async def get_manufacturer_listing(self, device_types: set[DeviceType] | None = None) -> list[tuple[str, str]]:
73+
async def get_manufacturer_listing(
74+
self,
75+
device_types: set[DeviceType] | None = None,
76+
discovery_by: DiscoveryBy | None = None,
77+
) -> list[tuple[str, str]]:
7478
"""Get listing of available manufacturers."""
75-
manufacturers = await self._loader.get_manufacturer_listing(device_types)
79+
manufacturers = await self._loader.get_manufacturer_listing(device_types, discovery_by)
7680
return sorted(manufacturers)
7781

78-
async def get_model_listing(self, manufacturer: str, device_types: set[DeviceType] | None = None) -> list[str]:
79-
"""Get listing of available models for a given manufacturer."""
82+
async def get_model_listing(
83+
self,
84+
manufacturer: str,
85+
device_types: set[DeviceType] | None = None,
86+
discovery_by: DiscoveryBy | None = None,
87+
) -> list[tuple[str, str]]:
88+
"""Get listing of available models and display names for a given manufacturer."""
8089

8190
resolved_manufacturers = await self._loader.find_manufacturers(manufacturer)
8291
if not resolved_manufacturers:
8392
return []
84-
all_models: list[str] = []
93+
94+
all_models: list[tuple[str, str]] = []
8595
for manufacturer in resolved_manufacturers:
86-
cache_key = f"{manufacturer}/{device_types}"
96+
cache_key = f"{manufacturer}/{device_types}/{discovery_by}"
8797
cached_models = self._manufacturer_models.get(cache_key)
8898
if cached_models:
89-
all_models.extend(cached_models)
99+
all_models.extend(sorted(cached_models))
90100
continue
91-
models = await self._loader.get_model_listing(manufacturer, device_types)
101+
models = await self._loader.get_model_listing(manufacturer, device_types, discovery_by)
92102
self._manufacturer_models[cache_key] = models
93-
all_models.extend(models)
103+
all_models.extend(sorted(models))
94104

95-
return sorted(all_models)
105+
return sorted(all_models, key=lambda model: model[0])
96106

97107
async def get_profile(
98108
self,

custom_components/powercalc/power_profile/loader/composite.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22

33
from custom_components.powercalc.power_profile.loader.protocol import Loader
4-
from custom_components.powercalc.power_profile.power_profile import DeviceType
4+
from custom_components.powercalc.power_profile.power_profile import DeviceType, DiscoveryBy
55

66
_LOGGER = logging.getLogger(__name__)
77

@@ -13,10 +13,14 @@ def __init__(self, loaders: list[Loader]) -> None:
1313
async def initialize(self) -> None:
1414
[await loader.initialize() for loader in self.loaders] # type: ignore[func-returns-value]
1515

16-
async def get_manufacturer_listing(self, device_types: set[DeviceType] | None) -> set[tuple[str, str]]:
16+
async def get_manufacturer_listing(
17+
self,
18+
device_types: set[DeviceType] | None,
19+
discovery_by: DiscoveryBy | None = None,
20+
) -> set[tuple[str, str]]:
1721
"""Get listing of available manufacturers."""
1822

19-
return {manufacturer for loader in self.loaders for manufacturer in await loader.get_manufacturer_listing(device_types)}
23+
return {manufacturer for loader in self.loaders for manufacturer in await loader.get_manufacturer_listing(device_types, discovery_by)}
2024

2125
async def find_manufacturers(self, search: str) -> set[str]:
2226
"""Check if a manufacturer is available. Also must check aliases."""
@@ -30,10 +34,15 @@ async def find_manufacturers(self, search: str) -> set[str]:
3034

3135
return found_manufacturers
3236

33-
async def get_model_listing(self, manufacturer: str, device_types: set[DeviceType] | None) -> set[str]:
34-
"""Get listing of available models for a given manufacturer."""
37+
async def get_model_listing(
38+
self,
39+
manufacturer: str,
40+
device_types: set[DeviceType] | None,
41+
discovery_by: DiscoveryBy | None = None,
42+
) -> set[tuple[str, str]]:
43+
"""Get listing of available models and display names for a given manufacturer."""
3544

36-
return {model for loader in self.loaders for model in await loader.get_model_listing(manufacturer, device_types)}
45+
return {model for loader in self.loaders for model in await loader.get_model_listing(manufacturer, device_types, discovery_by)}
3746

3847
async def load_model(self, manufacturer: str, model: str) -> tuple[dict, str] | None:
3948
for loader in self.loaders:

custom_components/powercalc/power_profile/loader/local.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from custom_components.powercalc.power_profile.error import LibraryLoadingError
1010
from custom_components.powercalc.power_profile.loader.protocol import Loader
11-
from custom_components.powercalc.power_profile.power_profile import DeviceType, PowerProfile
11+
from custom_components.powercalc.power_profile.power_profile import DeviceType, DiscoveryBy, PowerProfile
1212

1313
_LOGGER = logging.getLogger(__name__)
1414

@@ -25,14 +25,24 @@ async def initialize(self) -> None:
2525
if not self._is_custom_directory:
2626
await self._hass.async_add_executor_job(self._load_custom_library)
2727

28-
async def get_manufacturer_listing(self, device_types: set[DeviceType] | None) -> set[tuple[str, str]]:
28+
async def get_manufacturer_listing(
29+
self,
30+
device_types: set[DeviceType] | None,
31+
discovery_by: DiscoveryBy | None = None,
32+
) -> set[tuple[str, str]]:
2933
"""Get listing of all available manufacturers or filtered by model device_type."""
3034
if device_types is None:
31-
return {(manufacturer, manufacturer) for manufacturer in self._manufacturer_model_listing}
35+
if discovery_by is None:
36+
return {(manufacturer, manufacturer) for manufacturer in self._manufacturer_model_listing}
37+
return {
38+
(manufacturer, manufacturer)
39+
for manufacturer, profiles in self._manufacturer_model_listing.items()
40+
if any(profile.discovery_by == discovery_by for profile in profiles.values())
41+
}
3242

3343
manufacturers: set[tuple[str, str]] = set()
3444
for manufacturer in self._manufacturer_model_listing:
35-
models = await self.get_model_listing(manufacturer, device_types)
45+
models = await self.get_model_listing(manufacturer, device_types, discovery_by)
3646
if not models:
3747
continue
3848
manufacturers.add((manufacturer, manufacturer))
@@ -49,25 +59,32 @@ async def find_manufacturers(self, search: str) -> set[str]:
4959

5060
return set()
5161

52-
async def get_model_listing(self, manufacturer: str, device_types: set[DeviceType] | None) -> set[str]:
62+
async def get_model_listing(
63+
self,
64+
manufacturer: str,
65+
device_types: set[DeviceType] | None,
66+
discovery_by: DiscoveryBy | None = None,
67+
) -> set[tuple[str, str]]:
5368
"""Get listing of available models for a given manufacturer.
5469
5570
param manufacturer: manufacturer always handled in lower case
5671
param device_type: models of the manufacturer will be filtered by DeviceType, models
5772
without assigned device_type will be handled as DeviceType.LIGHT.
5873
None will return all models of a manufacturer.
59-
returns: Set[str] of models
74+
returns: Set[tuple[str, str]] of (model_id, model_name)
6075
"""
6176

62-
found_models: set[str] = set()
77+
found_models: set[tuple[str, str]] = set()
6378
models = self._manufacturer_model_listing.get(manufacturer.lower())
6479
if not models:
6580
return found_models
6681

6782
for profile in models.values():
6883
if device_types and profile.device_type not in device_types:
6984
continue
70-
found_models.add(profile.model)
85+
if discovery_by and profile.discovery_by != discovery_by:
86+
continue
87+
found_models.add((profile.model, profile.name or profile.model))
7188

7289
return found_models
7390

custom_components/powercalc/power_profile/loader/protocol.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,29 @@
11
from typing import Protocol
22

3-
from custom_components.powercalc.power_profile.power_profile import DeviceType
3+
from custom_components.powercalc.power_profile.power_profile import DeviceType, DiscoveryBy
44

55

66
class Loader(Protocol):
77
async def initialize(self) -> None:
88
"""Initialize the loader."""
99

10-
async def get_manufacturer_listing(self, device_types: set[DeviceType] | None) -> set[tuple[str, str]]:
10+
async def get_manufacturer_listing(
11+
self,
12+
device_types: set[DeviceType] | None,
13+
discovery_by: DiscoveryBy | None = None,
14+
) -> set[tuple[str, str]]:
1115
"""Get listing of possible manufacturers."""
1216

1317
async def find_manufacturers(self, search: str) -> set[str]:
1418
"""Check if a manufacturer is available. Also must check aliases."""
1519

16-
async def get_model_listing(self, manufacturer: str, device_types: set[DeviceType] | None) -> set[str]:
17-
"""Get listing of available models for a given manufacturer."""
20+
async def get_model_listing(
21+
self,
22+
manufacturer: str,
23+
device_types: set[DeviceType] | None,
24+
discovery_by: DiscoveryBy | None = None,
25+
) -> set[tuple[str, str]]:
26+
"""Get listing of available models and display names for a given manufacturer."""
1827

1928
async def load_model(self, manufacturer: str, model: str) -> tuple[dict, str] | None:
2029
"""Load and optionally download a model profile."""

0 commit comments

Comments
 (0)