Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ import "./sip-core";
import "./sip-call-dialog";
import "./sip-call-card";
import "./sip-contacts-card";
import "./sip-user-card";
87 changes: 87 additions & 0 deletions src/sip-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,28 @@ export interface User {
password: string;
}

/**
* Per-device SIP credentials stored in localStorage under 'sipcore-force-user'.
* When present, bypasses Home Assistant user matching entirely.
*
* Set on a device via the browser console (one-time setup):
* @example
* localStorage.setItem('sipcore-force-user', JSON.stringify({
* extension: "100",
* password: "mypassword",
* display_name: "Front Door Panel" // optional
* }));
*
* Clear to revert to normal HA user matching:
* @example
* localStorage.removeItem('sipcore-force-user');
*/
export interface ForceUser {
extension: string;
password: string;
display_name?: string;
}

export interface ICEConfig extends RTCConfiguration {
/** Timeout in milliseconds for ICE gathering */
iceGatheringTimeout?: number;
Expand Down Expand Up @@ -248,6 +270,40 @@ export class SIPCore {
}
console.debug(`Audio input set to ${deviceId}`);
}

/**
* Per-device forced SIP user credentials, stored in localStorage.
* When set, this device will always register with these credentials,
* ignoring the global `users` list and the logged-in HA user.
*
* Set: `localStorage.setItem('sipcore-force-user', JSON.stringify({ extension, password, display_name? }))`
* Clear: `localStorage.removeItem('sipcore-force-user')`
*/
get ForceUser(): ForceUser | null {
const raw = localStorage.getItem("sipcore-force-user");
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (typeof parsed.extension === "string" && typeof parsed.password === "string") {
return parsed as ForceUser;
}
console.warn("sipcore-force-user in localStorage is missing required fields (extension, password). Ignoring.");
return null;
} catch {
console.warn("sipcore-force-user in localStorage is not valid JSON. Ignoring.");
return null;
}
}

set ForceUser(forceUser: ForceUser | null) {
if (forceUser === null) {
localStorage.removeItem("sipcore-force-user");
console.info("sipcore-force-user cleared. Will use HA user matching on next reload.");
} else {
localStorage.setItem("sipcore-force-user", JSON.stringify(forceUser));
console.info(`sipcore-force-user set to extension ${forceUser.extension}. Reload to apply.`);
Comment on lines +301 to +304
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

The setter logs "Reload to apply" (line 304), but the card's setConfig method immediately calls applyForceUser() which applies changes without requiring a reload. This creates inconsistent messaging. Update the log message to reflect that the change will be applied immediately when using the card, or remove the misleading "Reload to apply" text.

Suggested change
console.info("sipcore-force-user cleared. Will use HA user matching on next reload.");
} else {
localStorage.setItem("sipcore-force-user", JSON.stringify(forceUser));
console.info(`sipcore-force-user set to extension ${forceUser.extension}. Reload to apply.`);
console.info("sipcore-force-user cleared. Will use HA user matching from now on.");
} else {
localStorage.setItem("sipcore-force-user", JSON.stringify(forceUser));
console.info(`sipcore-force-user set to extension ${forceUser.extension}. Changes will apply immediately when using the card.`);

Copilot uses AI. Check for mistakes.
}
}
Comment on lines +298 to +306
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

Storing plaintext SIP passwords in localStorage poses a significant security risk. localStorage is accessible to any JavaScript running on the same origin, making credentials vulnerable to XSS attacks. Consider encrypting the password before storage, or implement a more secure credential storage mechanism.

Copilot uses AI. Check for mistakes.
Comment on lines +298 to +306
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

The ForceUser setter accepts null to clear the value, but the getter returns ForceUser | null. However, the interface ForceUser requires both extension and password as non-optional strings, while display_name is optional. The validation in the getter (line 287) correctly checks for the presence of these required fields, which is good. Consider adding similar validation in the setter to fail fast if an invalid ForceUser object is passed.

Copilot uses AI. Check for mistakes.

private async setupAudio() {
this.incomingAudio = new Audio(this.config.incomingRingtoneUrl);
Expand Down Expand Up @@ -319,6 +375,24 @@ export class SIPCore {
}

private async setupUser(): Promise<void> {
// 0. Check for a per-device forced user stored in localStorage.
// This takes priority over everything — the global users list and
// the logged-in HA user are both ignored. Intended for kiosk tablets
// or shared dashboards that should always register as a specific
// Asterisk extension regardless of who is logged into HA.
const forceUser = this.ForceUser;
if (forceUser) {
this.user = {
ha_username: "",
display_name: forceUser.display_name ?? forceUser.extension,
extension: forceUser.extension,
password: forceUser.password,
};
console.info(`sipcore-force-user active: registering as extension ${this.user.extension} (${this.user.display_name})`);
this.ua = this.setupUA();
return;
}

const currentUserId = this.hass.user.id;
const currentUserName = this.hass.user.name;

Expand Down Expand Up @@ -411,6 +485,19 @@ export class SIPCore {
async startCall(extension: string) {
this.ua.call(extension, await this.callOptions());
}

/**
* Re-applies user selection immediately, called by `sip-user-card` when
* its YAML config changes. Stops the current UA, re-runs `setupUser()`
* with the latest `ForceUser` value from localStorage, then restarts.
*/
async applyForceUser(): Promise<void> {
console.info("sip-user-card config changed, re-registering...");
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

The applyForceUser method calls this.ua.stop() without checking if this.ua is defined. If this method is called before the UA is initialized (e.g., during early page load or if initialization failed), it will throw a runtime error. Add a guard check: if (!this.ua) return; or ensure the UA is initialized before calling stop().

Suggested change
console.info("sip-user-card config changed, re-registering...");
console.info("sip-user-card config changed, re-registering...");
if (!this.ua) {
console.warn("UA not initialized; cannot re-register.");
return;
}

Copilot uses AI. Check for mistakes.
this.ua.stop();
await this.setupUser();
this.ua.start();
Comment on lines +496 to +498
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

The applyForceUser method has no error handling. If setupUser() or ua.start() fail, the error will propagate uncaught to the caller (sip-user-card). Add try-catch error handling and log meaningful error messages to help users diagnose configuration issues.

Suggested change
this.ua.stop();
await this.setupUser();
this.ua.start();
try {
this.ua.stop();
} catch (error) {
console.error(
"Failed to stop existing SIP UA during re-registration. Continuing with reconfiguration anyway.",
error,
);
}
try {
await this.setupUser();
} catch (error) {
console.error(
"Failed to set up SIP user after sip-user-card config change. " +
"Please check ForceUser, SIP credentials, and configuration.",
error,
);
return;
}
try {
this.ua.start();
} catch (error) {
console.error(
"Failed to start SIP UA after re-registration. " +
"Please verify the WebSocket URL and SIP server availability.",
error,
);
return;
}

Copilot uses AI. Check for mistakes.
this.triggerUpdate();
}

/** Dispatches a `sipcore-update` event */
triggerUpdate() {
Expand Down
153 changes: 153 additions & 0 deletions src/sip-user-card.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { LitElement, html, css } from "lit";
import { customElement, state } from "lit/decorators.js";
import { sipCore } from "./sip-core";

declare global {
interface Window {
customCards?: Array<{ type: string; name: string; preview: boolean; description: string }>;
}
}

interface SIPUserCardConfig {
extension: string;
password: string;
display_name?: string;
}

/**
* A zero-height Lovelace card that forces SIP Core to register with a specific
* Asterisk extension on this device, regardless of which HA user is logged in.
*
* Add to a dashboard's YAML to configure a tablet or kiosk:
*
* @example
* type: custom:sip-user-card
* extension: "100"
* password: "mypassword"
* display_name: "Front Door Panel" # optional
*
* Removing the card from the dashboard clears the override and reverts to
* normal Home Assistant user matching.
*/
@customElement("sip-user-card")
class SIPUserCard extends LitElement {
@state()
private config: SIPUserCardConfig | undefined;

static get styles() {
return css`
:host {
display: block;
/* Zero height — this card is purely a config carrier */
height: 0;
overflow: hidden;
}

.status {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 12px;
color: var(--secondary-text-color);
font-family: var(--paper-font-body1_-_font-family);
}

.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}

.dot.registered {
background-color: var(--label-badge-green, #4caf50);
}

.dot.unregistered {
background-color: var(--label-badge-red, #f44336);
}
Comment on lines +45 to +69
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

The CSS styles for .status, .dot, .dot.registered, and .dot.unregistered (lines 46-69) are unused since the card has height: 0 and overflow: hidden, making the rendered content invisible. Remove these unused styles to reduce code bloat and confusion.

Suggested change
.status {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 12px;
color: var(--secondary-text-color);
font-family: var(--paper-font-body1_-_font-family);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot.registered {
background-color: var(--label-badge-green, #4caf50);
}
.dot.unregistered {
background-color: var(--label-badge-red, #f44336);
}

Copilot uses AI. Check for mistakes.
`;
}

// Called by HA with the YAML config whenever the dashboard loads or config changes.
setConfig(config: SIPUserCardConfig) {
if (!config.extension) throw new Error("sip-user-card: 'extension' is required");
if (!config.password) throw new Error("sip-user-card: 'password' is required");
Comment on lines +75 to +76
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

The validation only checks for the presence of config.password but doesn't validate if it's a non-empty string. An empty string would pass validation but would likely cause authentication failures. Add validation: if (!config.password || config.password.trim() === '') to ensure the password is not empty. Apply the same check to config.extension.

Suggested change
if (!config.extension) throw new Error("sip-user-card: 'extension' is required");
if (!config.password) throw new Error("sip-user-card: 'password' is required");
if (!config.extension || config.extension.trim() === "") {
throw new Error("sip-user-card: 'extension' is required and cannot be empty");
}
if (!config.password || config.password.trim() === "") {
throw new Error("sip-user-card: 'password' is required and cannot be empty");
}

Copilot uses AI. Check for mistakes.

// GUARD: Only trigger a re-registration if the credentials actually changed.
// This prevents HA's aggressive Lovelace rendering from spamming the Asterisk server.
const currentForceUser = sipCore.ForceUser;
const configChanged =
!currentForceUser ||
currentForceUser.extension !== config.extension ||
currentForceUser.password !== config.password;
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

The change detection at line 81-84 doesn't check if display_name changed. If a user only updates the display name in the YAML config, the credentials won't be re-applied and the old display name will remain active until the page reloads. Include display_name in the change detection: currentForceUser.display_name !== config.display_name.

Suggested change
currentForceUser.password !== config.password;
currentForceUser.password !== config.password ||
currentForceUser.display_name !== config.display_name;

Copilot uses AI. Check for mistakes.

if (configChanged) {
console.info(`sip-user-card: New credentials detected for ${config.extension}. Updating localStorage...`);

// Write to localStorage
sipCore.ForceUser = {
extension: config.extension,
password: config.password,
display_name: config.display_name,
};
Comment on lines +89 to +94
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

Storing plaintext SIP passwords in localStorage poses a significant security risk. localStorage is accessible to any JavaScript running on the same origin, making credentials vulnerable to XSS attacks. Consider encrypting the password before storage, or implement a more secure credential storage mechanism (such as using the Web Crypto API with a user-derived key, or storing only a session token that can be revoked).

Copilot uses AI. Check for mistakes.
Comment on lines +89 to +94
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

When multiple browser tabs/windows have different dashboards open simultaneously, all tabs share the same localStorage. If Tab A has a dashboard with sip-user-card forcing extension "100" and Tab B has a different dashboard with sip-user-card forcing extension "200", whichever tab loads last will overwrite localStorage and both tabs will attempt to register as the same extension. This defeats the purpose of the feature. Consider using sessionStorage instead of localStorage, or implement a tab-specific identifier to prevent cross-tab conflicts.

Copilot uses AI. Check for mistakes.

// Tell SIP Core to drop the old connection and log in with the new one
sipCore.applyForceUser();
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

The call to sipCore.applyForceUser() is not awaited. Since this is an async method that performs UA stop/restart operations, not awaiting it could lead to the card being marked as configured before the registration is complete. Consider adding await or handle the promise to detect registration failures and notify the user.

Suggested change
sipCore.applyForceUser();
sipCore.applyForceUser().catch((err) => {
console.error("sip-user-card: Failed to apply forced SIP user", err);
});

Copilot uses AI. Check for mistakes.
}

this.config = config;
}
Comment on lines +74 to +101
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

The setConfig method can be called multiple times by Home Assistant during re-renders. If credentials change rapidly or the method is called while applyForceUser() is still executing (which is async), this could lead to race conditions where multiple UA stop/start cycles overlap. Consider adding a debounce mechanism or a flag to prevent concurrent re-registration operations.

Copilot uses AI. Check for mistakes.

static getStubConfig(): SIPUserCardConfig {
return {
extension: "100",
password: "mypassword",
display_name: "Front Door Panel",
};
}

connectedCallback() {
super.connectedCallback();
window.addEventListener("sipcore-update", this.updateHandler);
}

disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("sipcore-update", this.updateHandler);
// Credentials remain in localStorage — this device stays registered as
// the forced extension until manually cleared via:
// localStorage.removeItem('sipcore-force-user')
Comment on lines +119 to +121
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

The disconnectedCallback leaves credentials in localStorage even after the card is removed from the dashboard. This contradicts the documentation in the class comment (line 29-30) which states "Removing the card from the dashboard clears the override and reverts to normal Home Assistant user matching." The credentials should be cleared when the card is removed to match the documented behavior: sipCore.ForceUser = null;

Suggested change
// Credentials remain in localStorage — this device stays registered as
// the forced extension until manually cleared via:
// localStorage.removeItem('sipcore-force-user')
// Clear forced credentials so removing the card reverts to normal
// Home Assistant user matching, as documented.
sipCore.ForceUser = null;

Copilot uses AI. Check for mistakes.
}

private updateHandler = () => {
this.requestUpdate();
};

render() {
const registered = sipCore.registered;
const ext = this.config?.extension ?? "?";
const name = this.config?.display_name ?? ext;

return html`
<div class="status">
<span class="dot ${registered ? "registered" : "unregistered"}"></span>
<span>${name} (${ext}) — ${registered ? "Registered" : "Unregistered"}</span>
</div>
`;
Comment on lines +129 to +138
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

The card's render method displays a status indicator despite claiming to be a "zero-height" configuration carrier. The CSS sets height: 0 and overflow: hidden, which will hide the rendered content. This creates a misleading implementation where visual status elements are rendered but never visible to users. Either remove the render method and return an empty template, or remove the zero-height constraint and make the status visible.

Suggested change
const registered = sipCore.registered;
const ext = this.config?.extension ?? "?";
const name = this.config?.display_name ?? ext;
return html`
<div class="status">
<span class="dot ${registered ? "registered" : "unregistered"}"></span>
<span>${name} (${ext}) ${registered ? "Registered" : "Unregistered"}</span>
</div>
`;
// This card acts purely as a zero-height configuration carrier for sipCore.
// It intentionally renders no visible UI.
return html``;

Copilot uses AI. Check for mistakes.
}

// Card height hint for HA layout engine — report 0 so it takes no grid space.
getCardSize() {
return 0;
}
}

window.customCards = window.customCards || [];
window.customCards.push({
type: "sip-user-card",
name: "SIP User Card",
preview: false,
description: "Forces SIP Core to register as a specific Asterisk extension on this device, regardless of the logged-in HA user. Useful for shared tablets and kiosk dashboards.",
});