homebridge-homeconnect
Version:
A Homebridge plugin that connects Home Connect appliances to Apple HomeKit
179 lines • 8.45 kB
JavaScript
// Homebridge plugin for Home Connect home appliances
// Copyright © 2023-2025 Alexander Thoukydides
import assert from 'node:assert';
import { assertIsDefined, assertIsString, formatList, plural } from './utils.js';
import { logError } from './log-error.js';
// Service name length in Unicode characters (code points, not octets or code units)
const SERVICE_LENGTH_MIN = 1;
const SERVICE_LENGTH_MAX = 250; // (Name is limited to 64, but ConfiguredName can be longer)
// Characters allowed in a service name (requires RegExp with 'u' flag)
const SERVICE_CHAR_END = /\p{L}|\p{Nd}/u; // (alphabetic or numeric)
const SERVICE_CHAR_ANY = /\p{L}|\p{Nd}|[- '&,.]/u; // (alphanumeric or punctuation)
const SERVICE_CHAR_EMOJI = /\p{Extended_Pictographic}/u;
// HomeKit service naming for an accessory
export class ServiceNames {
appliance;
// Shortcuts to Homebridge API
Service;
Characteristic;
// Logger
log;
// Persistent storage
persist;
// Service names set via HomeKit (which should not be overwritten)
customNames = new Map();
busyPromise;
// Construct a service naming service
constructor(appliance) {
this.appliance = appliance;
this.Service = appliance.Service;
this.Characteristic = appliance.Characteristic;
this.log = appliance.log;
// Load any previous custom names
assertIsDefined(this.appliance.platform.persist);
this.persist = this.appliance.platform.persist;
this.busyPromise = this.loadCustomNames();
}
// Add a Configured Name characteristic to the service
addConfiguredName(service, suffix, subtype) {
assert.notStrictEqual(suffix, '');
const description = `${suffix} Service`;
const defaultName = this.makeServiceName(suffix, subtype);
// Add the Configured Name characteristic
if (!service.testCharacteristic(this.Characteristic.ConfiguredName)) {
service.addOptionalCharacteristic(this.Characteristic.ConfiguredName);
service.setCharacteristic(this.Characteristic.Name, defaultName);
service.setCharacteristic(this.Characteristic.ConfiguredName, defaultName);
}
const characteristic = service.getCharacteristic(this.Characteristic.ConfiguredName);
characteristic.setProps({ perms: ["ev" /* Perms.NOTIFY */, "pr" /* Perms.PAIRED_READ */, "pw" /* Perms.PAIRED_WRITE */] });
// Current and default names
assertIsString(characteristic.value);
let currentName = characteristic.value;
// Set the initial value (asynchronously)
this.withCustomNames('read-only', () => {
if (currentName === this.customNames.get(suffix)) {
// Name was set via HomeKit, so preserve it
this.log.debug(`Preserving ${description} name "${currentName}" set via HomeKit`);
}
else {
// Probably not changed by the user via HomeKit, so set explicitly
if (currentName !== defaultName) {
if (currentName === '')
this.log.debug(`Naming ${description} as "${defaultName}"`);
else
this.log.info(`Renaming ${description} to "${defaultName}" (was "${currentName}")`);
}
characteristic.updateValue(defaultName);
currentName = defaultName;
}
});
// Monitor changes to the name
characteristic.onSet(this.appliance.onSetString(async (name) => {
await this.withCustomNames('read-write', () => {
if (name !== currentName) {
this.log.info(`SET ${description} name to "${name}" (was "${currentName}")`);
currentName = name;
if (name !== defaultName) {
if (this.customNames.get(suffix) === undefined)
this.log.info(`HomeKit override on ${suffix} service name`);
this.customNames.set(suffix, name);
}
else {
this.log.info(`Removing HomeKit override on ${suffix} service name`);
this.customNames.delete(suffix);
}
}
});
}));
}
// Construct the display name for a service
makeServiceName(suffix, subtype) {
assert.notStrictEqual(suffix, '');
// Check whether the appliance name should be used as a prefix
const prefixConfig = this.appliance.config.names?.prefix;
const applyPrefix = subtype?.startsWith('program ') ? (prefixConfig?.programs ?? false)
: (prefixConfig?.other ?? true);
// Construct the service name
const accessoryName = this.appliance.accessory.displayName;
const name = applyPrefix ? `${accessoryName} ${suffix}` : suffix;
this.validateServiceName(suffix, name);
return name;
}
// Validate a service name (log warnings, but do not apply any fixes)
validateServiceName(suffix, name) {
// Check the length in characters (Unicode code units)
const issues = [];
const characters = name.split('');
const length = characters.length;
if (length < SERVICE_LENGTH_MIN)
issues.push(`too short (${length} < ${SERVICE_LENGTH_MIN})`);
if (SERVICE_LENGTH_MAX < length)
issues.push(`too long (${length} > ${SERVICE_LENGTH_MAX})`);
// Check for invalid characters
const firstChar = characters.shift();
const lastChar = characters.pop();
const firstCharIssue = this.validateCharacters(SERVICE_CHAR_END, [firstChar], 'invalid first character');
const otherCharIssue = this.validateCharacters(SERVICE_CHAR_ANY, characters, 'invalid character');
const lastCharIssue = this.validateCharacters(SERVICE_CHAR_END, [lastChar], 'invalid last character');
// If there were any issues then issue a summary warning
const defined = (items) => items.filter(item => item !== undefined);
issues.push(...defined([firstCharIssue, otherCharIssue, lastCharIssue]));
if (issues.length)
this.log.warn(`Invalid ${suffix} service name "${name}": ${formatList(issues)}`);
// Log Apple's guidance for any matching issues
if (otherCharIssue)
this.log.warn(' Use only alphanumeric, space, and apostrophe characters');
if (firstCharIssue || lastCharIssue)
this.log.warn(' Start and end with an alphabetic or numeric character');
if (SERVICE_CHAR_EMOJI.test(name))
this.log.warn(" Don't include emojis");
// Return whether the name passed all checks
return issues.length === 0;
}
// Validate characters (Unicode code units) in a service name
validateCharacters(regexp, characters, issue) {
const badCharacters = characters.filter(c => c !== undefined && !regexp.test(c));
if (!badCharacters.length)
return;
return `${plural(badCharacters.length, issue, false)} (${formatList(badCharacters.map(c => `"${c}"`))})`;
}
// Perform an operation using the custom names
async withCustomNames(type, operation) {
while (this.busyPromise)
await this.busyPromise;
operation();
if (type === 'read-write') {
this.busyPromise = this.saveCustomNames();
await this.busyPromise;
}
}
// Restore any previous custom names
async loadCustomNames() {
try {
const persist = await this.persist.getItem('custom service names');
if (persist)
this.customNames = new Map(Object.entries(persist.customNames));
}
catch (err) {
logError(this.log, 'Load custom names', err);
}
finally {
this.busyPromise = undefined;
}
}
// Save changes to the custom names
async saveCustomNames() {
try {
const persist = { customNames: Object.fromEntries(this.customNames) };
await this.persist.setItem('custom service names', persist);
}
catch (err) {
logError(this.log, 'Save custom names', err);
}
finally {
this.busyPromise = undefined;
}
}
}
//# sourceMappingURL=service-name.js.map