Skip to content

perf: server-side aggregate sensors and client-side optimizations#152

Merged
Julien-Decoen merged 9 commits into
Thank-you-Linus:mainfrom
flatline-84:performance-improvements
Jul 16, 2026
Merged

perf: server-side aggregate sensors and client-side optimizations#152
Julien-Decoen merged 9 commits into
Thank-you-Linus:mainfrom
flatline-84:performance-improvements

Conversation

@flatline-84

@flatline-84 flatline-84 commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Linus Dashboard is great but I find that my tablet struggles with it. Part of it is all my devices, part of it is the tablet is not very good. Hoping to make LD a little quicker. Also did some dev changes since it was hard to get this repo initialized and ready to work in.

  • New sensor platform creates server-side aggregate sensors per domain/floor, reducing home view template evaluations from ~60 to ~3
  • Fix several client-side bottlenecks: O(n²) sorts, sequential area card creation, redundant template computations, and forced per-minute re-evaluations

Changes

Server-side aggregate sensors

  • aggregate.py: constants and pure computation functions (active states, icons, colors)
  • sensor.py: diagnostic sensors, event-driven with 100ms debounce, hidden from default UI
  • Sensors cover global and floor scope; area scope stays client-side

Frontend

  • AggregateChip uses server-side sensor when available (state_attr(...) instead of inlining 100 entity IDs), falls back gracefully if sensor doesn't exist
  • Removed now() from getLastChangedTemplate — was forcing all relative_time() cards to re-evaluate every minute
  • Parallelized HomeView area card creation with Promise.all()
  • Fixed O(n² log n) sort — replaced indexOf() in comparator with pre-built Map
  • Replaced exposedDomainIds.some(...) inner-loop calls with a pre-computed Set
  • Replaced Object.keys(DEVICE_CLASSES).includes() with a module-level Set
  • Added explicit entity_id arrays to AggregateChip and UnavailableChip for faster Mushroom subscriptions

Performance

Metric Before After
Template evaluations per state change ~60 ~3
Jinja2 template size per chip ~2–10KB ~80 bytes
WebSocket subscriptions ~2000 ~20

Test plan

Uh there was no real test plan. I put it in my personal HA with ~2000 entities / ~135 devices and it seemed a little faster? Probably a bit of placebo but I expect this to be a first pass for performance improves

Comment thread .devcontainer/devcontainer.json Outdated
"type": "bind"
}
],
// "mounts": [

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what this folder is for?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. That's a mount for sapiens, a project I use to help my AI agents develop, unrelated to Linus Dashboard.

Comment thread config/configuration.yaml
# Composants essentiels (remplace default_config sans go2rtc)
automation: !include automations.yaml
script: !include scripts.yaml
# automation: !include automations.yaml

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Errors booting HA up if these are enabled, since the files don't exist

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you launching the test instance through the devcontainer setup? Trying to figure out if this is a gap in the devcontainer bootstrap (should probably generate/include empty automations.yaml/scripts.yaml) or something else on your end.

Comment thread Makefile
# Start Home Assistant
dev:
@echo "📄 Loading environment..."
@if [ ! -e config/custom_components ]; then \

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This allows LD to be present as an integration in the new HA setup

@s4piens

s4piens commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Hey @flatline-84 , sorry for the late review, and thanks for this ! 💚

You're our first outside contributor so it's an honor to review your MR, and it tackles a real perf problem which makes it even more relevant.

I'm more reactive on Discord if you want to discuss features or anything related, feel free to ping me there.

You put your finger on a real problem. I've always tried to minimize the complexity to keep things light, but you're right that with a lot of entities it gets slow to load... Going through dedicated sensors like you did is a really good approach.

I tested the branch on our HA test instance and ran into something that looks like a bug: on a cold restart, all the aggregate sensors came up empty (0 entities), but reloading the integration afterward found everything correctly. I still need to investigate more on my end to confirm it's not just something specific to my test setup. If it does turn out to be a race condition (entity discovery running before other integrations have posted their state). Feel free to take a look either way.

One question: device_class_filter is always None in _build_aggregate_sensors, so device_class-scoped chips never get a server sensor and fall back to the old path. Intentional for this first pass?

I just merged the Magic Areas PR so this branch is behind now, probably conflicts — can you rebase when you get a chance?

Thanks again, looking forward to getting this in. ✨

flatline-84 and others added 4 commits July 16, 2026 16:44
…e state

The aggregate sensors only tracked entities whose live state already
existed in the state machine at platform-setup time. On a cold boot the
sensor platform is forwarded before other integrations have posted their
states, so entities were skipped and the tracked set (frozen at
construction) never recovered until a reload. Depending on timing this
produced fully empty aggregates or partially-populated ones that never
updated when the untracked entities changed (most visibly the floor
light chips).

Resolve device_class from the entity registry, which is fully populated
and persistent early in startup, and drop the hass.states gate. Live
states are still read in _update_state at compute time, so counts remain
correct as states arrive.
The build was hardcoding device_class_filter=None, so the only sensors
created were domain-level. But the frontend (AggregateChip.getAggregateSensorId)
still appends device_class to the id it looks up, and compute_color for
binary_sensor keys its color map on device_class. As a result every
device_class-scoped chip failed the lookup and silently fell back to the
slower client-side template path, losing the perf win for exactly the
high-entity-count domains (motion/door/etc.).

Emit both tiers: an entity always joins its domain-level bucket and,
when it has a device_class, an additional (domain, device_class) bucket.
This produces sensor.linus_dashboard_<domain>_active for plain chips and
sensor.linus_dashboard_<domain>_<device_class>[_<floor>]_active for
device_class chips, matching the ids the frontend constructs.
@flatline-84

Copy link
Copy Markdown
Contributor Author

Hey @s4piens,

Out of all the dashboards I've tried, this is the one that got closest to what I wanted for how little setup I wanted to do 😆

I've updated and fixed (I think) the problems you've highlighted. I also had an issue (same problem?) with the count of entities being off sometimes and I think it's fixed. Unfortunately, pretty hard to test so I usually end up deploying to the HA that runs my house and I receive feedback (complaints) from my wife.

I tested the branch on our HA test instance and ran into something that looks like a bug: on a cold restart, all the aggregate sensors came up empty (0 entities), but reloading the integration afterward found everything correctly. I still need to investigate more on my end to confirm it's not just something specific to my test setup. If it does turn out to be a race condition (entity discovery running before other integrations have posted their state). Feel free to take a look either way.

Yeah this was because on cold-boot not all of the entities are ready, so it freezes the set before it has a chance to get everything. This should be fixed

One question: device_class_filter is always None in _build_aggregate_sensors, so device_class-scoped chips never get a server sensor and fall back to the old path. Intentional for this first pass?

Should be fixed as well. This is a repo (and PR) that's very heavily developed with AI but going forward I'm going to try to understand the codebase better and put more directed fixes in (or features). Just wanted to get a small(-ish) PR up to understand how it's all setup and make sure I can dev properly. PR #159 looks handy!

I just merged the Magic Areas PR so this branch is behind now, probably conflicts — can you rebase when you get a chance?

Done.

Let me know if anything is incorrect - I don't use Discord often but maybe we can chat and work on features / improvements in the future!

root added 2 commits July 16, 2026 21:44
Resolves conflicts in .devcontainer/devcontainer.json and README.md
against the fake-house dev-env automation merged to main — took main's
side for both (fake-house-watch autostart, corrected dev-setup docs),
nothing from performance-improvements' own changes to those two files
was lost since it only touched the surrounding dev-setup text, not the
performance work itself.
@Julien-Decoen
Julien-Decoen merged commit 13dd7ed into Thank-you-Linus:main Jul 16, 2026
3 of 4 checks passed
@Julien-Decoen

Julien-Decoen commented Jul 16, 2026

Copy link
Copy Markdown
Member

Hey @flatline-84,

Really glad this is your first contribution — and love that your wife is doing QA on the live deployment 😄 That's about as real-world testing as it gets.

The server-side aggregate sensor approach was exactly the right call — cutting home view template evaluations from ~60 to ~3 is a big deal, and it's basically what kicked off the whole performance push the project has had since. I'll probably revisit parts of it to make it a bit more general-purpose down the line, but that's building on what you opened up here, not replacing it.

Glad the cold-boot race and the device_class_filter thing are sorted — merged! 🎉

Feel free to drop by Discord if you want to bounce around feature ideas, always happy to have you keep contributing.

Julien-Decoen pushed a commit that referenced this pull request Jul 16, 2026
Brings in PR #152 (server-side aggregate sensors + Magic Areas
reintegration) and PR #159 (fake-house dev-env automation). Real
conflicts in sensor.py, AggregateChip.ts and entityResolver.ts —
resolved by keeping PR #152's full DOMAIN_ACTIVE_STATES + device_class
bucket coverage in _build_aggregate_sensors (needed for
AggregateChip's floor/global fast path across every domain) rather
than restricting it to climate/media_player as originally planned;
that restriction assumed every other domain's hidden counting sensor
was fully superseded by this branch's own dedicated group entities,
but AggregateChip.ts doesn't know about those yet, so dropping the
generic sensors would have silently regressed PR #152's just-merged
perf work for floor/global-scope chips. Left a note in the module
docstring — de-duplicating the two systems (AggregateChip preferring
the dedicated group entity when one exists) is a follow-up, not part
of this change.

entityResolver.ts: kept this branch's removal of the Linus Brain
branch for presence/all_lights (superseded by native Dashboard
entities, no backward compat per plan) while preserving main's Magic
Areas fallback branches intact.
Julien-Decoen pushed a commit that referenced this pull request Jul 17, 2026
… entities

AggregateChip.ts's floor/global fast path (PR #152) and this session's
dedicated group entities (light.py/switch.py/fan.py/cover.py/siren.py/
binary_sensor.py) were computing the same total/active_entity_ids/icon/
color twice for light, switch, fan, cover and most binary_sensor
device_classes — the chip never knew the dedicated entity existed, so
it kept reading the generic hidden counting sensor even when a richer,
controllable equivalent was sitting right next to it.

- AggregateChip.ts: getAggregateSensorId() now tries the dedicated
  group entity first (light.linus_dashboard_all_lights_*, etc., or
  binary_sensor.linus_dashboard_{device_class}_* for classes that have
  one), only falling back to the generic sensor for climate/
  media_player (no dedicated group — no simple on/off control
  semantics) and binary_sensor classes that don't get their own group
  (motion/presence/occupancy, folded into the presence composite
  instead). The two sources report their count differently (the
  generic sensor's own state IS the count; a group entity's state is
  on/off, count is in active_entity_ids), so the content template
  branches on which one was found.
- sensor.py: _build_aggregate_sensors now only generates the
  domain-level (no device_class) bucket for GENERIC_DOMAIN_LEVEL_DOMAINS
  (climate, media_player, binary_sensor — binary_sensor has no "every
  class regardless of type" dedicated group). Device_class buckets stay
  unrestricted for every domain, since they're still the only source
  for combinations without a dedicated group (cover-by-type, binary_
  sensor's presence-related classes). Also renamed the sensor's
  `entity_ids` attribute to `entity_id` (ATTR_ENTITY_ID) for consistency
  with every other group-shaped entity in this integration.
- __init__.py: added async_cleanup_orphaned_aggregate_sensors, called
  before platform setup — anyone already running the old (still-)
  restricted set would otherwise keep 11 permanently-unavailable
  sensors (light/switch/fan/cover's domain-level bucket) in the
  registry forever, same footgun as the Linus Brain cleanup.

Verified live against the ha-test fake house: hidden sensor count went
from 33 to 22 (exactly the 11 light/switch/fan/cover domain-level
entries removed, climate/media_player/binary_sensor untouched), the
cleanup routine logged and removed all 11 orphans on restart with no
errors, and all 39 dedicated group entities are unaffected.
Julien-Decoen pushed a commit that referenced this pull request Jul 19, 2026
… computation

Goes further than the previous group.util-based refactor: light.py,
switch.py, fan.py, cover.py, and binary_sensor.py's per-device_class groups
now directly inherit HA core's own homeassistant.components.group.<domain>
class (e.g. group.light.LightGroup) alongside NestedGroupMixin, delegating
async_update_group_state() entirely to HA instead of maintaining our own
copy of that computation. Same class Magic Areas builds its own groups on.

NestedGroupMixin gained _sync_ha_group_state(domain, device_class=None) to
drive this: keeps self._entity_ids (HA's own classes read this name, not
our _member_entity_ids) in sync, calls async_update_supported_features per
member for the platforms that track features incrementally (fan/cover),
calls the inherited async_update_group_state(), then layers our own
entity_id/total/active_entity_ids/icon/color on top via
compute_group_attributes. async_added_to_hass no longer calls super() —
GroupEntity's own version (further up these classes' MRO) sets up an
*undebounced* subscription, which would defeat NestedGroupMixin's own
debounce (added for PR #152's "flatline" performance issue).

Two HA-init quirks discovered and documented in light.py's module docstring
(same pattern repeated in every file below):
- Entity._name_internal checks `hasattr(self, "_attr_name")`, not
  truthiness. HA's own __init__ always sets it, permanently blocking our
  translation_key-based naming unless explicitly `del`eted (not nulled)
  right after calling it.
- HALightGroup specifically also sets a class-level _attr_icon default
  ("mdi:lightbulb-group") — Entity.icon has the same hasattr pattern, and
  when non-None it overwrites our own dynamic on/off icon (which lives
  inside extra_state_attributes, same "icon" key, different mechanism) in
  HA's flattened state attributes. Explicitly nulled at the instance level
  for light.py only — no other converted platform sets a default icon.

Deliberately kept as our own overrides, not HA's equivalents, where we have
a considered behavior difference:
- light.py/fan.py: async_turn_on's smart on-members-only adjustment
  filtering (HA's own forwards blindly to every member regardless of
  current on/off state).
- light.py: supported_features narrowed to the intersection across members
  and min/max_color_temp_kelvin computed from real members (HA's own unions
  features and hardcodes a fixed 2000-6500K range regardless of members).
- fan.py: preset_mode support entirely, plus async_set_percentage/
  async_set_preset_mode's on-members-only filtering — HA's own FanGroup
  doesn't track preset_mode at all (missing from its SUPPORTED_FLAGS).
- cover.py: async_set_cover_position's on-members-only filtering.

Inherited as-is (no override) where HA's own behavior is equal or better:
- switch.py: turn_on/turn_off (identical blind-forward logic either way).
- fan.py: gains oscillate/direction support for free (never had it before).
- cover.py: open/close/stop_cover and all _tilt variants — HA's own already
  forward only to members supporting the relevant feature, a safety
  property our own hand-rolled versions of these (not set_cover_position,
  which still has real on-members-only logic worth keeping) didn't have.

climate.py (no HA core group.climate exists) and media_player.py (HA's own
group.media_player doesn't inherit GroupEntity at all and uses a different
internal attribute name — a worse fit for this pattern) are unchanged,
already at the group.util level from the previous refactor. binary_sensor.py's
PresenceGroup (a composite OR-gate across multiple domains/device_classes,
not a single homogeneous list) has no HA equivalent either, also unchanged.

Verified via live restarts of the ha-test fixture after each file: no
tracebacks, and correct friendly_name/icon/device_class/active_entity_ids
for light/switch/fan/cover/binary_sensor groups, including a live light
turning on and the group correctly reflecting active_entity_ids/icon/color.
Julien-Decoen added a commit that referenced this pull request Jul 19, 2026
…#161)

* feat: area/floor/global group entities for presence, lights, and more

Rapatrie de Linus Brain vers Linus Dashboard les entités groupe "mécaniques"
(pas d'IA) — présence par zone et groupe de lumières — pour qu'elles soient
disponibles à tout utilisateur de Dashboard, pas seulement ceux ayant Brain
installé. Généralise ensuite le même pattern à switch/fan/cover/siren et à
tous les device_class de binary_sensor, avec une hiérarchie imbriquée à trois
échelles (global > étage > zone) plutôt qu'un système plat.

- group_manager.py, entity_group.py : logique de scan/imbrication partagée,
  portée du pattern anti race-condition de Linus Brain (EVENT_HOMEASSISTANT_STARTED
  + délai) qui a résolu le bug de cold-restart de _build_aggregate_sensors.
- binary_sensor.py : groupe de présence composite (motion/presence/occupancy/
  media_player) + un groupe par device_class binary_sensor restant.
- light.py, switch.py, fan.py, cover.py, siren.py : groupes de contrôle par
  zone/étage/global, mêmes device en HA (get_area_device_info/
  get_floor_device_info), même garde anti-boucle infinie (entity.platform ==
  DOMAIN) documentée dans chaque fichier qui scanne par zone.
- sensor.py : ajout de sensors numériques (température/humidité/illuminance/
  batterie, somme/moyenne/minimum selon state_class et device_class) et d'un
  sensor de santé (entités unavailable/unknown) par zone/étage/global ; le
  système de comptage caché existant (PR #152) ne garde un rôle que pour
  climate/media_player, tous les autres domaines étant désormais couverts par
  une entité groupe dédiée et visible.
- entityResolver.ts : resolvePresenceSensor/resolveAllLights pointent
  directement vers les nouvelles entités natives (plus de fallback Brain) ;
  ActivityDetectionChip.ts corrigé pour ne plus dériver la détection Brain de
  la résolution de présence (devenue toujours "native").
- config_flow.py/__init__.py : option pour masquer ces nouvelles entités des
  assistants vocaux (best-effort, API HA versionnée).

Basé sur pr-152 (pas main) car aggregate.py/sensor.py — dont ce travail
réutilise compute_active_count/compute_icon/compute_color — n'existent que
là. Conflit de merge attendu sur entityResolver.ts une fois pr-152 fusionnée,
main ayant déjà réintégré le support Magic Areas (#142) après la création de
pr-152 ; à réconcilier à ce moment-là.

Validé en live sur l'env de test HA (ha-test, HA 2026.4.0, fake house 6
zones / 2 étages) : 88 entités créées sans erreur au démarrage, hiérarchie
imbriquée confirmée via l'API (le groupe étage liste bien les entity_id des
groupes de zone, le global liste les groupes d'étage, pas les entités
brutes). Un bug réel corrigé pendant cette validation : `GroupEntity` n'est
pas dans `homeassistant.helpers.group` sur HA 2026.4.0 mais dans
`homeassistant.components.group.entity`, avec des attentes internes
(`_entity_ids`, hooks de preview) non satisfaites par mon code — retiré de
l'héritage de PresenceGroup/BinarySensorDeviceClassGroup, NestedGroupMixin
gère déjà seul ce qui compte réellement (l'attribut entity_id lu par la
modale more-info).

Connu comme non couvert par cette PR : aucun test pytest (le dépôt n'a
actuellement aucune infra de test Python, seulement ruff en CI — bootstrap
d'un harnais pytest-homeassistant-custom-component est un chantier séparé) ;
retrait des entités équivalentes côté Linus Brain (repo différent, PR
séparée) ; agrégation position/vitesse pour cover/fan (v1 = on/off
uniquement) ; câblage des nouveaux groupes switch/fan/cover dans
AggregateChip.ts (les entités existent, ce chip ne les résout pas encore) ;
contrôle réel des entités (turn_on/turn_off) pas encore testé en live, juste
leur création et leurs attributs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat: full feature aggregation for light/fan/cover/siren groups

Previously every group entity (except switch, inherently binary) only
forwarded bare on/off — light.py's own docstring claimed brightness/color
support but _attr_supported_color_modes was hardcoded to ONOFF, and
fan/cover explicitly deferred percentage/position as v1 scope. Ports
Linus Brain's AreaLightGroup feature-aggregation logic (dynamic
supported_color_modes/supported_features detection via intersection,
brightness/color averaging across on members, smart filtering so an
adjustment like "nudge brightness" only touches already-on members
without waking dark ones) and extends the same pattern to fan
(percentage/preset_mode) and cover (position/tilt), plus lighter
supported_features/available_tones detection for siren (which has no
persistent tone/volume state to average, unlike the others).

Two real bugs found while validating live against the ha-test fake
house:
- entity_group.py's scan_domain_members only excluded this
  integration's own entities (entity.platform == DOMAIN), so a foreign
  integration's own group entity living in the same area (Linus
  Brain's light.linus_brain_all_lights_salon, Magic Areas' fan group)
  got scanned in as if it were a raw member — double-counting it and
  skewing averaged attributes. Fixed by also excluding any entity
  whose own state already carries an `entity_id` attribute (the same
  ATTR_ENTITY_ID convention this integration, Brain, and HA's native
  `group:` all use to mark "I am a group"), a general fix that doesn't
  need to know about specific other integrations.
- cover.py read ATTR_POSITION/ATTR_TILT_POSITION from member state
  attributes, but those are the set_cover_position/
  set_cover_tilt_position *service call* parameter names — the actual
  state attributes are ATTR_CURRENT_POSITION/ATTR_CURRENT_TILT_POSITION,
  a different pair of constants. Fixed; verified live (opening
  cover.volet_salon now correctly surfaces current_position on the
  area group).

Verified live: fan percentage read/write round-trip (turn_on with
percentage, set_percentage, turn_off — all correctly forwarded to and
reflected from the real member), cover open/close + position reporting,
and the foreign-group exclusion (member counts dropped from 4→3 and
2→1 once Brain's/Magic Areas' own groups stopped being counted).
Brightness/color specifically not live-tested — the fake house's
lights are all plain on/off templates with no dimmable fixture — but
the aggregation math is structurally identical to the verified
percentage/position averaging.

* fix: drop redundant location suffix from group entity names, add dimmable test light

Every group entity (light/switch/fan/cover/siren/binary_sensor groups,
numeric sensors, health sensor) already gets its own per-scope device
(get_area_device_info/get_floor_device_info/get_global_device_info),
which HA's has_entity_name convention combines with the entity's own
name wherever it's displayed. But the entity's own translation string
also embedded the area/floor name (or "(Whole House)"), producing
doubled names like "Linus Dashboard - Chambre Toutes les lumières -
Chambre" in notifications/modals. Stripped the redundant suffix from
both en.json and fr.json across all 12 affected translation key pairs
— the device name is now the only thing carrying location.

Also added a real dimmable light (salon_principal, via level/set_level
in fake_house.yaml) to the test fixture: every other fake light was
plain on/off, so light.py's brightness/color aggregation had nothing
to actually exercise in this environment. Caught two real HA template
quirks while wiring it up and verified live: the modern `template:`
light schema uses `level`, not `level_template` (that's the legacy
`light: platform: template` key); and HA calls the `set_level` action
*instead of* `turn_on` when brightness is passed to turn_on (not both),
so set_level has to also flip the underlying boolean on itself.

Verified live end-to-end: group turn_on(brightness=X) with nothing on
turns every member on (brightness applies to the one that supports it);
an adjustment with a mix of on/off members only touches the already-on
one, leaving onoff-only siblings alone — the exact Linus Brain smart-
filtering behavior this was meant to port.

* fix: point at Linus Dashboard's own presence/light-group entities, not Brain's

Linus Brain's AreaLightGroup and PresenceDetectionBinarySensor were
just removed (superseded by this integration's own native area/floor/
global equivalents — see the Linus Brain PR). Two popups still built
entity_ids for the old linus_brain_presence_detection_*/
linus_brain_all_lights_* pattern directly (bypassing EntityResolver),
which would now silently resolve to nothing:

- LinusBrainAreaPopup.ts: its presence/all-lights status and control
  cards now read Dashboard's own binary_sensor.linus_dashboard_
  presence_detection_area_*/light.linus_dashboard_all_lights_area_*.
  Also dropped presence from the hasLinusBrain gating check — a
  Dashboard-native entity existing no longer signals Brain is
  installed, only the activity sensor still does that.
- ActivityDetectionPopup.ts: the presence-group member lookup (used to
  catch sensors that don't match the standard device_class filters)
  now reads Dashboard's presence group instead of Brain's.

Also adds the hide_groups_from_voice_assistants translation that was
missing from the config flow (en.json/fr.json data + data_description).

* fix: de-duplicate aggregate chip data source — prefer dedicated group entities

AggregateChip.ts's floor/global fast path (PR #152) and this session's
dedicated group entities (light.py/switch.py/fan.py/cover.py/siren.py/
binary_sensor.py) were computing the same total/active_entity_ids/icon/
color twice for light, switch, fan, cover and most binary_sensor
device_classes — the chip never knew the dedicated entity existed, so
it kept reading the generic hidden counting sensor even when a richer,
controllable equivalent was sitting right next to it.

- AggregateChip.ts: getAggregateSensorId() now tries the dedicated
  group entity first (light.linus_dashboard_all_lights_*, etc., or
  binary_sensor.linus_dashboard_{device_class}_* for classes that have
  one), only falling back to the generic sensor for climate/
  media_player (no dedicated group — no simple on/off control
  semantics) and binary_sensor classes that don't get their own group
  (motion/presence/occupancy, folded into the presence composite
  instead). The two sources report their count differently (the
  generic sensor's own state IS the count; a group entity's state is
  on/off, count is in active_entity_ids), so the content template
  branches on which one was found.
- sensor.py: _build_aggregate_sensors now only generates the
  domain-level (no device_class) bucket for GENERIC_DOMAIN_LEVEL_DOMAINS
  (climate, media_player, binary_sensor — binary_sensor has no "every
  class regardless of type" dedicated group). Device_class buckets stay
  unrestricted for every domain, since they're still the only source
  for combinations without a dedicated group (cover-by-type, binary_
  sensor's presence-related classes). Also renamed the sensor's
  `entity_ids` attribute to `entity_id` (ATTR_ENTITY_ID) for consistency
  with every other group-shaped entity in this integration.
- __init__.py: added async_cleanup_orphaned_aggregate_sensors, called
  before platform setup — anyone already running the old (still-)
  restricted set would otherwise keep 11 permanently-unavailable
  sensors (light/switch/fan/cover's domain-level bucket) in the
  registry forever, same footgun as the Linus Brain cleanup.

Verified live against the ha-test fake house: hidden sensor count went
from 33 to 22 (exactly the 11 light/switch/fan/cover domain-level
entries removed, climate/media_player/binary_sensor untouched), the
cleanup routine logged and removed all 11 orphans on restart with no
errors, and all 39 dedicated group entities are unaffected.

* feat: group-control tile for switch/fan/cover/siren, voice-hide on rebuild, docs+version

Area-scope popup group-control tile (renamed getLinusBrainEntity ->
getGroupEntity / buildLinusBrainSection -> buildGroupControlSection,
since it resolves Dashboard-native and Magic Areas entities too, not
just Brain's):
- entityResolver.ts: added resolveGroupEntity(), one generic method for
  switch/fan/cover/siren rather than four near-identical ones — none of
  them have a Magic Areas equivalent, unlike light/climate.
- AggregateChip.ts: getGroupEntity() now resolves switch/fan/cover/siren
  via the new resolver method, using the existing DEDICATED_GROUP_DOMAINS
  slug map.
- AggregatePopup.ts: buildGroupControlSection() picks a domain-appropriate
  tile feature (light-brightness / fan-speed / cover-open-close +
  cover-position), instead of hardcoding light-brightness.
- PopupFactory.ts: CoverPopup and the generic AggregatePopup path (which
  also handles switch/fan/siren) were hardcoding groupEntity: null,
  discarding it before it ever reached the popup — now forwarded.

Voice-assistant hiding now also runs after dynamic rebuilds, not just
once at config entry setup: previously an area/entity added after
startup (new area, entity moved in, ...) got a fresh group entity that
was never hidden, silently exposing it to voice assistants by default
even with the option enabled. async_hide_group_entities_from_voice_
assistants moved its option-check inside itself and is now called from
every platform's _rebuild() after adding new entities, not just once
from async_setup_entry.

Housekeeping: bumped to 1.7.0-beta.1 (manifest.json, package.json,
package-lock.json) for this feature; added "siren" to manifest.json's
after_dependencies (missed when siren.py was added); documented the
new native group entities in the README FAQ (previously undocumented
anywhere).

* fix: voice-assistant hiding was silently broken since it was first added

Found while live-verifying the previous commit's "hide on rebuild" fix:
async_listed_assistants (imported from homeassistant.components.
homeassistant.exposed_entities) doesn't exist on this HA version — the
whole function has always been raising ImportError and bailing out
immediately, silently logging a warning and never hiding a single
entity, at setup or on rebuild. Confirmed by checking core.entity_
registry directly before/after: 0 Linus Dashboard entities had
should_expose set on any assistant despite the option being on.

HA's exposed_entities module replaced that function with a plain
KNOWN_ASSISTANTS constant at some point; async_expose_entity itself was
never affected. Imported it separately from async_expose_entity so a
future rename of one can't silently take the other down with it again,
same failure mode that caused this in the first place.

Verified live: after the fix, all 90 Linus Dashboard entities in
core.entity_registry now correctly show should_expose: false for
conversation/cloud.alexa/cloud.google_assistant.

* fix: add missing binary_sensor/siren entries to Helper's icon fallback map

Helper.ts's #DOMAIN_ACTIVE_STATES (the client-side fallback AggregateChip
uses for area scope, or when a server-side entity is momentarily
unavailable) never got binary_sensor/siren added, even though
aggregate.py's DOMAIN_ACTIVE_STATES/DOMAIN_ICONS — the source of truth
this mirrors — already had both. Without an entry, getIcon() silently
fell through to "Path A" (HA's own per-entity icon state map), which
the surrounding comment already documents as unreliable for a group of
entities in mixed states rather than a single entity — exactly the
case that matters now that binary_sensor-by-device_class and siren
groups exist as real AggregateChip targets. colorMapping in
variables.ts already had full coverage for both domains, so only
getIcon's map needed this.

* feat: use dedicated group entities for area-scope chip rendering too

Re-checked the "AreaView/FloorView recompute client-side" concern from
the original plan by actually reading createAreaScopeChips/
createFloorScopeChips (utils.ts) — they don't recompute anything
themselves, they just existence-check then delegate straight to
AggregateChip, which was already the single shared renderer. So that
specific worry didn't apply. But it did surface a real, adjacent one:
getAggregateSensorId() unconditionally returned null for area scope,
forcing every area chip through the slower client-side per-entity
Jinja template path (Helper.getIcon/getIconColor/getContent iterating
every raw entity) even though light.py/switch.py/fan.py/cover.py/
siren.py/binary_sensor.py's dedicated group entities have the exact
same icon/color/active_entity_ids attributes at area scope as they do
at floor/global — that null was only ever correct because dedicated
group entities didn't exist yet when this method was first written.

Now tries the dedicated group entity at area scope first, same as
floor/global, and only returns null if none exists (climate/
media_player, or a binary_sensor device_class without its own group —
same domains that already fell back to client-side at floor/global).
The generic hidden counting sensor has no area tier at all (sensor.py
only builds floor/global), so area scope skips that fallback tier
entirely rather than trying entity_ids that could never exist.

* refactor: de-duplicate per-domain conditional chip building, fix stale comments

Reviewed every remaining AggregateChip caller (HomeAreaCard, ControllerCard,
StandardDomainView, HomeView, AggregateView, SectionBuilder,
deviceClassHelper) for cleanup opportunities. Two had real duplication:

- HomeAreaCard.ts::getChipsCard: the health/window/door/cover/climate/fan
  badge chips all repeated the identical "wrap an AggregateChip in a
  ConditionalChip watching these entities for state on" shape 6 times.
  Extracted to buildConditionalDomainChip().
- HomeView.ts::createSectionCards: the per-floor light/climate/fan/cover
  chips repeated "check entities exist on this floor, then build the
  chip" 5 times, including an inline IIFE for the no-device_class cover
  case. Extracted to #buildFloorScopeChipIfPresent().

Also fixed stale "(Linus Brain only)" comments in HomeAreaCard.ts:
resolveAllLights has been Dashboard-native-first (not Brain at all)
since this session's earlier work; resolveAreaState/
resolveLightControlSwitch are still Brain-first but the comment ignored
their Magic Areas fallback.

The other 5 files were left alone on purpose:
- ControllerCard.ts / StandardDomainView.ts / AggregateView.ts: already
  clean, no meaningful duplication.
- SectionBuilder.ts: doesn't call AggregateChip at all — a separate,
  more generic badge builder using Helper.getIcon/getIconColor directly
  (already benefits from this session's earlier binary_sensor/siren fix
  to that shared function). Not redundant with AggregateChip, serves a
  different (caller-provided entity list vs domain+scope auto-discovery)
  purpose.
- deviceClassHelper.ts: uses require() instead of ES imports (likely
  dodging a circular-import with Helper.ts) and does its own dynamic
  device_class discovery instead of the fixed DEVICE_CLASSES list other
  views use. Both pre-existing and working; changing either without a
  real build to verify against (no Node in this environment) is a risk
  not worth taking for a cosmetic win.

* feat: Turn All On/Off buttons target the dedicated group entity when one exists

AggregatePopup's control buttons called light.turn_on/off (etc.) with
every raw member entity_id listed individually. For domains with a
dedicated group entity (light/switch/fan/cover/siren), that's strictly
worse than just targeting the group entity itself: more data in the
service call, and it bypasses the group's own control method entirely
even though that's exactly what already exists to handle this.

Reuses the resolution AggregateChip already did for its own rendering
(getAggregateSensorId's isDedicatedGroup) instead of re-deriving it —
threaded through PopupFactory as dedicatedGroupEntity alongside the
existing (area-only) groupEntity field. buildControlButtons targets
[dedicatedGroupEntity] instead of entity_ids when present, falling
back to the raw list unchanged for climate/media_player (no dedicated
group ever exists for either).

No behavior change for the bare on/off case itself: turn_on() with no
kwargs isn't treated as an "adjustment" by any group's smart-filtering
(that only kicks in for brightness/percentage/position/preset_mode
adjustments), so it still targets every member either way — this is a
pure simplification of the request being sent, not a functional change.

* fix: stray */ inside a JSDoc comment broke the Rspack build

A comment mentioning `_floor_*/_global` closed its own /** block early
at the literal */ — everything after that point (including the
dedicatedGroupEntity field declaration) parsed as garbage, which is
why the actual error surfaced many lines later as a confusing 'Expected
{, got interface' on the *next* interface. Build & Smoke Tests caught
it (Rspack actually compiles the bundle); TypeScript & ESLint didn't
flag it because that job's steps are continue-on-error: true — worth
remembering it isn't a reliable green signal on its own, only Build &
Smoke Tests actually proves the file parses.

* fix: floor/area drill-down separators lost dedicated-group Turn All targeting

buildFloorSeparator/buildAreaSeparator built the heading's tap_action via a
separate PopupFactory.createPopup(...) call that hardcoded groupEntity and
dedicatedGroupEntity to null, instead of reusing the AggregateChip it builds
right next to it for the badge — which already resolves both correctly for
that floor/area. Net effect: opening a floor/area popup by drilling down from
its parent (global or floor) popup silently fell back to targeting every raw
member entity on Turn All On/Off, instead of the single dedicated group
entity, even though opening that same floor/area popup directly (from its own
top-level chip) used the group entity correctly.

Fix: use the already-built chip's own tap_action as the heading's tap_action
— same popup, correctly resolved, one fewer redundant aggregate-source lookup.

* feat: group control tile at every scope, history graph for sensor popups

Group control tile (brightness/fan-speed/cover-position slider) previously
only rendered at area scope, since it was gated on groupEntity — which
getGroupEntity() only ever populates for area scope by design (floor/global
route through dedicatedGroupEntity instead). Gate on dedicatedGroupEntity
first (covers light/switch/fan/cover/siren at area/floor/global alike),
falling back to groupEntity for domains with no dedicated group (climate,
via Magic Areas) — same tile, same behavior, just no longer area-only.

Also add a 24h history-graph card to sensor-domain popups (temperature,
humidity, etc.), one line per entity in scope — the statistics card above it
only shows the current sum/average, not the trend, which is usually the
actual reason to open one of these.

* fix: hide Turn All On/Off buttons when a group control tile is shown

The group control tile's own toggle already turns every member on/off (plus
brightness/fan-speed/cover-position via its features) — showing the Turn All
On/Off buttons above it was a redundant, less capable duplicate of the same
control whenever a dedicated (or Magic Areas) group entity exists for this
scope/domain. Buttons now only render when there's no tile to show instead.

* fix: binary_sensor group icons/translation, hidden climate/media_player aggregate visibility, popup separator consistency

- compute_icon() ignored device_class entirely for binary_sensor (always the
  generic checkbox icon), unlike compute_color() which already had a
  BINARY_SENSOR_COLORS override — added the equivalent BINARY_SENSOR_ICONS
  map and threaded device_class through both call sites (entity_group.py's
  compute_group_attributes, sensor.py's generic aggregate sensor), so
  door/motion/window/etc. groups get an icon that actually matches instead
  of a generic checkbox.
- binary_sensor.py's per-device_class groups (door, window, smoke, ...) used
  a hand-rolled translation string "{device_class}" that just echoed the raw
  English slug back verbatim in every language ("door" even in a French
  install) instead of translating anything. Dropped the custom
  translation_key/placeholders for this class entirely — with
  _attr_device_class already set and no translation_key, HA falls back to
  its own core per-device_class entity name (already localized) combined
  with the device's own name for the area/floor distinction, same mechanism
  every other integration relies on for this exact situation.
- LinusDashboardAggregateSensor (the old generic hidden-counting sensor,
  which now only exists for climate/media_player/binary_sensor domain-level
  buckets — every other domain has its own dedicated group entity) was
  always diagnostic/hidden by class default. climate and media_player have
  no dedicated group of their own, so this hidden sensor was the *only*
  aggregate they had — as a hidden diagnostic entity, a user had no reason
  to ever notice it existed. Made domain-level buckets (device_class_filter
  is None) visible and non-diagnostic; device_class-specific buckets are
  unchanged.
- AggregatePopup's "Individual Controls" section heading only appeared when
  an area had more than one entity, making a 1-light area's popup look
  structurally different from a 3-light area's for no functional reason.
  Now shows whenever there's at least one entity.

* fix: un-hide climate/media_player/binary_sensor domain-level aggregate sensors

Bumping LinusDashboardAggregateSensor's visible_default in the previous
commit only affects entities created from now on — HA applies
entity_registry_visible_default once, at first creation, and never
retroactively on an existing registry entry. Anyone who already ran an
earlier version has these stuck with hidden_by="integration" forever, so the
class-attribute fix alone would look like it did nothing. Explicit one-time
migration, same exact-unique_id-set technique as the existing orphaned-sensor
cleanup, clears hidden_by only when it's still "integration" (never touches
a user's own manual hide).

* feat: dedicated group entities for climate and media_player (full domain parity)

Adds climate.py/media_player.py, following the exact same nested area/floor/
global pattern as light/switch/fan/cover/siren (entity_group.py's
build_nested_domain_groups, unchanged). v1 "dumb" aggregation, matching the
documented incompleteness already accepted for fan/cover:
- ClimateGroup: hvac_mode/supported_features are an intersection across
  members (a mode/feature the group offers must actually work everywhere);
  current/target temperature are simple averages; min/max_temp is the
  widest range any member supports. turn_on/turn_off go through the
  universal set_hvac_mode primitive rather than the optional TURN_ON/
  TURN_OFF feature, so the group's own turn_on/off always works regardless
  of member support — TURN_ON/TURN_OFF are unconditionally added to
  supported_features for the same reason.
- MediaPlayerGroup: play/pause/stop/turn_on/turn_off forwarded blindly to
  every member (a member lacking support just logs a warning, doesn't fail
  the group); volume_level is a simple average. source/source_list
  deliberately not aggregated — no sensible single value across a mixed
  fleet of players.

Consequences of no longer deferring these two domains:
- sensor.py's GENERIC_DOMAIN_LEVEL_DOMAINS drops to just ("binary_sensor",)
  — climate/media_player's old hidden counting sensor is now dead weight,
  added to __init__.py's _DEAD_GENERIC_SENSOR_DOMAINS cleanup instead of
  needing to stay un-hidden (_UNHIDE_DOMAIN_LEVEL_SENSOR_DOMAINS trimmed to
  just binary_sensor accordingly).
- AggregateChip.ts's DEDICATED_GROUP_DOMAINS and AggregatePopup.ts's
  GROUP_TILE_FEATURES gain climate/media_player entries so the frontend
  picks up the new dedicated groups the same way it already does for light/
  switch/fan/cover/siren (Turn All button targeting, quick-control tile).

Also fixes a real, previously-invisible bug found while building this:
get_area_device_info()'s own docstring said area-scoped Linus Dashboard
devices must be explicitly placed in their real HA area via
device_registry (since suggested_area risks creating a duplicate area) —
but nothing ever actually made that call, for any domain, since the very
first area-scoped group entity shipped. Every "Linus Dashboard - <Area>"
device has been sitting with no area assigned this whole time; none of the
"open Settings > Areas > Salon and see its Linus Dashboard device" UX the
plan described was actually happening. Added entity_group.py's
ensure_area_device_placed() (called from build_nested_domain_groups and
binary_sensor.py's two custom area loops) to actually do it.

Placing these devices in their real area makes the self-inclusion risk
entity_group.py's CRITICAL PATTERN comment warns about newly real on the
frontend too (it was already guarded against on the backend) — a group
entity's own device now genuinely sits in the area it's a member of, so
without a matching exclusion, e.g. light.linus_dashboard_all_lights_area_salon
would show up as an extra "individual" tile in the Salon light popup,
indistinguishable from a real light. Helper.ts already excluded Magic Areas/
Linus Brain's own entities from area/domain entity lists for the exact same
reason — added Linus Dashboard's own platform to that same exclusion.

* debug: log ensure_area_device_placed to diagnose why area_id isn't sticking

* chore: drop debug logging now that ensure_area_device_placed is verified working

* fix: MediaPlayerPopup never received dedicatedGroupEntity at all

PopupFactory's createMediaPlayerPopup hardcoded groupEntity: null (correct
— no Magic Areas media_player group exists) but never forwarded
dedicatedGroupEntity, unlike every other popup builder. Combined with
MediaPlayerPopup's own buildControlButtons override (custom "Play All"/
"Pause All" conditional cards, written before dedicatedGroupEntity existed)
always targeting the raw entity_ids directly, media_player never picked up
its new group entity at all: no quick-control tile, and Play All/Pause All
never targeted the group even when it existed and was available.

Threading dedicatedGroupEntity through now makes AggregatePopup's own
tileEntity gating do the right thing automatically — the group tile shows
when the entity is available (with its own play/pause/stop/volume, added to
GROUP_TILE_FEATURES), and MediaPlayerPopup's override is skipped entirely in
that case (same "don't show a redundant duplicate control" gate every other
domain already gets), falling back to Play All/Pause All against entity_ids
only when there's no group tile to show instead.

* clean: dedup media_player active states, remove dead buildFloorAreaSeparators

media_player.py's own _ACTIVE_STATES tuple was a hand-copied duplicate of
aggregate.py's DOMAIN_ACTIVE_STATES["media_player"] — two lists that would
only stay in sync by remembering to update both. Import it instead.

buildFloorAreaSeparators had zero callers: buildIndividualCardsHierarchical
(the only place that builds floor/area separators) already covers both
cases it tried to handle — global scope gets buildFloorSeparator per floor
plus buildAreaSeparator per area inside it, floor scope gets just
buildAreaSeparator — via two separate, actually-wired methods. This third
one duplicated the same two scenarios in a single parameterized function
that never replaced them, just sat unused alongside.

* refactor: reuse HA core's group.util helpers instead of hand-rolled reduction

Architecture request: stay as close to HA's own patterns as practical for
easier long-term maintenance. homeassistant.components.group.util ships
with every HA install (it's what HA's own light.group/switch.group/etc.
platforms are built on) and provides exactly the "reduce this attribute
across a list of states" primitives light.py/climate.py/media_player.py
were each hand-rolling with their own for-loops:

- light.py: brightness averaging -> reduce_attribute (its default reducer,
  mean_int, is the same sum/len/int() math we did by hand). Color attributes
  (color_temp/rgb/hs/rgbw/rgbww/effect) -> a small _agree_or_none built on
  attribute_equal + find_state_attributes, preserving the existing "show
  nothing rather than an averaged color no member is actually displaying"
  behavior rather than switching to HA's own group.light (which averages
  colors via mean_tuple/mean_circle) — that's a real behavior difference,
  not just an implementation detail, so kept as-is rather than silently
  changed. min/max_color_temp_kelvin -> reduce_attribute(reduce=min/max).
- climate.py: current/target temperature -> reduce_attribute with a new
  shared mean_float reducer (group.util's default, mean_int, truncates —
  wrong for temperature). min/max_temp -> reduce_attribute(reduce=min/max).
- media_player.py: volume_level -> reduce_attribute(reduce=mean_float).

supported_features/supported_color_modes/hvac_modes intersection logic is
deliberately NOT switched to HA's own group platforms' approach (which
union features rather than intersect) — that's a considered difference
covered in each file's own docstring, not something "closer to HA" should
paper over. hvac_mode/media_player state aggregation also stays manual:
group.util's helpers reduce *attributes*, and those are the state itself.

mean_float added once in entity_group.py (shared by climate.py and
media_player.py) rather than duplicated in both.

* feat: Magic Areas fallback for media_player group, matching climate

media_player.py's native group entity is still tried first at every scope
(getAggregateSensorId, unchanged) — this only kicks in as a secondary
fallback if that entity happens to be unavailable, same role
resolveClimateGroup already plays for climate. Magic Areas does have a real
media_player_group entity (wraps HA core's own group.media_player.
MediaPlayerGroup) to fall back to, unlike media_player_control (a boolean
switch, not a media_player entity).

Filling this in for consistency with climate rather than leaving an
asymmetry where one dedicated-group domain gets a fallback and the other
doesn't, for no principled reason.

* refactor: multiply-inherit HA core's group.<domain> classes for state computation

Goes further than the previous group.util-based refactor: light.py,
switch.py, fan.py, cover.py, and binary_sensor.py's per-device_class groups
now directly inherit HA core's own homeassistant.components.group.<domain>
class (e.g. group.light.LightGroup) alongside NestedGroupMixin, delegating
async_update_group_state() entirely to HA instead of maintaining our own
copy of that computation. Same class Magic Areas builds its own groups on.

NestedGroupMixin gained _sync_ha_group_state(domain, device_class=None) to
drive this: keeps self._entity_ids (HA's own classes read this name, not
our _member_entity_ids) in sync, calls async_update_supported_features per
member for the platforms that track features incrementally (fan/cover),
calls the inherited async_update_group_state(), then layers our own
entity_id/total/active_entity_ids/icon/color on top via
compute_group_attributes. async_added_to_hass no longer calls super() —
GroupEntity's own version (further up these classes' MRO) sets up an
*undebounced* subscription, which would defeat NestedGroupMixin's own
debounce (added for PR #152's "flatline" performance issue).

Two HA-init quirks discovered and documented in light.py's module docstring
(same pattern repeated in every file below):
- Entity._name_internal checks `hasattr(self, "_attr_name")`, not
  truthiness. HA's own __init__ always sets it, permanently blocking our
  translation_key-based naming unless explicitly `del`eted (not nulled)
  right after calling it.
- HALightGroup specifically also sets a class-level _attr_icon default
  ("mdi:lightbulb-group") — Entity.icon has the same hasattr pattern, and
  when non-None it overwrites our own dynamic on/off icon (which lives
  inside extra_state_attributes, same "icon" key, different mechanism) in
  HA's flattened state attributes. Explicitly nulled at the instance level
  for light.py only — no other converted platform sets a default icon.

Deliberately kept as our own overrides, not HA's equivalents, where we have
a considered behavior difference:
- light.py/fan.py: async_turn_on's smart on-members-only adjustment
  filtering (HA's own forwards blindly to every member regardless of
  current on/off state).
- light.py: supported_features narrowed to the intersection across members
  and min/max_color_temp_kelvin computed from real members (HA's own unions
  features and hardcodes a fixed 2000-6500K range regardless of members).
- fan.py: preset_mode support entirely, plus async_set_percentage/
  async_set_preset_mode's on-members-only filtering — HA's own FanGroup
  doesn't track preset_mode at all (missing from its SUPPORTED_FLAGS).
- cover.py: async_set_cover_position's on-members-only filtering.

Inherited as-is (no override) where HA's own behavior is equal or better:
- switch.py: turn_on/turn_off (identical blind-forward logic either way).
- fan.py: gains oscillate/direction support for free (never had it before).
- cover.py: open/close/stop_cover and all _tilt variants — HA's own already
  forward only to members supporting the relevant feature, a safety
  property our own hand-rolled versions of these (not set_cover_position,
  which still has real on-members-only logic worth keeping) didn't have.

climate.py (no HA core group.climate exists) and media_player.py (HA's own
group.media_player doesn't inherit GroupEntity at all and uses a different
internal attribute name — a worse fit for this pattern) are unchanged,
already at the group.util level from the previous refactor. binary_sensor.py's
PresenceGroup (a composite OR-gate across multiple domains/device_classes,
not a single homogeneous list) has no HA equivalent either, also unchanged.

Verified via live restarts of the ha-test fixture after each file: no
tracebacks, and correct friendly_name/icon/device_class/active_entity_ids
for light/switch/fan/cover/binary_sensor groups, including a live light
turning on and the group correctly reflecting active_entity_ids/icon/color.

* fix: use native presence entity in ActivityDetectionPopup without Brain

The Presence status chip and the presence line in the history graph were
both gated on isLinusBrain, even though presenceSensorEntity
(binary_sensor.linus_dashboard_presence_detection_area_X) has been native
since presence moved out of Brain — it works with or without Brain
installed. Only the genuinely Brain-only pieces (Activity chip/duration/
details/stats, all reading sensor.linus_brain_activity_*'s own attributes)
still require it.

* fix: don't show an empty brightness slider on non-dimmable light groups

light-brightness tile feature renders empty when the entity's
supported_color_modes is just ["onoff"] — nothing to bind the slider to.
HomeAreaCard's light tile and AggregatePopup's group control tile both
assumed every light group supports it. Added Helper.lightSupportsBrightness
and made both conditional on it; when unsupported, features falls back to
[] rather than a broken slider — the tile's own tap-to-toggle still turns
the group on/off either way, no dedicated "on/off buttons" tile feature
exists for light in HA to use instead.

* feat: aggregate entity for every device_class, not a fixed short list

binary_sensor.py: motion/presence/occupancy were excluded from
_discover_generic_device_classes, so only the composite presence_detection
group existed for them — no way to see "how many motion sensors are
triggered right now" specifically, only "is anyone home". Removed the
exclusion; a sensor can feed both the composite and its own device_class
group, they answer different questions.

sensor.py: NUMERIC_DEVICE_CLASSES was a fixed 4-item list (temperature/
humidity/illuminance/battery), deliberately short to avoid entity sprawl.
Replaced with _discover_numeric_device_classes (same dynamic-discovery
approach binary_sensor.py already used), gated on the state actually being
numeric (skips enum/timestamp/date device classes outright, and anything
else whose live value doesn't parse as a float) rather than a hand-picked
allowlist.

Fixed a real bug found while making this dynamic: the aggregation mode was
hardcoded to "measurement" for every device_class ("battery/illuminance/
humidity/temperature are all measurement in practice" — true for exactly
those four, not for whatever else gets discovered now). A sum-type
device_class like energy/water/gas would have been silently averaged
instead of summed. Now reads each device_class's real state_class from a
sampled member and passes that through instead.

Also dropped the hand-written translation_key/placeholders for numeric
sensors (same reasoning as binary_sensor.py's per-device_class groups,
already fixed for the same reason) — no way to pre-write a translation for
a device_class discovered at runtime, so _attr_device_class + no
translation_key falls back to HA's own core per-device_class sensor name
instead. Removed the now-dead numeric_* translation strings (including
battery's "(min)" annotation, which doesn't have a generic equivalent —
minor label simplification, not worth blocking dynamic discovery over).

* feat: activity chip falls back to native presence entity, not a static icon

Without Linus Brain, ActivityDetectionChip showed a permanently static grey
"mdi:home-search" icon — now falls back to
binary_sensor.linus_dashboard_presence_detection_area_X (an "entity" type
chip, same as the Brain branch) when this area has one, which HA renders
with its own dynamic device_class=occupancy icon (changes with on/off)
automatically — no template/state_attr wiring needed for that part.

Also gave PresenceGroup its own icon/color in extra_state_attributes
(compute_icon/compute_color with device_class="occupancy"), matching every
other group entity in this integration — it was the one exception. Reuses
the exact member scan/state dict _recompute already builds; deliberately
does not reference the per-device_class motion/presence/occupancy groups
binary_sensor.py also builds from the same raw sensors, so there's no
dependency between this composite and them — verified live (state history
shows the normal ~30-60s fixture motion cycle, not rapid flapping).

* refactor: consolidate LinusBrainAreaPopup into ActivityDetectionPopup

AreaView showed two separate chips whenever Brain was installed: Activity
Detection (always shown, localized, with history graphs and the sensor
list) and a second "Linus Brain" chip opening LinusBrainAreaPopup — a
near-duplicate covering the same activity/presence/duration/stats ground in
a different flat-cards layout with hardcoded English strings (no
Helper.localize at all). Two popups answering the same question two
different ways depending on installed integrations, rather than one model
consistently augmented when Brain is available.

Everything LinusBrainAreaPopup showed beyond what ActivityDetectionPopup
already covers was: automatic lighting switch and a device config link —
both genuinely Brain-only, no other home in the UI. Folded those into a new
Controls section (Brain-gated) in ActivityDetectionPopup; dropped the
redundant all-lights control (the area's own light card already provides
it) and the "Time Occupied" stat (already shown in the existing Statistics
section, same entity). Removed the LinusBrainAreaPopup chip/import from
AreaView and deleted the now-dead file.

* fix: health sensor never recomputed after initial boot-time snapshot

LinusDashboardHealthSensor computed its unavailable-entity count/list once
in __init__ and had no state-change subscription at all, unlike every other
aggregate/group entity in this integration. In practice this froze whatever
a handful of entities happened to report during the first second of HA
startup (many platforms briefly report "unknown" before their first real
state lands), and never updated again — so it could permanently list
entities as unavailable long after they came back online, which is what the
user was seeing on the test environment.

Gives it the same debounced async_track_state_change_event pattern already
used by LinusDashboardAggregateSensor in this file. Area-level sensors track
their raw scanned entities directly; floor/global-level sensors track their
child sensors' entity_ids and read each child's own already-computed list
(same nested-scope pattern as the rest of the integration) instead of
re-scanning raw entities.

Updated _build_health_sensors to wire up tracked_entity_ids/nested instead
of precomputing unavailable lists at build time, and switched the global
tier's build gate from area_group_ids to floor_group_ids for consistency
with every other platform's nested-hierarchy gate.

Verified live on the ha-test fixture: after restart, all unavailable_area_*/
floor_*/global sensors settled to entity_id=[]/total=0 within the 0.1s
debounce window, correctly reflecting that no fixture entity is actually
unavailable right now — previously they stayed stuck reporting the
transient boot-time "unknown" entities as permanently unavailable.

* fix: show a confirmation card when the Unavailable view has nothing to show

UnavailableView built its sections entirely from per-area unavailable-entity
scans; when nothing was unavailable anywhere, createSectionCards() returned
an empty array and the view rendered completely blank — reported live on
the test environment right after the health-sensor fix made "zero
unavailable" an actually-reachable state.

Adds the same empty-state message TagsView already uses for "no labels":
a mushroom-template-card with a green check icon, under new
entity.text.unavailable_view translation keys (en/fr).

Verified via tsc --noEmit, eslint, and rspack build:prod (node:24 container,
no errors) — no browser access in this environment to visually confirm
rendering, so this is compile-verified, not screenshot-verified.

* test: add backend unit tests for aggregate/entity_group/health sensor, fix numeric aggregation mode bug found by them

No Python test infrastructure existed in this repo at all — every backend
change this session (multi-inheritance group refactor, dynamic device_class
discovery, health sensor live-update fix) had only been verified by hand
against the ha-test fixture. Adds a pytest suite covering the parts that
don't need a full pytest-homeassistant-custom-component harness: the pure
aggregate.py helpers, entity_group.py's exclusion parsing and group-attribute
builder, and LinusDashboardHealthSensor's recompute logic (including a
direct regression test for the boot-time-snapshot bug fixed earlier this
session). Uses lightweight MagicMock(spec=HomeAssistant) fixtures, same
convention as the sibling Linus Brain project's tests/conftest.py, rather
than pulling in the full plugin.

Writing test_resolve_numeric_aggregation_mode_total_increasing_sums caught
a real, live bug in resolve_numeric_aggregation_mode(): it compared its
state_class parameter against SENSOR_STATE_CLASS_TOTAL(_INCREASING), sets
that actually hold device_class names (ported from the frontend's
device_class-keyed heuristic in variables.ts/Helper.ts, a different check
entirely) — a real state_class value like "total_increasing" can never
match a device_class name, so the sum branch was dead code and every
energy/water/gas/monetary numeric aggregate was silently averaged instead
of summed. Fixed by comparing state_class directly against HA's own
SensorStateClass values ("total"/"total_increasing") and removing the
misleading sets.

32/32 tests pass. Verified live on ha-test: temperature aggregates
(measurement -> average) unaffected; no fixture entity with a total/
total_increasing state_class exists to also confirm the sum path live, so
that path is confirmed by the new unit test only.

* docs: regenerate CHANGELOG.md and draft RELEASE_NOTES.md for the beta

CHANGELOG.md was stuck at 1.4.0-beta.8 (2026-01-05) despite several
already-tagged releases since (1.5.x, 1.6.0-beta.1) — regenerated via
scripts/generate-changelog.sh to catch it up to every existing tag. It
still won't show an entry for the current unreleased work (1.7.0-beta.1 in
package.json) since the generator only reads from git tags and no tag
exists for it yet — that entry appears once an actual version tag is cut,
which normally happens after this branch merges to main, not from here.

RELEASE_NOTES.md drafted via scripts/generate-release-notes.sh (70 commits
since the 1.6.0-beta.1 tag) then hand-written into real English/French
descriptions and a beta-tester checklist (the generator only produces raw
commit-message dumps and TODO placeholders), then run through
scripts/format-release-notes.sh for the collapsible GitHub layout.

Not done: version bump, git tag, or push — those are separate, more
consequential steps (tagging decides the actual release number and
triggers the GitHub Actions release/Discord workflow once pushed) left for
an explicit go-ahead rather than bundled into this docs update.

* test: cover UnavailableView's empty-state fallback and its regression guard

No frontend test existed for this view at all. Adds three cases: the new
confirmation-card path when nothing is unavailable anywhere (including the
edge case of zero floors), and a guard that the real per-floor/per-area
cards still render — not the confirmation card — when something actually
is unavailable, so the empty-state addition can't silently swallow real
content.

Also gives static confirmation that the English translation renders
correctly (all_good_title/all_good_subtitle in en.json) — the user had
only visually checked the French rendering; no browser access here to
screenshot either language, so this is compile+unit-test verified, not
visually verified.

---------

Co-authored-by: root <root@srv544245.hstgr.cloud>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants