Skip to content

Commit 5172c13

Browse files
committed
feat: auto-detect cover entities from lighting group labels
Groups on the Lighting application (56) whose label contains a cover keyword (blind/shutter/shade/awning/curtain/roller/garage door) are now discovered as Home Assistant cover entities instead of light. This fixes the common case where shutter relays share the lighting application with real lights and previously all appeared as lights. Classification is label-only and conservative: a manual type_overrides entry and application-id mappings always take precedence; auto-detection only ever upgrades the default light. Gated by ha_discovery_auto_type (default on), with ha_discovery_auto_type_name_heuristics and a tunable ha_discovery_auto_type_cover_keywords list.
1 parent 57c3f0c commit 5172c13

8 files changed

Lines changed: 238 additions & 7 deletions

File tree

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,10 +164,18 @@ The crucial step is setting the correct `ha_discovery_*_app_id` values to match
164164
**Important Notes:**
165165

166166
* Discovery for Covers, Switches, Relays, PIRs, and HVAC is **disabled by default** (`null`). Only the Lighting application (56) is discovered automatically, and **every** Lighting group is published as a `light`. You *must* set the corresponding `ha_discovery_*_app_id` in `settings.js` to the correct C-Bus Application ID to enable the other types.
167-
* **Devices that live on the Lighting application (56) but are not lights** — e.g. shutter-relay units (blinds use lighting group addresses) or a thermostat exposed on app 56 — will appear as `light` entities because classification is purely by Application ID. To reclassify an individual group, add a per-group `type_overrides` entry in your labels file (`"<net>/<app>/<group>": "cover" | "switch" | "relay" | "pir" | "hvac"`) or via the web UI; this is the only way to change the type of a group that shares the Lighting application with real lights.
167+
* **Devices that live on the Lighting application (56) but are not lights** — e.g. shutter-relay units (blinds use lighting group addresses) or a thermostat exposed on app 56 — are classified by Application ID and so default to `light`. Motorised covers whose label contains a cover keyword are now auto-detected (see *Automatic cover detection* below). For anything auto-detection can't infer, add a per-group `type_overrides` entry in your labels file (`"<net>/<app>/<group>": "cover" | "switch" | "relay" | "pir" | "hvac"`) or via the web UI; an override always wins.
168168
* If multiple discovery types (e.g., Cover and Switch) are configured with the *same* Application ID, `cgateweb` prioritizes discovery in this order: Cover > Switch > Relay > PIR. Only the first matching type will be discovered for a given C-Bus group using that Application ID.
169169
* For more technical details, see `docs/project-homeassistant-discovery.md`.
170170
171+
#### Automatic cover detection
172+
173+
Groups on the Lighting application (56) whose label contains a cover keyword (`blind`, `shutter`, `shade`, `awning`, `curtain`, `roller`, `garage door`) are published as Home Assistant `cover` entities instead of `light`. This is on by default (`ha_discovery_auto_type: true`).
174+
175+
Precedence: a manual `type_overrides` entry always wins, then application-id mappings, then this automatic detection, then the default `light`. To disable auto-detection set `ha_discovery_auto_type: false`; to keep it on but turn off keyword matching set `ha_discovery_auto_type_name_heuristics: false`. Customise the keyword list with `ha_discovery_auto_type_cover_keywords` (matching is case-insensitive and catches plurals).
176+
177+
Note: a shutter relay with a non-descriptive name still appears as a light — add a `type_overrides` entry (e.g. `"254/56/15": "cover"`) for those.
178+
171179
### Testing
172180
173181
This project uses Jest for unit testing.

homeassistant-addon/DOCS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,9 @@ Disable auto-discovery (`auto_discover_networks: false`) if:
174174
| `ha_discovery_switch_app_id` | integer | (null) | C-Bus app ID for switches (optional). Leave empty to disable switch discovery. |
175175
| `ha_discovery_trigger_app_id` | integer | (null) | C-Bus app ID for trigger groups (keypads, scene buttons). Typically `202`. Each group is exposed as an HA `event` entity, a companion `button` entity, and (when `ha_discovery_scene_enabled` is `true`) a `scene` entity. Leave empty to disable. |
176176
| `ha_discovery_scene_enabled` | boolean | `true` | Publish an HA `scene` entity for each C-Bus trigger group in addition to the `event` and `button` entities. Set to `false` to suppress scene entities. |
177+
| `ha_discovery_auto_type` | boolean | `true` | Auto-detect device types for Lighting-application (56) groups. Currently detects motorised covers (blinds/shutters) from the group label. A manual `type_overrides` entry and application-id mappings always take precedence; auto-detection only upgrades the default `light`. |
178+
| `ha_discovery_auto_type_name_heuristics` | boolean | `true` | When `ha_discovery_auto_type` is on, classify covers by matching the group label against the cover keyword list. Set to `false` to turn keyword matching off. |
179+
| `ha_discovery_auto_type_cover_keywords` | list | `[blind, shutter, shade, awning, curtain, roller, garage door]` | Keywords that mark a Lighting group as a cover. Matching is case-insensitive and catches plurals. |
177180
| `ha_discovery_hvac_app_id` | integer | (null) | C-Bus app ID for HVAC/climate zones. The standard C-Bus HVAC application is `201`. Each group is exposed as an HA `climate` entity. Leave empty to disable. |
178181
| `ha_hvac_temperature_unit` | list | `C` | Temperature unit for HVAC climate entities: `C` for Celsius, `F` for Fahrenheit. |
179182
| `ha_bridge_diagnostics_enabled` | boolean | `true` | Publish bridge health/diagnostic entities to Home Assistant via MQTT Discovery |

src/defaultSettings.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ const defaultSettings = {
5151
ha_discovery_pir_app_id: null,
5252
ha_discovery_trigger_app_id: null,
5353
ha_discovery_scene_enabled: true,
54+
// Automatic device-type detection for groups on the Lighting application
55+
// (56). Phase 1: detect motorised covers (blinds/shutters) from the group
56+
// label. Only ever upgrades the lighting fallback — manual type_overrides
57+
// and application-id mappings always take precedence.
58+
ha_discovery_auto_type: true,
59+
ha_discovery_auto_type_name_heuristics: true,
60+
ha_discovery_auto_type_cover_keywords: [
61+
'blind', 'shutter', 'shade', 'awning', 'curtain', 'roller', 'garage door'
62+
],
5463
ha_discovery_hvac_app_id: null,
5564
ha_hvac_temperature_unit: 'C',
5665
ha_bridge_diagnostics_enabled: true,

src/deviceTypeClassifier.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Conservative keyword list for detecting motorised cover devices (blinds,
2+
// shutters, curtains, etc.) that are wired onto the C-Bus Lighting application
3+
// (56). The C-Gate tree cannot tell a shutter relay from a lighting relay, so
4+
// the group's name is the only automatic signal. Leading word-boundary match
5+
// (no trailing boundary) so plurals like "blinds"/"shutters" are caught.
6+
const DEFAULT_COVER_KEYWORDS = [
7+
'blind', 'shutter', 'shade', 'awning', 'curtain', 'roller', 'garage door'
8+
];
9+
10+
function escapeRegExp(s) {
11+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
12+
}
13+
14+
/**
15+
* Decide an effective HA discovery type for a Lighting-application group from
16+
* its resolved label. Returns 'cover' on a keyword match, otherwise null
17+
* (meaning "leave as the default light").
18+
*
19+
* @param {string} label - The resolved group label (custom label or TREEXML label).
20+
* @param {object} settings - Bridge settings.
21+
* @returns {'cover'|null}
22+
*/
23+
function classifyLightingGroup(label, settings = {}) {
24+
if (settings.ha_discovery_auto_type === false) return null;
25+
if (settings.ha_discovery_auto_type_name_heuristics === false) return null;
26+
if (typeof label !== 'string' || !label.trim()) return null;
27+
28+
const keywords = (Array.isArray(settings.ha_discovery_auto_type_cover_keywords)
29+
&& settings.ha_discovery_auto_type_cover_keywords.length)
30+
? settings.ha_discovery_auto_type_cover_keywords
31+
: DEFAULT_COVER_KEYWORDS;
32+
33+
for (const kw of keywords) {
34+
if (typeof kw !== 'string' || !kw.trim()) continue;
35+
const re = new RegExp(`\\b${escapeRegExp(kw.trim())}`, 'i');
36+
if (re.test(label)) return 'cover';
37+
}
38+
return null;
39+
}
40+
41+
module.exports = { classifyLightingGroup, DEFAULT_COVER_KEYWORDS };

src/haDiscovery.js

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const parseString = require('xml2js').parseString;
22
const { createLogger } = require('./logger');
33
const { getDiscoveryTypeForApp, getDiscoveryConfig } = require('./haDiscoveryConfigs');
4+
const { classifyLightingGroup } = require('./deviceTypeClassifier');
45
const { findNetworkData, collectUnitGroups } = require('./haDiscoveryTree');
56
const { backoffDelay } = require('./backoff');
67
const {
@@ -670,11 +671,20 @@ class HaDiscovery {
670671
return;
671672
}
672673

673-
const typeOverride = typeOverrides.get(labelKey);
674-
if (typeOverride && typeOverride !== 'light') {
675-
const config = getDiscoveryConfig(typeOverride);
674+
// Resolve an effective type. Manual type_overrides have absolute
675+
// priority; auto-detection only fills in when there is no override
676+
// and only ever upgrades the lighting fallback (it never returns
677+
// 'light'). Application-id mappings are handled in
678+
// _processEnableControlGroups and never reach this lighting path.
679+
const labelForClassification = labelMap.get(labelKey) || group.Label || '';
680+
let resolvedType = typeOverrides.get(labelKey);
681+
if (!resolvedType) {
682+
resolvedType = classifyLightingGroup(labelForClassification, this.settings);
683+
}
684+
if (resolvedType && resolvedType !== 'light') {
685+
const config = getDiscoveryConfig(resolvedType);
676686
if (config) {
677-
this.logger.debug(`Type override: ${labelKey} -> ${typeOverride}`);
687+
this.logger.debug(`Resolved type: ${labelKey} -> ${resolvedType}`);
678688
if (config.isHvac) {
679689
// HVAC needs the dedicated climate payload (temperature/mode
680690
// topics); the generic builder would publish a broken
@@ -685,15 +695,15 @@ class HaDiscovery {
685695
}
686696
// Remove any stale retained light config for this group.
687697
// This covers the case where the type changes within the same session
688-
// (e.g. first run saw it as a light; this run sees the type override).
698+
// (e.g. first run saw it as a light; this run resolves a non-light type).
689699
const uniqueId = `cgateweb_${networkId}_${appId}_${groupId}`;
690700
const staleTopic = `${this.settings.ha_discovery_prefix}/${HA_COMPONENT_LIGHT}/${uniqueId}/${HA_DISCOVERY_SUFFIX}`;
691701
this._publish(staleTopic, '', MQTT_RETAINED_STATE_OPTIONS);
692702
// Ensure the stale light topic is not retained in _publishedTopics
693703
this._publishedTopics.delete(staleTopic);
694704
return;
695705
}
696-
this.logger.warn(`Unknown type override "${typeOverride}" for ${labelKey}, falling back to light`);
706+
this.logger.warn(`Unknown resolved type "${resolvedType}" for ${labelKey}, falling back to light`);
697707
}
698708

699709
const customLabel = labelMap.get(labelKey);

tests/defaultSettings.test.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const { defaultSettings } = require('../src/defaultSettings');
2+
3+
describe('defaultSettings — auto device-type detection', () => {
4+
it('enables auto type detection and name heuristics by default', () => {
5+
expect(defaultSettings.ha_discovery_auto_type).toBe(true);
6+
expect(defaultSettings.ha_discovery_auto_type_name_heuristics).toBe(true);
7+
});
8+
9+
it('ships a non-empty default cover keyword list', () => {
10+
expect(Array.isArray(defaultSettings.ha_discovery_auto_type_cover_keywords)).toBe(true);
11+
expect(defaultSettings.ha_discovery_auto_type_cover_keywords).toContain('blind');
12+
});
13+
});

tests/deviceTypeClassifier.test.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
const {
2+
classifyLightingGroup,
3+
DEFAULT_COVER_KEYWORDS
4+
} = require('../src/deviceTypeClassifier');
5+
6+
describe('classifyLightingGroup', () => {
7+
const on = { ha_discovery_auto_type: true };
8+
9+
it('returns "cover" for blind/shutter labels (incl. plurals)', () => {
10+
expect(classifyLightingGroup('Master Bedroom Blind', on)).toBe('cover');
11+
expect(classifyLightingGroup('Living Blinds', on)).toBe('cover');
12+
expect(classifyLightingGroup('Patio Shutter', on)).toBe('cover');
13+
expect(classifyLightingGroup('Kitchen Curtains', on)).toBe('cover');
14+
expect(classifyLightingGroup('Front Awning', on)).toBe('cover');
15+
expect(classifyLightingGroup('Roller Door', on)).toBe('cover');
16+
expect(classifyLightingGroup('Garage Door', on)).toBe('cover');
17+
});
18+
19+
it('returns null for ordinary light labels', () => {
20+
expect(classifyLightingGroup('Kitchen Downlights', on)).toBeNull();
21+
expect(classifyLightingGroup('Hallway', on)).toBeNull();
22+
expect(classifyLightingGroup('Bedroom Lamp', on)).toBeNull();
23+
});
24+
25+
it('returns null for empty/invalid labels', () => {
26+
expect(classifyLightingGroup('', on)).toBeNull();
27+
expect(classifyLightingGroup(' ', on)).toBeNull();
28+
expect(classifyLightingGroup(undefined, on)).toBeNull();
29+
expect(classifyLightingGroup(null, on)).toBeNull();
30+
});
31+
32+
it('is disabled when auto_type is false', () => {
33+
expect(classifyLightingGroup('Master Blind', { ha_discovery_auto_type: false })).toBeNull();
34+
});
35+
36+
it('is disabled when name heuristics are turned off', () => {
37+
expect(classifyLightingGroup('Master Blind', {
38+
ha_discovery_auto_type: true,
39+
ha_discovery_auto_type_name_heuristics: false
40+
})).toBeNull();
41+
});
42+
43+
it('defaults to enabled when auto_type key is absent', () => {
44+
expect(classifyLightingGroup('Master Blind', {})).toBe('cover');
45+
});
46+
47+
it('honours a custom keyword list', () => {
48+
const s = { ha_discovery_auto_type: true, ha_discovery_auto_type_cover_keywords: ['persiana'] };
49+
expect(classifyLightingGroup('Persiana Salon', s)).toBe('cover');
50+
expect(classifyLightingGroup('Bedroom Blind', s)).toBeNull();
51+
});
52+
53+
it('exports the default keyword list', () => {
54+
expect(DEFAULT_COVER_KEYWORDS).toEqual(
55+
expect.arrayContaining(['blind', 'shutter', 'curtain', 'awning', 'roller', 'garage door', 'shade'])
56+
);
57+
});
58+
});

tests/haDiscovery.test.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1187,6 +1187,95 @@ describe('HaDiscovery', () => {
11871187
expect(payload.modes).toEqual(expect.arrayContaining(['off', 'auto', 'cool', 'heat', 'fan_only']));
11881188
});
11891189

1190+
describe('Auto device-type detection (cover name-heuristic)', () => {
1191+
function makeHa(settings, labelData) {
1192+
return new HaDiscovery(settings, mockPublishFn, mockSendCommandFn, labelData);
1193+
}
1194+
1195+
it('publishes a cover (not light) when a lighting group label looks like a blind', () => {
1196+
const settings = { ...mockSettings, ha_discovery_auto_type: true, ha_discovery_cover_app_id: null };
1197+
const labelData = {
1198+
labels: new Map([['254/56/10', 'Master Bedroom Blinds']]),
1199+
typeOverrides: new Map(),
1200+
entityIds: new Map(),
1201+
exclude: new Set()
1202+
};
1203+
const ha = makeHa(settings, labelData);
1204+
ha._publishDiscoveryFromTree('254', MOCK_TREEXML_RESULT_NET254);
1205+
1206+
const coverCall = mockPublishFn.mock.calls.find(
1207+
c => c[0] === 'testhomeassistant/cover/cgateweb_254_56_10/config'
1208+
);
1209+
expect(coverCall).toBeDefined();
1210+
const payload = JSON.parse(coverCall[1]);
1211+
expect(payload.device.name).toBe('Master Bedroom Blinds');
1212+
1213+
// The stale light config must be cleared with an empty retained payload.
1214+
const lightCall = mockPublishFn.mock.calls.find(
1215+
c => c[0] === 'testhomeassistant/light/cgateweb_254_56_10/config'
1216+
);
1217+
expect(lightCall).toBeDefined();
1218+
expect(lightCall[1]).toBe('');
1219+
});
1220+
1221+
it('leaves ordinary lighting labels as lights', () => {
1222+
const settings = { ...mockSettings, ha_discovery_auto_type: true };
1223+
const labelData = {
1224+
labels: new Map([['254/56/10', 'Kitchen Downlights']]),
1225+
typeOverrides: new Map(), entityIds: new Map(), exclude: new Set()
1226+
};
1227+
const ha = makeHa(settings, labelData);
1228+
ha._publishDiscoveryFromTree('254', MOCK_TREEXML_RESULT_NET254);
1229+
1230+
const lightCall = mockPublishFn.mock.calls.find(
1231+
c => c[0] === 'testhomeassistant/light/cgateweb_254_56_10/config'
1232+
);
1233+
expect(lightCall).toBeDefined();
1234+
expect(lightCall[1]).not.toBe('');
1235+
const coverCall = mockPublishFn.mock.calls.find(
1236+
c => c[0] === 'testhomeassistant/cover/cgateweb_254_56_10/config'
1237+
);
1238+
expect(coverCall).toBeUndefined();
1239+
});
1240+
1241+
it('does nothing when auto_type is disabled (no behaviour change)', () => {
1242+
const settings = { ...mockSettings, ha_discovery_auto_type: false };
1243+
const labelData = {
1244+
labels: new Map([['254/56/10', 'Master Bedroom Blinds']]),
1245+
typeOverrides: new Map(), entityIds: new Map(), exclude: new Set()
1246+
};
1247+
const ha = makeHa(settings, labelData);
1248+
ha._publishDiscoveryFromTree('254', MOCK_TREEXML_RESULT_NET254);
1249+
1250+
const lightCall = mockPublishFn.mock.calls.find(
1251+
c => c[0] === 'testhomeassistant/light/cgateweb_254_56_10/config'
1252+
);
1253+
expect(lightCall).toBeDefined();
1254+
expect(lightCall[1]).not.toBe('');
1255+
});
1256+
1257+
it('manual type_overrides win over auto-detection', () => {
1258+
// Label looks like a blind, but the user explicitly forces 'switch'.
1259+
const settings = { ...mockSettings, ha_discovery_auto_type: true };
1260+
const labelData = {
1261+
labels: new Map([['254/56/10', 'Master Bedroom Blinds']]),
1262+
typeOverrides: new Map([['254/56/10', 'switch']]),
1263+
entityIds: new Map(), exclude: new Set()
1264+
};
1265+
const ha = makeHa(settings, labelData);
1266+
ha._publishDiscoveryFromTree('254', MOCK_TREEXML_RESULT_NET254);
1267+
1268+
const switchCall = mockPublishFn.mock.calls.find(
1269+
c => c[0] === 'testhomeassistant/switch/cgateweb_254_56_10/config'
1270+
);
1271+
expect(switchCall).toBeDefined();
1272+
const coverCall = mockPublishFn.mock.calls.find(
1273+
c => c[0] === 'testhomeassistant/cover/cgateweb_254_56_10/config'
1274+
);
1275+
expect(coverCall).toBeUndefined();
1276+
});
1277+
});
1278+
11901279
it('should inject default_entity_id with domain prefix when entity_ids has an entry', () => {
11911280
const labelData = {
11921281
labels: new Map([['254/56/10', 'Kitchen']]),

0 commit comments

Comments
 (0)