diff --git a/README.md b/README.md index d86b158..d621875 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,6 @@ [![hacs_badge][hacs_shield]][hacs] [![GitHub Latest Release][releases_shield]][latest_release] -[hacs_shield]: https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge -[hacs]: https://github.com/hacs/integration - -[releases_shield]: https://img.shields.io/github/release/igor-panteleev/lovelace-qr-code-card.svg?style=for-the-badge -[latest_release]: https://github.com/igor-panteleev/lovelace-qr-code-card/releases/latest - # Lovelace QRCode Generator card This card provides a possibility to generate QRCode in Home Assistant interface. @@ -19,88 +13,46 @@ This card provides a possibility to generate QRCode in Home Assistant interface. ## Configuration - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
KeyTypeRequiredDefaultDescription
- General options -
titlestringnoemptyTitle for the card
sourcestringyestextCard source type. Options: text, entity, wifi
- Text mode options -
textstringyesQRCode example textText that will be used for QRCode generation
- Entity mode options -
entitystringyesemptyEntity that will be used for QRCode generation
- Wi-Fi mode options -
auth_typestringyesemptyWi-Fi network authentication type. Options: WEP, WPA, nopass
ssidstringyesemptyWi-Fi network ssid
passwordstringyes (except nopass authentication)emptyWi-Fi network password
is_hiddenbooleannoemptyIs Wi-Fi network is hidden
+### Main config + +| Key | Type | Required | Default | Description | +|-----------------------|------------------------------------------|-----------------|---------------------|-------------------------------------------------------------------------| +| *Generic options* | +| `title` | string | no | empty | Title for the card | +| `source` | string | yes | `text` | Card source type.
Options: `text,` `entity`, `wifi` | +| *Text mode options* | +| `text` | string | yes | QRCode example text | Text that will be used for QRCode generation | +| *Entity mode options* | +| `entity` | string | yes | empty | Entity that will be used for QRCode generation | +| *Wi-Fi mode options* | +| `auth_type` | string | yes | empty | Wi-Fi network authentication type.
Options: `WEP`, `WPA`, `nopass` | +| `ssid` | string \| [EntityConfig](#entity-config) | yes | empty | Wi-Fi network ssid | +| `password` | string \| [EntityConfig](#entity-config) | yes1 | empty | Wi-Fi network password | +| `is_hidden` | boolean | no | empty | Is Wi-Fi network is hidden | + +1Required for `WEP` and `WPA` authentication + +### Entity Config + +| Key | Type | Required | Description | +|-------------|--------|----------|--------------------------------------------------------------------------| +| `entity` | string | yes | Entity to get state from | +| `attribute` | string | no | Enables usage of a configured attribute instead of state of given entity | + + +### Example WiFi config +```yaml +type: custom:qr-code-card +source: wifi +title: My Awesom WiFi +auth_type: WPA +ssid: my_awesom_wifi +password: + entity: input_text.my_super_secure_password +``` + +[hacs_shield]: https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge +[hacs]: https://github.com/hacs/integration + +[releases_shield]: https://img.shields.io/github/release/igor-panteleev/lovelace-qr-code-card.svg?style=for-the-badge +[latest_release]: https://github.com/igor-panteleev/lovelace-qr-code-card/releases/latest diff --git a/package.json b/package.json index 42159f5..6881984 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "qr-code-card", - "version": "v1.1.0", + "version": "v1.2.0", "description": "QR code card", "keywords": [ "home-assistant", diff --git a/src/editor.ts b/src/editor.ts index 317a7a8..2579b55 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -12,7 +12,7 @@ import { } from "./types/types"; import { localizeWithHass } from "./localize/localize"; import { SourceType } from "./models/source-type"; -import { AuthenticationType, is_password_protected } from "./models/authentication-type"; +import { AuthenticationType, isPasswordProtected } from "./models/authentication-type"; import { EDITOR_CUSTOM_ELEMENT_NAME } from "./const"; @@ -54,24 +54,39 @@ export class QRCodeCardEditor extends LitElement implements LovelaceCardEditor { get _ssid(): string { const config = this._config as WiFiSourceConfig | undefined; - return config?.ssid || ""; + if (typeof config?.ssid === "string") { + return config?.ssid || ""; + } + return "" } get _password(): string { const config = this._config as WiFiSourceConfig | undefined; - return config?.password || ""; + if (typeof config?.password === "string") { + return config?.password || ""; + } + return ""; } get _is_hidden(): boolean { const config = this._config as WiFiSourceConfig | undefined; return config?.is_hidden || false; } + + get _is_debug(): boolean { + const config = this._config as QRCodeCardConfig | undefined; + return config?.debug || false; + } get _entity(): string { const config = this._config as EntitySourceConfig | undefined; return config?.entity || "" } + private _isDisabled(): boolean { + return this._config?.source === SourceType.WIFI && (typeof this._config?.ssid !== "string" || typeof this._config?.password !== "string"); + } + private _localize(ts: TranslatableString): string { return localizeWithHass(ts, this.hass, this._config); } @@ -81,6 +96,13 @@ export class QRCodeCardEditor extends LitElement implements LovelaceCardEditor { return html``; } + if (this._isDisabled()) { + return html` +
+
${this._localize("editor.yaml_mode")}
+
`; + } + const entities = Object.keys(this.hass.states); return html` @@ -139,7 +161,7 @@ export class QRCodeCardEditor extends LitElement implements LovelaceCardEditor { .configValue=${"ssid"} @input=${this._valueChanged}> - ${is_password_protected(this._auth_type) ? html` + ${isPasswordProtected(this._auth_type) ? html`
` : ""} + +
+ + + +
`; } @@ -250,6 +281,10 @@ export class QRCodeCardEditor extends LitElement implements LovelaceCardEditor { color: var(--secondary-text-color); direction: var(--direction); } + + .error { + color: var(--error-color); + } `; } } diff --git a/src/generator.ts b/src/generator.ts new file mode 100644 index 0000000..f292e1d --- /dev/null +++ b/src/generator.ts @@ -0,0 +1,18 @@ +import QRCode from "qrcode"; + +import { DataUrl } from "./types/types"; +import { TranslatableError } from "./models/error"; + +const quality = { + margin: 1, + width: 500 +} + +export async function generateQR(inputString: string): Promise { + try { + return QRCode.toDataURL(inputString, quality); + } + catch (e: unknown) { + throw new TranslatableError("generation.unknown_error") + } +} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index d8399fb..fada52d 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -13,7 +13,8 @@ "auth_type": "Authentication type (required)", "ssid": "SSID (required)", "password": "Password (required)", - "is_hidden": "Hidden SSID" + "is_hidden": "Hidden SSID", + "is_debug": "Debug mode (Show value for QR code)" }, "title": { "show_password": "Show password", @@ -30,9 +31,13 @@ "WPA": "WPA", "nopass": "None" } - } + }, + "yaml_mode": "Configuring SSID and password from entity is not supported in visual editor mode. Please switch to code editor mode." }, "validation": { + "debug": { + "invalid": "Debug mode must be true or false" + }, "source": { "missing": "Missing property: source", "invalid": "Invalid source type" @@ -45,16 +50,29 @@ "invalid": "Invalid authentication type" }, "ssid": { - "missing": "Missing property: ssid" + "missing": "Missing property: ssid", + "unknown_type": "Unsupported data type for SSID: {type}", + "unknown_entity": "Entity specified for SSID is unknown: {entity}", + "unknown_attribute": "Attribute specified for SSID is unknown: {attribute}", + "unavailable": "Entity specified for SSID is unavailable: {entity}" }, "password": { - "missing": "Missing property: password" + "missing": "Missing property: password", + "unknown_type": "Unsupported data type for password: {type}", + "unknown_entity": "Entity specified for password is unknown: {entity}", + "unknown_attribute": "Attribute specified for password is unknown: {attribute}", + "unavailable": "Entity specified for password is unavailable: {entity}" }, "entity": { - "missing": "Missing property: entity" + "missing": "Missing property: entity", + "unknown_type": "Unsupported data type for entity: {type}", + "unknown_entity": "Unknown entity: {entity}", + "unknown_attribute": "Attribute specified for entity is unknown: {attribute}", + "unavailable": "Entity is unavailable: {entity}" } }, "generation": { - "error": "An error occurred while generating QR-Code" + "error": "An error occurred while generating QR-Code: {message}", + "unknown_error": "An error occurred while generating QR-Code" } } diff --git a/src/models/authentication-type.ts b/src/models/authentication-type.ts index c3369d4..e3da799 100644 --- a/src/models/authentication-type.ts +++ b/src/models/authentication-type.ts @@ -6,6 +6,6 @@ export enum AuthenticationType { const PasswordAuthenticationTypes = [AuthenticationType.WEP, AuthenticationType.WPA]; -export function is_password_protected(auth_type: AuthenticationType | undefined): boolean { +export function isPasswordProtected(auth_type: AuthenticationType | undefined): boolean { return auth_type !== undefined && PasswordAuthenticationTypes.includes(auth_type); } diff --git a/src/models/data-builder.ts b/src/models/data-builder.ts new file mode 100644 index 0000000..3b9f30e --- /dev/null +++ b/src/models/data-builder.ts @@ -0,0 +1,131 @@ +import { + EntitySourceConfig, + QRCodeCardConfig, + QRCodeGeneratorClass, + TextSourceConfig, + WiFiSourceConfig, +} from "../types/types"; +import { isPasswordProtected } from "./authentication-type"; +import { SourceType } from "./source-type"; +import { HomeAssistant } from "custom-card-helpers"; +import { TranslatableError } from "./error"; + + +abstract class QRCodeDataBuilder { + + protected readonly config: T; + protected readonly hass: HomeAssistant; + + public constructor(hass: HomeAssistant, config: T) { + this.hass = hass; + this.config = config; + } + + public getInputString(): string { + try { + return this._getInputString(); + } + catch (e: unknown) { + if (e instanceof TranslatableError) { + throw e + } else if (e instanceof Error) { + throw new TranslatableError(["generation.error", "{message}", e.message]) + } + throw new TranslatableError("generation.unknown_error") + } + } + + protected abstract _getInputString(): string + + protected _getValueFromConfig(property: string): string { + let result: string; + + const configProperty = this.config[property]; + if (configProperty === undefined) { + throw new TranslatableError(`validation.${property}.missing`) + } else if (typeof configProperty === "string") { + result = configProperty; + } else if (configProperty.hasOwnProperty("entity")) { + const entity = this.hass?.states[configProperty.entity] + if (entity === undefined) { + throw new TranslatableError([`validation.${property}.unknown_entity`, "{entity}", configProperty.entity]) + } + if (configProperty.attribute !== undefined) { + const attribute_value = entity.attributes[configProperty.attribute]; + if (attribute_value === undefined) { + throw new TranslatableError([`validation.${property}.unknown_attribute`, "{attribute}", configProperty.attribute]) + } + result = attribute_value.toString(); + } else { + const state = entity.state; + if (state === "unavailable") { + throw new TranslatableError([`validation.${property}.unavailable`, "{entity}", configProperty.entity]) + } + result = state; + } + } else { + throw new TranslatableError([`validation.${property}.unknown_type`, "{type}", typeof configProperty]) + } + + return result; + } +} + + +class TextQRCodeDataBuilder extends QRCodeDataBuilder { + + protected _getInputString(): string { + return this.config.text || ""; + } +} + + +class WiFiQRCodeDataBuilder extends QRCodeDataBuilder { + protected readonly special_chars = ['\\', ';', ',', '"', ':'] + + protected _escape(plain: string): string { + return this.special_chars.reduce( + (previousValue, currentValue) => { + return previousValue.replace(currentValue, '\\'+currentValue) + }, + plain + ); + } + + protected _getInputString(): string { + const ssid = this._getValueFromConfig("ssid"); + let text = `WIFI:T:${this.config.auth_type || ""};S:${this._escape(ssid)};`; + + if (isPasswordProtected(this.config.auth_type)) { + const password = this._getValueFromConfig("password"); + text += `P:${this._escape(password)};` + } + + if (this.config.is_hidden) { + text += "H:true" + } + + return text; + } +} + +class EntityQRCodeDataBuilder extends QRCodeDataBuilder { + protected _getInputString(): string { + return this._getValueFromConfig("entity") + } +} + +const configBuilderMapping = new Map>>([ + [SourceType.TEXT, TextQRCodeDataBuilder], + [SourceType.WIFI, WiFiQRCodeDataBuilder], + [SourceType.ENTITY, EntityQRCodeDataBuilder] +]); + + +export function getInputString(hass: HomeAssistant, config: QRCodeCardConfig): string { + const dataBuilderCls = configBuilderMapping.get(config.source); + + if (!dataBuilderCls) throw new TranslatableError("validation.source.invalid"); + + return new dataBuilderCls(hass, config).getInputString(); +} diff --git a/src/models/error.ts b/src/models/error.ts new file mode 100644 index 0000000..b9ecf3b --- /dev/null +++ b/src/models/error.ts @@ -0,0 +1,8 @@ +import { TranslatableString } from "../types/types"; +import { localize } from "../localize/localize"; + +export class TranslatableError extends Error { + public constructor(ts: TranslatableString) { + super(localize(ts)); + } +} diff --git a/src/models/generator.ts b/src/models/generator.ts deleted file mode 100644 index 8b02376..0000000 --- a/src/models/generator.ts +++ /dev/null @@ -1,100 +0,0 @@ -import QRCode from "qrcode"; - -import { - DataUrl, EntitySourceConfig, - QRCodeCardConfig, - QRCodeGeneratorClass, - TextSourceConfig, - WiFiSourceConfig, -} from "../types/types"; -import { is_password_protected } from "./authentication-type"; -import { SourceType } from "./source-type"; -import { localize } from "../localize/localize"; -import { HomeAssistant } from "custom-card-helpers"; - - -abstract class QRCodeGenerator { - - protected readonly config: T; - protected readonly hass: HomeAssistant; - - // TODO: make it configurable - protected readonly quality = { - margin: 1, - width: 500 - } - - public constructor(hass: HomeAssistant, config: T) { - this.hass = hass; - this.config = config; - } - - public async generate(): Promise { - try { - return QRCode.toDataURL(this.input, this.quality); - } - catch (e: any) { - throw new Error(localize("generation.error")) - } - } - - protected abstract get input(): string - -} - - -class TextQRCodeGenerator extends QRCodeGenerator { - - protected get input(): string { - return this.config.text || ""; - } -} - - -class WiFiQRCodeGenerator extends QRCodeGenerator { - protected readonly special_chars = ['\\', ';', ',', '"', ':'] - - protected _escape(plain: string): string { - return this.special_chars.reduce( - (previousValue, currentValue) => { - return previousValue.replace(currentValue, '\\'+currentValue) - }, - plain - ); - } - - protected get input(): string { - let text = `WIFI:T:${this.config.auth_type || ""};S:${this._escape(this.config.ssid || "")};`; - - if (is_password_protected(this.config.auth_type)) { - text += `P:${this._escape(this.config.password || "")};` - } - - if (!this.config.is_hidden) { - text += "H:true" - } - - return text; - } -} - -class EntityQRCodeGenerator extends QRCodeGenerator { - protected get input(): string { - return this.hass?.states[this.config.entity].state - } -} - -const generatorMap = new Map>>([ - [SourceType.TEXT, TextQRCodeGenerator], - [SourceType.WIFI, WiFiQRCodeGenerator], - [SourceType.ENTITY, EntityQRCodeGenerator] -]); - - -export async function generateQR(hass: HomeAssistant, config: QRCodeCardConfig): Promise { - const generatorCls = generatorMap.get(config.source); - - if (!generatorCls) throw new Error(localize("validation.source.invalid")); - - return await (new generatorCls(hass, config)).generate(); -} diff --git a/src/qr-code-card.ts b/src/qr-code-card.ts index 33ce68c..d31af85 100644 --- a/src/qr-code-card.ts +++ b/src/qr-code-card.ts @@ -9,10 +9,12 @@ import type {QRCodeCardConfig, TranslatableString } from "./types/types"; import { localize, localizeWithHass } from "./localize/localize"; import { getWatchedEntities, hasConfigOrAnyEntityChanged } from "./utils" import { version } from '../package.json'; -import { generateQR } from "./models/generator"; +import { getInputString } from "./models/data-builder"; +import { generateQR } from "./generator"; import { validateConfig } from "./validators"; import { SourceType } from "./models/source-type"; import { CARD_CUSTOM_ELEMENT_NAME, EDITOR_CUSTOM_ELEMENT_NAME } from "./const"; +import { TranslatableError } from "./models/error"; console.info( `%c QR-CODE-GENERATOR %c ${version} `, @@ -46,6 +48,7 @@ export class QRCodeCard extends LitElement { private config!: QRCodeCardConfig; private watchedEntities: string[] = []; + private inputString!: string; @property({ attribute: false }) public _hass!: HomeAssistant; @state() private errors: string[] = []; @state() private dataUrl = ""; @@ -75,12 +78,13 @@ export class QRCodeCard extends LitElement { private async _updateQR(): Promise { try { - this.dataUrl = await generateQR(this.hass, this.config); + this.inputString = getInputString(this.hass, this.config) + this.dataUrl = await generateQR(this.inputString); } catch (e: unknown) { - if (e instanceof Error) { + if (e instanceof TranslatableError) { this.errors = [e.message] } else { - this.errors = ['An unknown error occurred']; + this.errors = [this._localize("generation.unknown_error")]; } } } @@ -97,13 +101,15 @@ export class QRCodeCard extends LitElement { } protected async update(changedProperties: PropertyValues): Promise { - await this._updateQR(); + if (this.errors.length == 0) { + await this._updateQR(); + } super.update(changedProperties); } protected render(): TemplateResult { if (this.errors.length > 0) { - return this._showErrors(this.errors); + return this._showErrors(); } if (!this.dataUrl) { @@ -117,6 +123,7 @@ export class QRCodeCard extends LitElement { return html` ${(this.config?.title ?? "").length > 0 ? html`

${this.config.title}

`: ""} + ${(this.config?.debug ?? false) ? html`

Input string: ${this.inputString}

`: ""}
@@ -124,15 +131,16 @@ export class QRCodeCard extends LitElement { ` } - private _showErrors(errors: string[]): TemplateResult { - errors.forEach(e => console.error(e)); + private _showErrors(): TemplateResult { + this.errors.forEach(e => console.error(e)); const errorCard = document.createElement("hui-error-card") as LovelaceCard; - errorCard.setConfig({ - type: "error", - error: errors[0], - origConfig: this.config, + customElements.whenDefined("hui-error-card").then(() => { + errorCard.setConfig({ + type: "error", + error: this.errors[0], + origConfig: this.config, + }); }); - return html` ${errorCard} `; } @@ -147,8 +155,8 @@ export class QRCodeCard extends LitElement { flex-direction: column; flex: 1; position: relative; - padding: 0px; - border-radius: 4px; + padding: 0; + border-radius: 6px; overflow: hidden; } .qrcode { diff --git a/src/types/types.ts b/src/types/types.ts index 274fce7..6bf762a 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -19,8 +19,14 @@ export type DataUrl = string; export type QRCodeGeneratorClass = new (...args: any[]) => T; export type QRCodeValidatorClass = new (...args: any[]) => T; +export interface EntityConfig { + readonly entity: string; + readonly attribute?: string; +} + export interface BaseQRCodeCardConfig extends LovelaceCardConfig { readonly language?: Language; + readonly debug?: boolean; readonly title?: string; readonly source: SourceType; } @@ -31,8 +37,8 @@ export interface TextSourceConfig extends BaseQRCodeCardConfig { export interface WiFiSourceConfig extends BaseQRCodeCardConfig { readonly auth_type: AuthenticationType; - readonly ssid: string; - readonly password?: string; + readonly ssid: string | EntityConfig; + readonly password?: string | EntityConfig; readonly is_hidden?: boolean; } diff --git a/src/utils.ts b/src/utils.ts index e5c173f..a598446 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,6 +2,7 @@ import { PropertyValues } from "@lit/reactive-element"; import { HomeAssistant } from "custom-card-helpers"; import { QRCodeCardConfig } from "./types/types" import { SourceType } from "./models/source-type"; +import { TranslatableError } from "./models/error"; export function hasConfigOrAnyEntityChanged( watchedEntities: string[], @@ -24,11 +25,11 @@ export function getWatchedEntities(config: QRCodeCardConfig): string[] { watchedEntities.add(config.entity); break; - // TODO: add support to use entities for WIFI QR code - // case SourceType.WIFI: - // if (config.ssid.hasOwnProperty("entity")) watchedEntities.add(config.ssid["entity"]); - // if (config.password.hasOwnProperty("entity")) watchedEntities.add(config.password["entity"]); + case SourceType.WIFI: + if (config.ssid.hasOwnProperty("entity")) watchedEntities.add(config.ssid["entity"]); + if (config.password.hasOwnProperty("entity")) watchedEntities.add(config.password["entity"]); } return [...watchedEntities]; } + diff --git a/src/validators.ts b/src/validators.ts index 480d1ac..42c7a72 100644 --- a/src/validators.ts +++ b/src/validators.ts @@ -8,7 +8,7 @@ import { } from "./types/types"; import { localize } from "./localize/localize"; import { SourceType } from "./models/source-type"; -import { AuthenticationType, is_password_protected } from "./models/authentication-type"; +import { AuthenticationType, isPasswordProtected } from "./models/authentication-type"; abstract class Validator { @@ -27,6 +27,18 @@ abstract class Validator { } +class DebugModeValidator extends Validator { + protected _validate(): string[] { + const errors: string[] = []; + + if (this.config.debug !== undefined && typeof this.config.debug !== "boolean") { + errors.push("validation.debug.invalid"); + } + + return errors; + } +} + class SourceValidator extends Validator { protected _validate(): string[] { const errors: string[] = []; @@ -71,11 +83,17 @@ class WiFiValidator extends Validator { // Validate ssid if (!this.config.ssid) { errors.push("validation.ssid.missing"); + } else if (typeof this.config.ssid !== "string" && !this.config.ssid.hasOwnProperty("entity")) { + errors.push("validation.ssid.entity.missing") } // Validate password - if (is_password_protected(this.config.auth_type) && !this.config.password) { - errors.push("validation.password.missing"); + if (isPasswordProtected(this.config.auth_type)) { + if (!this.config.password) { + errors.push("validation.password.missing"); + } else if (typeof this.config.password !== "string" && !this.config.password.hasOwnProperty("entity")) { + errors.push("validation.password.entity.missing") + } } return errors; @@ -102,6 +120,7 @@ export function validateConfig(config: QRCodeCardConfig): string[] { const errors: TranslatableString[] = []; new SourceValidator(config).validate().forEach(e => errors.push(e)); + new DebugModeValidator(config).validate().forEach(e => errors.push(e)); if (errors.length == 0) { const validatorCls = validatorMap.get(config.source);