├── .nvmrc
├── requirements.txt
├── .github
├── FUNDING.yml
└── workflows
│ ├── validate.yml
│ ├── build.yml
│ ├── update-docs.yml
│ └── release.yml
├── .gitattributes
├── .gitignore
├── images
├── icon.png
├── logo.png
├── popup.png
├── icon@2x.png
├── logo@2x.png
├── call_card.png
├── dark_icon.png
├── dark_logo.png
├── icon.aseprite
├── icon_only.png
├── label.aseprite
├── logo.aseprite
├── contacts_card.png
├── dark_icon@2x.png
├── dark_logo@2x.png
├── popup_config.png
├── dark_icon.aseprite
├── dark_logo.aseprite
└── icon_only.aseprite
├── src
├── index.ts
├── tsconfig.json
├── audio-visualizer.ts
├── sip-contacts-card.ts
├── sip-call-card.ts
├── sip-core.ts
└── sip-call-dialog.ts
├── custom_components
└── sip_core
│ ├── www
│ ├── ring-tone.mp3
│ └── ringback-tone.mp3
│ ├── const.py
│ ├── translations
│ └── en.json
│ ├── manifest.json
│ ├── config_flow.py
│ ├── defaults.py
│ ├── resources.py
│ └── __init__.py
├── demo
├── package.json
├── package-lock.json
└── example-card.js
├── hacs.json
├── webpack.config.js
├── .devcontainer
└── devcontainer.json
├── LICENSE
├── package.json
└── README.md
/.nvmrc:
--------------------------------------------------------------------------------
1 | v18.20.4
2 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | homeassistant
2 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [TECH7Fox]
2 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | .vscode
4 | vendor
5 | sip_core.js
6 | sip_core.js.LICENSE.txt
7 |
--------------------------------------------------------------------------------
/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/images/icon.png
--------------------------------------------------------------------------------
/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/images/logo.png
--------------------------------------------------------------------------------
/images/popup.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/images/popup.png
--------------------------------------------------------------------------------
/images/icon@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/images/icon@2x.png
--------------------------------------------------------------------------------
/images/logo@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/images/logo@2x.png
--------------------------------------------------------------------------------
/images/call_card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/images/call_card.png
--------------------------------------------------------------------------------
/images/dark_icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/images/dark_icon.png
--------------------------------------------------------------------------------
/images/dark_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/images/dark_logo.png
--------------------------------------------------------------------------------
/images/icon.aseprite:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/images/icon.aseprite
--------------------------------------------------------------------------------
/images/icon_only.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/images/icon_only.png
--------------------------------------------------------------------------------
/images/label.aseprite:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/images/label.aseprite
--------------------------------------------------------------------------------
/images/logo.aseprite:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/images/logo.aseprite
--------------------------------------------------------------------------------
/images/contacts_card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/images/contacts_card.png
--------------------------------------------------------------------------------
/images/dark_icon@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/images/dark_icon@2x.png
--------------------------------------------------------------------------------
/images/dark_logo@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/images/dark_logo@2x.png
--------------------------------------------------------------------------------
/images/popup_config.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/images/popup_config.png
--------------------------------------------------------------------------------
/images/dark_icon.aseprite:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/images/dark_icon.aseprite
--------------------------------------------------------------------------------
/images/dark_logo.aseprite:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/images/dark_logo.aseprite
--------------------------------------------------------------------------------
/images/icon_only.aseprite:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/images/icon_only.aseprite
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import "./sip-core";
2 | import "./sip-call-dialog";
3 | import "./sip-call-card";
4 | import "./sip-contacts-card";
5 |
--------------------------------------------------------------------------------
/custom_components/sip_core/www/ring-tone.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/custom_components/sip_core/www/ring-tone.mp3
--------------------------------------------------------------------------------
/custom_components/sip_core/www/ringback-tone.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TECH7Fox/sipcore-hass-integration/HEAD/custom_components/sip_core/www/ringback-tone.mp3
--------------------------------------------------------------------------------
/demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "demo",
3 | "version": "1.0.0",
4 | "main": "example-card.js",
5 | "dependencies": {
6 | "lit": "^3.3.0"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/hacs.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "SIP Core",
3 | "filename": "sip_core.zip",
4 | "zip_release": true,
5 | "render_readme": true,
6 | "hide_default_branch": true
7 | }
8 |
--------------------------------------------------------------------------------
/custom_components/sip_core/const.py:
--------------------------------------------------------------------------------
1 | """Constants for the SIP Core integration."""
2 |
3 | DOMAIN = "sip_core"
4 | JS_FILENAME = "sip_core.js"
5 | JS_URL_PATH = f"/sip_core_files/{JS_FILENAME}"
6 | ASTERISK_ADDON_SLUG = "3e533915_asterisk"
7 |
--------------------------------------------------------------------------------
/.github/workflows/validate.yml:
--------------------------------------------------------------------------------
1 | name: Validate
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | permissions: {}
10 |
11 | jobs:
12 | validate-hacs:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: HACS validation
16 | uses: hacs/action@main
17 | with:
18 | category: integration
19 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 | permissions:
3 | contents: read
4 |
5 | on:
6 | push:
7 | branches:
8 | - main
9 | pull_request:
10 |
11 | jobs:
12 | build:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v2
16 | - name: Setup Node.js
17 | uses: actions/setup-node@v2
18 | - name: Install Dependencies
19 | run: npm ci
20 | - name: Build Card
21 | run: npm run build
22 |
--------------------------------------------------------------------------------
/custom_components/sip_core/translations/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "options": {
3 | "step": {
4 | "init": {
5 | "data": {
6 | "sip_config": "SIP Core Configuration"
7 | },
8 | "data_description": {
9 | "sip_config": "Complete SIP Core configuration object including ICE settings, ringtone URLs, user credentials, and popup options. See the documentation for details."
10 | }
11 | }
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowJs": true,
4 | "target": "ES2022",
5 | "module": "ES2022",
6 | "strict": true,
7 | "esModuleInterop": true,
8 | "importHelpers": true,
9 | "sourceMap": true,
10 | "resolveJsonModule": true,
11 | "moduleResolution": "node",
12 | "experimentalDecorators": true,
13 | "lib": [
14 | "ES2022",
15 | "DOM"
16 | ],
17 | "types": []
18 | },
19 | "include": [
20 | "**/*.ts"
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | entry: './src/index.ts',
5 | devtool: 'inline-source-map',
6 | mode: 'development',
7 | module: {
8 | rules: [
9 | {
10 | test: /\.ts$/,
11 | use: 'ts-loader',
12 | exclude: /node_modules/,
13 | },
14 | ],
15 | },
16 | resolve: {
17 | extensions: ['.tsx', '.ts', '.js'],
18 | },
19 | output: {
20 | filename: 'sip_core.js',
21 | path: path.resolve(__dirname, 'custom_components', 'sip_core', 'www'),
22 | },
23 | };
24 |
--------------------------------------------------------------------------------
/.github/workflows/update-docs.yml:
--------------------------------------------------------------------------------
1 | name: Update Docs
2 |
3 | permissions: {}
4 | on:
5 | push:
6 | branches:
7 | - main
8 |
9 | jobs:
10 | trigger-update-docs:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - name: Trigger Update Docs Workflow
15 | run: |
16 | curl -L \
17 | -X POST \
18 | -H "Accept: application/vnd.github+json" \
19 | -H "Authorization: Bearer ${{ secrets.PAT }}" \
20 | -H "X-GitHub-Api-Version: 2022-11-28" \
21 | https://api.github.com/repos/TECH7Fox/sip-hass-docs/dispatches \
22 | -d '{"event_type": "update-docs"}'
23 |
--------------------------------------------------------------------------------
/custom_components/sip_core/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "domain": "sip_core",
3 | "name": "SIP Core",
4 | "after_dependencies": [
5 | "frontend",
6 | "lovelace",
7 | "hassio"
8 | ],
9 | "codeowners": [
10 | "@TECH7Fox"
11 | ],
12 | "dependencies": [
13 | "http"
14 | ],
15 | "single_config_entry": true,
16 | "config_flow": true,
17 | "documentation": "https://tech7fox.github.io/sip-hass-docs",
18 | "iot_class": "local_push",
19 | "issue_tracker": "https://github.com/TECH7Fox/sipcore-hass-integrations/issues",
20 | "requirements": [],
21 | "version": "v5.0.1"
22 | }
23 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the
2 | // README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
3 | {
4 | "name": "Node.js & TypeScript",
5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
6 | "image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye",
7 |
8 | // Features to add to the dev container. More info: https://containers.dev/features.
9 | // "features": {},
10 |
11 | // Use 'forwardPorts' to make a list of ports inside the container available locally.
12 | // "forwardPorts": [],
13 |
14 | // Use 'postCreateCommand' to run commands after the container is created.
15 | "postCreateCommand": "npm ci"
16 |
17 | // Configure tool-specific properties.
18 | // "customizations": {},
19 |
20 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
21 | // "remoteUser": "root"
22 | }
23 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Jordy Kuhne
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ha-sip",
3 | "version": "5.0.1",
4 | "description": "A SIP client inside home assistant!",
5 | "main": "sipjs-card.js",
6 | "scripts": {
7 | "build": "webpack --progress --config webpack.config.js --mode production",
8 | "watch": "webpack --progress --config webpack.config.js --mode development --watch"
9 | },
10 | "repository": {
11 | "type": "git",
12 | "url": "git+https://github.com/TECH7Fox/sipcore-hass-integration.git"
13 | },
14 | "keywords": [
15 | "sip",
16 | "home-assistant"
17 | ],
18 | "author": "Jordy Kuhne",
19 | "license": "MIT",
20 | "bugs": {
21 | "url": "https://github.com/TECH7Fox/sipcore-hass-integration/issues"
22 | },
23 | "homepage": "https://github.com/TECH7Fox/sipcore-hass-integration#readme",
24 | "devDependencies": {
25 | "ts-loader": "^9.5.2",
26 | "tslib": "^2.3.1",
27 | "typescript": "^4.9.5",
28 | "webpack": "^5.99.7",
29 | "webpack-cli": "^4.10.0"
30 | },
31 | "dependencies": {
32 | "jssip": "^3.10.6",
33 | "lit": "^2.8.0"
34 | },
35 | "prettier": {
36 | "singleQuote": false,
37 | "semi": true,
38 | "trailingComma": "all",
39 | "printWidth": 120,
40 | "tabWidth": 4,
41 | "useTabs": false,
42 | "bracketSpacing": true
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | jobs:
8 | release:
9 | name: Prepare release
10 | permissions:
11 | contents: write
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v3
15 | with:
16 | token: ${{ secrets.PAT }}
17 | - name: Update version
18 | run: |
19 | npm version ${{ github.event.release.tag_name }} --no-git-tag-version
20 | sed -i "s/\"version\": \".*\"/\"version\": \"${{ github.event.release.tag_name }}\"/" custom_components/sip_core/manifest.json
21 | - uses: EndBug/add-and-commit@v9
22 | with:
23 | default_author: github_actions
24 | message: 'Bump version to ${{ github.event.release.tag_name }}'
25 | push: origin HEAD:main
26 | - name: Build the file
27 | run: |
28 | npm ci
29 | npm run build
30 | - name: Create release zip
31 | run: |
32 | cd ${{ github.workspace }}/custom_components/sip_core
33 | zip sip_core.zip -r ./
34 | - name: Upload files to release
35 | uses: svenstaro/upload-release-action@v1-release
36 | with:
37 | repo_token: ${{ secrets.GITHUB_TOKEN }}
38 | file: ${{ github.workspace }}/custom_components/sip_core/sip_core.zip
39 | tag: ${{ github.ref }}
40 | overwrite: true
41 | file_glob: true
42 |
--------------------------------------------------------------------------------
/custom_components/sip_core/config_flow.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import voluptuous as vol
3 | from typing import Any
4 |
5 | from homeassistant.core import callback
6 | from homeassistant.config_entries import ConfigFlowResult, OptionsFlow, ConfigFlow, ConfigEntry
7 | from homeassistant.helpers.selector import ObjectSelector
8 | from .const import DOMAIN
9 | from .defaults import sip_config
10 |
11 | logger: logging.Logger = logging.getLogger(__name__)
12 |
13 |
14 | class SipCoreConfigFlow(ConfigFlow, domain=DOMAIN):
15 | """Handle a config flow for SIP Core."""
16 |
17 | VERSION = 1
18 |
19 | async def async_step_user(self, user_input: dict[str, Any] | None = None):
20 | await self.async_set_unique_id(DOMAIN)
21 | self._abort_if_unique_id_configured()
22 | return self.async_create_entry(title="SIP Core", data=user_input or {})
23 |
24 | @staticmethod
25 | @callback
26 | def async_get_options_flow(config_entry: ConfigEntry[Any]) -> "SipCoreOptionsFlowHandler":
27 | return SipCoreOptionsFlowHandler()
28 |
29 |
30 | class SipCoreOptionsFlowHandler(OptionsFlow):
31 | """Handle SIP Core options flow."""
32 |
33 | async def async_step_init(
34 | self, user_input: dict[str, Any] | None = None
35 | ) -> ConfigFlowResult:
36 | """Manage the SIP Core options."""
37 | if user_input is not None:
38 | return self.async_create_entry(title="SIP Core", data=user_input)
39 |
40 | return self.async_show_form(
41 | step_id="init",
42 | data_schema=vol.Schema({
43 | vol.Required(
44 | "sip_config",
45 | description={
46 | "suggested_value": self.config_entry.options.get("sip_config", sip_config),
47 | }
48 | ): ObjectSelector(),
49 | })
50 | )
51 |
--------------------------------------------------------------------------------
/custom_components/sip_core/defaults.py:
--------------------------------------------------------------------------------
1 | sip_config = {
2 | "ice_config": {
3 | "iceGatheringTimeout": 1000,
4 | "iceCandidatePoolSize": 0,
5 | "iceTransportPolicy": "all",
6 | "iceServers": [
7 | {
8 | "urls": ["stun:stun.l.google.com:19302"]
9 | }
10 | ],
11 | "rtcpMuxPolicy": "require"
12 | },
13 | "outgoingRingtoneUrl": "/sip_core_files/ringback-tone.mp3",
14 | "incomingRingtoneUrl": "/sip_core_files/ring-tone.mp3",
15 | "backup_user": {
16 | "ha_username": "myuser",
17 | "extension": "100",
18 | "password": "1234"
19 | },
20 | "users": [
21 | {
22 | "ha_username": "jordy",
23 | "extension": "101",
24 | "password": "1234"
25 | },
26 | {
27 | "ha_username": "alice",
28 | "extension": "102",
29 | "password": "1234"
30 | }
31 | ],
32 | "sip_video": False,
33 | "auto_answer": False,
34 | "popup_config": {
35 | "auto_open": True,
36 | "large": False,
37 | "hide_header_button": False,
38 | "buttons": [
39 | {
40 | "label": "Open Door",
41 | "icon": "mdi:door-open",
42 | "type": "dtmf",
43 | "data": "1"
44 | },
45 | {
46 | "label": "Switch lights",
47 | "icon": "mdi:lightbulb",
48 | "type": "service_call",
49 | "data": {
50 | "domain": "light",
51 | "service": "toggle",
52 | "entity_id": "light.bedroom_lights"
53 | }
54 | }
55 | ],
56 | "extensions": {
57 | "008": {
58 | "name": "Bob"
59 | },
60 | "8001": {
61 | "name": "Doorbell",
62 | "camera_entity": "camera.doorbell"
63 | }
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/demo/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "demo",
3 | "version": "1.0.0",
4 | "lockfileVersion": 3,
5 | "requires": true,
6 | "packages": {
7 | "": {
8 | "name": "demo",
9 | "version": "1.0.0",
10 | "dependencies": {
11 | "lit": "^3.3.0"
12 | }
13 | },
14 | "node_modules/@lit-labs/ssr-dom-shim": {
15 | "version": "1.3.0",
16 | "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.3.0.tgz",
17 | "integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ=="
18 | },
19 | "node_modules/@lit/reactive-element": {
20 | "version": "2.1.0",
21 | "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.0.tgz",
22 | "integrity": "sha512-L2qyoZSQClcBmq0qajBVbhYEcG6iK0XfLn66ifLe/RfC0/ihpc+pl0Wdn8bJ8o+hj38cG0fGXRgSS20MuXn7qA==",
23 | "dependencies": {
24 | "@lit-labs/ssr-dom-shim": "^1.2.0"
25 | }
26 | },
27 | "node_modules/@types/trusted-types": {
28 | "version": "2.0.7",
29 | "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
30 | "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
31 | },
32 | "node_modules/lit": {
33 | "version": "3.3.0",
34 | "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.0.tgz",
35 | "integrity": "sha512-DGVsqsOIHBww2DqnuZzW7QsuCdahp50ojuDaBPC7jUDRpYoH0z7kHBBYZewRzer75FwtrkmkKk7iOAwSaWdBmw==",
36 | "dependencies": {
37 | "@lit/reactive-element": "^2.1.0",
38 | "lit-element": "^4.2.0",
39 | "lit-html": "^3.3.0"
40 | }
41 | },
42 | "node_modules/lit-element": {
43 | "version": "4.2.0",
44 | "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.0.tgz",
45 | "integrity": "sha512-MGrXJVAI5x+Bfth/pU9Kst1iWID6GHDLEzFEnyULB/sFiRLgkd8NPK/PeeXxktA3T6EIIaq8U3KcbTU5XFcP2Q==",
46 | "dependencies": {
47 | "@lit-labs/ssr-dom-shim": "^1.2.0",
48 | "@lit/reactive-element": "^2.1.0",
49 | "lit-html": "^3.3.0"
50 | }
51 | },
52 | "node_modules/lit-html": {
53 | "version": "3.3.0",
54 | "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.0.tgz",
55 | "integrity": "sha512-RHoswrFAxY2d8Cf2mm4OZ1DgzCoBKUKSPvA1fhtSELxUERq2aQQ2h05pO9j81gS1o7RIRJ+CePLogfyahwmynw==",
56 | "dependencies": {
57 | "@types/trusted-types": "^2.0.2"
58 | }
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/demo/example-card.js:
--------------------------------------------------------------------------------
1 | import { LitElement, html, css } from "https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js";
2 |
3 | class ExampleCard extends LitElement {
4 | sipCore;
5 |
6 | static styles = css`
7 | ha-card {
8 | padding: 16px;
9 | font-family: Arial, sans-serif;
10 | }
11 |
12 | .status {
13 | margin-bottom: 16px;
14 | }
15 |
16 | .buttons {
17 | display: flex;
18 | gap: 8px;
19 | }
20 |
21 | button {
22 | padding: 8px 16px;
23 | font-size: 14px;
24 | cursor: pointer;
25 | }
26 | `;
27 |
28 | connectedCallback() {
29 | if (window.sipCore) {
30 | this.sipCore = window.sipCore;
31 | }
32 | super.connectedCallback();
33 | window.addEventListener("sipcore-update", this.updateHandler);
34 | }
35 |
36 | disconnectedCallback() {
37 | super.disconnectedCallback();
38 | window.removeEventListener("sipcore-update", this.updateHandler);
39 | }
40 |
41 | updateHandler = () => {
42 | if (!this.sipCore) {
43 | this.sipCore = window.sipCore;
44 | }
45 | this.requestUpdate();
46 | };
47 |
48 | setConfig(config) {
49 | // Validate the config here
50 | }
51 |
52 | render() {
53 | if (!this.sipCore) {
54 | return html`
Loading...
`;
55 | }
56 |
57 | return html`
58 |
59 |
60 | Call State: ${this.sipCore.callState}
61 | Call Duration: ${this.sipCore.callDuration}
62 | Remote Name: ${this.sipCore.remoteName || "N/A"}
63 |
64 |
65 | this.sipCore.answerCall()}>Answer Call
66 | this.sipCore.endCall()}>End Call
67 | this.sipCore.startCall("8001")}>Call Extension 8001
68 |
69 |
70 | `;
71 | }
72 | }
73 |
74 | customElements.define("sip-example-card", ExampleCard);
75 | window.customCards = window.customCards || [];
76 | window.customCards.push({
77 | type: "sip-example-card",
78 | name: "SIP Example Card",
79 | preview: true,
80 | description: "SIP Example Card",
81 | });
82 |
--------------------------------------------------------------------------------
/custom_components/sip_core/resources.py:
--------------------------------------------------------------------------------
1 |
2 |
3 | import logging
4 | from homeassistant.components.lovelace.resources import (
5 | ResourceStorageCollection,
6 | ResourceYAMLCollection,
7 | )
8 | from homeassistant.const import CONF_ID, CONF_URL
9 | from homeassistant.components.lovelace.const import (
10 | CONF_RESOURCE_TYPE_WS,
11 | DOMAIN as LL_DOMAIN,
12 | )
13 | from .const import JS_URL_PATH
14 | from homeassistant.core import HomeAssistant
15 |
16 |
17 | logger = logging.getLogger(__name__)
18 |
19 |
20 | async def add_resources(hass: HomeAssistant):
21 | """Add SIP Core resources to Lovelace."""
22 |
23 | resources: ResourceStorageCollection | ResourceYAMLCollection | None = None
24 | if lovelace_data := hass.data.get(LL_DOMAIN):
25 | resources = lovelace_data.resources
26 | if resources:
27 | if not resources.loaded:
28 | await resources.async_load()
29 | logger.debug("Manually loaded resources")
30 | resources.loaded = True
31 |
32 | res_id = next(
33 | (
34 | data[CONF_ID]
35 | for data in resources.async_items()
36 | if data[CONF_URL] == JS_URL_PATH
37 | ),
38 | None,
39 | )
40 |
41 | if res_id is None:
42 | logger.info("Registering SIP Core module in Lovelace resources")
43 | if isinstance(resources, ResourceYAMLCollection):
44 | logger.warning("SIP Core module not registered because resources are managed via YAML")
45 | return False # TODO: Return error for user?
46 | else:
47 | data = await resources.async_create_item(
48 | {CONF_RESOURCE_TYPE_WS: "module", CONF_URL: JS_URL_PATH}
49 | )
50 | logger.debug(f"Registered SIP Core module with resource ID {data[CONF_ID]}")
51 | else:
52 | logger.debug(f"module already registered with resource ID {res_id}")
53 |
54 |
55 | async def remove_resources(hass: HomeAssistant):
56 | """Remove SIP Core resources from Lovelace."""
57 |
58 | resources: ResourceStorageCollection | ResourceYAMLCollection | None = None
59 | if lovelace_data := hass.data.get(LL_DOMAIN):
60 | resources = lovelace_data.resources
61 | if resources:
62 | if not resources.loaded:
63 | await resources.async_load()
64 | logger.debug("Manually loaded resources for unload")
65 | resources.loaded = True
66 |
67 | res_id = next(
68 | (
69 | data[CONF_ID]
70 | for data in resources.async_items()
71 | if data[CONF_URL] == JS_URL_PATH
72 | ),
73 | None,
74 | )
75 |
76 | if res_id is not None and isinstance(resources, ResourceStorageCollection):
77 | logger.info("Removing SIP Core module from Lovelace resources")
78 | await resources.async_delete_item(res_id)
79 | else:
80 | logger.debug("SIP Core module resource not found during unload")
81 |
--------------------------------------------------------------------------------
/src/audio-visualizer.ts:
--------------------------------------------------------------------------------
1 | class AudioVisualizer {
2 | private shouldStop: boolean = false;
3 | private audioContext: AudioContext;
4 | private analyser: AnalyserNode;
5 | private renderRoot: HTMLElement | ShadowRoot;
6 | private visualValueCount;
7 | private visualMainElement;
8 | private visualElements: NodeListOf | undefined;
9 |
10 | constructor(renderRoot: HTMLElement | ShadowRoot, stream: MediaStream, visualValueCount = 16) {
11 | this.shouldStop = false;
12 | this.renderRoot = renderRoot;
13 | this.visualValueCount = visualValueCount;
14 | this.visualMainElement = this.renderRoot.querySelector("#audioVisualizer");
15 | this.audioContext = new AudioContext();
16 | this.initDOM();
17 | this.analyser = this.audioContext.createAnalyser();
18 | this.connectStream(stream);
19 | }
20 |
21 | stop() {
22 | this.shouldStop = true;
23 | }
24 |
25 | initDOM() {
26 | if (this.visualMainElement) {
27 | this.visualMainElement.innerHTML = "";
28 | let i;
29 | for (i = 0; i < this.visualValueCount; ++i) {
30 | const elm = document.createElement("div");
31 | this.visualMainElement.appendChild(elm);
32 | }
33 |
34 | this.visualElements = this.renderRoot.querySelectorAll("#audioVisualizer div");
35 | }
36 | }
37 |
38 | processFrame(data: Uint8Array) {
39 | const dataMap: { [key: number]: number } = {
40 | 0: 15,
41 | 1: 10,
42 | 2: 8,
43 | 3: 9,
44 | 4: 6,
45 | 5: 5,
46 | 6: 2,
47 | 7: 1,
48 | 8: 0,
49 | 9: 4,
50 | 10: 3,
51 | 11: 7,
52 | 12: 11,
53 | 13: 12,
54 | 14: 13,
55 | 15: 14,
56 | };
57 | let i;
58 | for (i = 0; i < this.visualValueCount; ++i) {
59 | const value = data[dataMap[i]] / 255; // + 0.025;
60 | const elmStyles = this.visualElements![i].style;
61 | elmStyles.transform = `scaleY(${value})`;
62 | elmStyles.opacity = Math.max(0.25, value).toString();
63 | }
64 | }
65 |
66 | connectStream(stream: MediaStream) {
67 | const source = this.audioContext.createMediaStreamSource(stream);
68 | source.connect(this.analyser);
69 | this.analyser.smoothingTimeConstant = 0.5;
70 | this.analyser.fftSize = 32;
71 |
72 | this.initRenderLoop();
73 | }
74 |
75 | initRenderLoop() {
76 | const frequencyData = new Uint8Array(this.analyser.frequencyBinCount);
77 | const renderFrame = () => {
78 | this.analyser?.getByteFrequencyData(frequencyData);
79 | this.processFrame(frequencyData);
80 |
81 | if (this.shouldStop !== true) {
82 | requestAnimationFrame(renderFrame);
83 | }
84 | };
85 | requestAnimationFrame(renderFrame);
86 | }
87 | }
88 |
89 | export { AudioVisualizer };
90 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | **🚀 Make and receive SIP calls directly in your Home Assistant dashboard**
9 |
10 |
11 |
12 |
13 | SIP Core, part of the SIP-HASS project, is the system that powers Home Assistant cards to make and receive SIP calls using WebRTC.
14 | It includes official cards and popups, but also supports third-party cards.
15 |
16 |
17 |
18 |
19 |
20 | [](https://discordapp.com/invite/qxnDtHbwuD)
21 | [](https://tech7fox.github.io/sip-hass-docs/)
22 | [](https://my.home-assistant.io/redirect/hacs_repository/?owner=TECH7Fox&repository=sipcore-hass-integration&category=integration)
23 |
24 |
25 |
26 | ## ✨ Features
27 |
28 | * 📞 Make and receive calls
29 | * 🔔 (Custom) Ringtones
30 | * 📹 Video calls
31 | * 🔢 DTMF support
32 | * 🪟 Popups for incoming calls
33 | * 🚀 Auto call on load (using `?call=` in the URL)
34 | * 🎤 Audio device selection
35 | * 🛠️ API for third-party developers to build custom cards and popups
36 |
37 | ## Default Popup
38 |
39 |
40 |
41 |
42 |
43 |
44 | - 🚪 Automatically opens on incoming calls
45 | - 📊 Audio Visualizer
46 | - ⚙️ Menu to configure audio devices
47 | - 🐛 And shows debug information
48 | - 🎮 Custom buttons
49 | - 🔇 Mute mic & camera buttons
50 |
51 | ## Call Card
52 |
53 |
54 |
55 | `custom:sip-call-card`
56 |
57 | - 📊 Audio Visualizer
58 | - 📹 Supports camera entities for video
59 | - 🎮 Custom buttons for quick actions
60 | - 🔇 Mute mic & camera buttons
61 |
62 |
63 |
64 |
65 | ## Contacts Card
66 |
67 |
68 |
69 | `custom:sip-contacts-card`
70 |
71 | - 📞 Start calls to users/numbers
72 | - 👤 Option to hide your own user
73 | - 🎨 Custom names & icons
74 | - ✏️ Open field option
75 | - 🟢 State color with status entity
76 |
77 |
78 |
79 |
80 | ## 📋 Requirements
81 | For this to work you will need the following:
82 | * ☎️ A sip/pbx server (Works best with the [Asterisk add-on](https://github.com/TECH7Fox/Asterisk-add-on))
83 | * 🔒 HTTPS for Home Assistant
84 | * 📦 HACS for easy installation
85 |
86 |
87 | ## 📚 Wiki
88 |
89 | You can find the installation instructions and guides on the documentation site: [SIP-HASS Docs](https://tech7fox.github.io/sip-hass-docs/)
90 |
91 | ## ⭐ Star History
92 |
93 |
94 |
95 |
96 |
--------------------------------------------------------------------------------
/custom_components/sip_core/__init__.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from pathlib import Path
3 | from homeassistant.core import HomeAssistant
4 | from homeassistant.components.http import StaticPathConfig
5 | from aiohttp.web import Request, Response
6 | from homeassistant.components.hassio.const import DOMAIN as HASSIO_DOMAIN
7 | from homeassistant.components.hassio.handler import HassIO, get_supervisor_client
8 | from homeassistant.helpers.http import HomeAssistantView
9 | from homeassistant.config_entries import ConfigEntry
10 | from .const import ASTERISK_ADDON_SLUG, DOMAIN, JS_FILENAME, JS_URL_PATH
11 | from .resources import add_resources, remove_resources
12 | from .defaults import sip_config
13 |
14 | logger = logging.getLogger(__name__)
15 |
16 |
17 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
18 | """Set up the SIP Core component."""
19 |
20 | logger.info("Registering SIP Core HTTP views")
21 | hass.http.register_view(SipCoreConfigView())
22 | hass.http.register_view(AsteriskIngressView())
23 |
24 | logger.info("Setting up SIP Core component")
25 | hass.data.setdefault(DOMAIN, {
26 | "data": config_entry.data,
27 | "options": {"sip_config": sip_config},
28 | "entry_id": config_entry.entry_id,
29 | })
30 | logger.info(config_entry.data)
31 | logger.info(config_entry.options)
32 | logger.info(config_entry.entry_id)
33 |
34 | await hass.http.async_register_static_paths(
35 | [
36 | StaticPathConfig(
37 | url_path=JS_URL_PATH,
38 | path=Path(__file__).parent / "www" / JS_FILENAME,
39 | cache_headers=True,
40 | ),
41 | StaticPathConfig(
42 | url_path="/sip_core_files/ringback-tone.mp3",
43 | path=Path(__file__).parent / "www" / "ringback-tone.mp3",
44 | cache_headers=True,
45 | ),
46 | StaticPathConfig(
47 | url_path="/sip_core_files/ring-tone.mp3",
48 | path=Path(__file__).parent / "www" / "ring-tone.mp3",
49 | cache_headers=True,
50 | ),
51 | ]
52 | )
53 |
54 | await add_resources(hass)
55 |
56 | config_entry.add_update_listener(update_listener)
57 |
58 | return True
59 |
60 |
61 | async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
62 | """Unload a config entry."""
63 | logger.info("Unloading SIP Core component")
64 | hass.data.pop(DOMAIN, None)
65 | await remove_resources(hass)
66 | return True
67 |
68 |
69 | async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry):
70 | """Handle options update."""
71 | logger.info("SIP Core configuration updated")
72 | hass.data[DOMAIN]["options"]["sip_config"] = config_entry.options.get("sip_config")
73 |
74 |
75 | class SipCoreConfigView(HomeAssistantView):
76 | """View to serve SIP Core configuration."""
77 |
78 | url = "/api/sip-core/config"
79 | name = "api:sip-core:config"
80 | requires_auth = True
81 |
82 | async def get(self, request: Request):
83 | """Handle GET request."""
84 | hass: HomeAssistant = request.app["hass"]
85 | try:
86 | sip_config = hass.data[DOMAIN]["options"]["sip_config"]
87 | return self.json(sip_config)
88 | except KeyError:
89 | return self.json({"error": "No configuration found"}, status_code=500)
90 |
91 |
92 | class AsteriskIngressView(HomeAssistantView):
93 | """View to handle Asterisk Add-on ingress."""
94 |
95 | url = "/api/sip-core/asterisk-ingress"
96 | name = "api:sip-core:asterisk-ingress"
97 | requires_auth = True
98 |
99 | async def get(self, request: Request) -> Response:
100 | hass: HomeAssistant = request.app["hass"]
101 | hassio: HassIO | None = hass.data.get(HASSIO_DOMAIN)
102 | if not hassio:
103 | return self.json({"error": "supervisor not available"}, status_code=503)
104 |
105 | supervisor_client = get_supervisor_client(hass)
106 | try:
107 | addon_info = await supervisor_client.addons.addon_info(ASTERISK_ADDON_SLUG)
108 | ingress_entry = addon_info.ingress_entry
109 | if not ingress_entry:
110 | raise ValueError("Ingress entry not found for Asterisk add-on")
111 | return self.json({"ingress_entry": ingress_entry})
112 | except Exception as err:
113 | logger.error(f"Error fetching Asterisk add-on info: {err}")
114 | return self.json({"error": "Failed to fetch add-on info"}, status_code=500)
115 |
--------------------------------------------------------------------------------
/src/sip-contacts-card.ts:
--------------------------------------------------------------------------------
1 | import { LitElement, html, css } from "lit";
2 | import { sipCore } from "./sip-core";
3 |
4 | declare global {
5 | interface Window {
6 | customCards?: Array<{ type: string; name: string; preview: boolean; description: string }>;
7 | }
8 | }
9 |
10 | interface Extension {
11 | name: string;
12 | status_entity: string | null;
13 | camera_entity: string | null;
14 | hidden: boolean | null;
15 | edit: boolean | null;
16 | override_icon: string | null;
17 | override_state: string | null;
18 | }
19 |
20 | interface SIPContactsCardConfig {
21 | extensions: { [key: string]: Extension };
22 | title: string;
23 | hide_me: boolean;
24 | state_color: boolean;
25 | }
26 |
27 | class SIPContactsCard extends LitElement {
28 | public hass: any;
29 | public config: SIPContactsCardConfig | undefined;
30 |
31 | static get styles() {
32 | return css`
33 | #audioVisualizer {
34 | min-height: 20em;
35 | height: 100%;
36 | white-space: nowrap;
37 | align-items: center;
38 | display: flex;
39 | justify-content: center;
40 | }
41 |
42 | #audioVisualizer div {
43 | display: inline-block;
44 | width: 3px;
45 | height: 100px;
46 | margin: 0 7px;
47 | background: currentColor;
48 | transform: scaleY(0.5);
49 | opacity: 0.25;
50 | }
51 |
52 | .wrapper {
53 | padding: 8px;
54 | padding-top: 0px;
55 | padding-bottom: 2px;
56 | margin-top: -16px;
57 | }
58 |
59 | .flex {
60 | flex: 1;
61 | margin-top: 6px;
62 | margin-bottom: 6px;
63 | display: flex;
64 | justify-content: space-between;
65 | align-items: center;
66 | min-width: 0;
67 | }
68 |
69 | .info,
70 | .info > * {
71 | white-space: nowrap;
72 | overflow: hidden;
73 | text-overflow: ellipsis;
74 | }
75 |
76 | .info {
77 | flex: 1 1 30%;
78 | cursor: pointer;
79 | margin-left: 16px;
80 | margin-right: 8px;
81 | }
82 |
83 | .editField {
84 | width: 100%;
85 | margin-left: 16px;
86 | margin-right: 8px;
87 | }
88 |
89 | state-badge {
90 | flex-shrink: 0;
91 | }
92 | `;
93 | }
94 |
95 | connectedCallback() {
96 | super.connectedCallback();
97 | window.addEventListener("sipcore-update", () => this.requestUpdate());
98 | }
99 |
100 | disconnectedCallback() {
101 | super.disconnectedCallback();
102 | window.removeEventListener("sipcore-update", () => this.requestUpdate());
103 | }
104 |
105 | render() {
106 | var connection_state = "";
107 | if (sipCore.RTCSession != null) {
108 | connection_state = sipCore.RTCSession?.status.toString();
109 | }
110 |
111 | return html`
112 |
113 |
114 | ${Object.entries(this.config?.extensions || {}).map(([number, extension]) => {
115 | const isMe = number === sipCore.user.extension;
116 | const stateObj = this.hass.states[extension.status_entity || ""] || null;
117 | if (extension.hidden) return;
118 | if (isMe && this.config?.hide_me) return;
119 | const icon = stateObj ? extension.override_icon : extension.override_icon || "mdi:account";
120 | if (extension.edit) {
121 | return html`
122 |
123 |
128 | {
135 | if (e.key === "Enter") {
136 | var el = this.shadowRoot?.getElementById(
137 | `custom_${extension.name}`,
138 | ) as any;
139 | const customNumber = el.value;
140 | sipCore.startCall(customNumber);
141 | }
142 | }}"
143 | class="editField"
144 | >
145 | {
147 | var el = this.shadowRoot?.getElementById(`custom_${extension.name}`) as any;
148 | const customNumber = el.value;
149 | sipCore.startCall(customNumber);
150 | }}"
151 | appearance="plain"
152 | size="small"
153 | variant="brand"
154 | >CALL
156 |
157 | `;
158 | } else {
159 | return html`
160 |
161 |
166 |
${extension.name}
167 |
sipCore.startCall(number)}"
169 | appearance="plain"
170 | size="small"
171 | variant="brand"
172 | >CALL
173 |
174 | `;
175 | }
176 | })}
177 |
178 |
179 | `;
180 | }
181 |
182 | // The user supplied configuration. Throw an exception and Home Assistant
183 | // will render an error card.
184 | setConfig(config: any) {
185 | this.config = config;
186 | // TODO: Check if config is valid
187 | }
188 |
189 | static getStubConfig() {
190 | return {
191 | extensions: {
192 | "100": {
193 | name: "John Doe",
194 | },
195 | "101": {
196 | name: "Joe Smith",
197 | },
198 | "102": {
199 | name: "Doorbell",
200 | override_icon: "mdi:doorbell",
201 | },
202 | },
203 | };
204 | }
205 |
206 | // The height of your card. Home Assistant uses this to automatically
207 | // distribute all cards over the available columns.
208 | getCardSize() {
209 | return 3;
210 | }
211 | }
212 |
213 | customElements.define("sip-contacts-card", SIPContactsCard);
214 |
215 | window.customCards = window.customCards || [];
216 | window.customCards.push({
217 | type: "sip-contacts-card",
218 | name: "SIP Contacts Card",
219 | preview: true,
220 | description: "Offical SIP Contacts Card",
221 | });
222 |
--------------------------------------------------------------------------------
/src/sip-call-card.ts:
--------------------------------------------------------------------------------
1 | import { LitElement, html, css } from "lit";
2 | import { customElement, property, state } from "lit/decorators.js";
3 | import { sipCore, CALLSTATE } from "./sip-core";
4 | import { AudioVisualizer } from "./audio-visualizer";
5 |
6 | declare global {
7 | interface Window {
8 | customCards?: Array<{ type: string; name: string; preview: boolean; description: string }>;
9 | }
10 | }
11 |
12 | interface Extension {
13 | name: string;
14 | status_entity: string | null;
15 | camera_entity: string | null;
16 | hidden: boolean | null;
17 | edit: boolean | null;
18 | override_icon: string | null;
19 | override_state: string | null;
20 | }
21 |
22 | enum ButtonType {
23 | SERVICE_CALL,
24 | DTMF,
25 | }
26 |
27 | interface Button {
28 | label: string;
29 | icon: string;
30 | type: ButtonType;
31 | data: any;
32 | }
33 |
34 | interface CallCardConfig {
35 | buttons: Button[];
36 | extensions: { [key: string]: Extension };
37 | idle_text: string;
38 | largeUI: boolean;
39 | }
40 |
41 | @customElement("sip-call-card")
42 | class SIPCallCard extends LitElement {
43 | @property()
44 | public hass = sipCore.hass;
45 |
46 | @property()
47 | public config: CallCardConfig | undefined;
48 |
49 | @state()
50 | private audioVisualizer: AudioVisualizer | undefined;
51 |
52 | setConfig(config: any) {
53 | this.config = config;
54 | // TODO: Check if config is valid
55 | }
56 |
57 | static getStubConfig() {
58 | return {
59 | extensions: {
60 | "100": {
61 | name: "John Doe",
62 | },
63 | "101": {
64 | name: "Joe Smith",
65 | },
66 | "102": {
67 | name: "Doorbell",
68 | override_icon: "mdi:doorbell",
69 | },
70 | },
71 | buttons: [],
72 | };
73 | }
74 |
75 | static get styles() {
76 | return css`
77 | ha-card {
78 | overflow: hidden;
79 | position: relative;
80 | height: 100%;
81 | }
82 |
83 | hui-image {
84 | width: 100%;
85 | height: auto;
86 | }
87 |
88 | #remoteVideo {
89 | width: 100%;
90 | height: auto;
91 | }
92 |
93 | ha-icon {
94 | display: flex;
95 | align-items: center;
96 | justify-content: center;
97 | }
98 |
99 | #audioVisualizer {
100 | min-height: 230px;
101 | white-space: nowrap;
102 | align-items: center;
103 | display: flex;
104 | justify-content: center;
105 | }
106 |
107 | #audioVisualizer div {
108 | display: inline-block;
109 | width: 3px;
110 | height: 100px;
111 | margin: 0 7px;
112 | background: currentColor;
113 | transform: scaleY(0.5);
114 | opacity: 0.25;
115 | }
116 |
117 | .placeholder {
118 | display: flex;
119 | justify-content: center;
120 | align-items: center;
121 | height: 100%;
122 | background-color: var(--secondary-background-color);
123 | color: var(--primary-text-color);
124 | min-height: 230px;
125 | }
126 |
127 | .footer {
128 | position: absolute;
129 | left: 0px;
130 | right: 0px;
131 | bottom: 0px;
132 | background-color: var(--ha-picture-card-background-color, rgba(0, 0, 0, 0.3));
133 | padding: 4px 8px;
134 | font-size: 16px;
135 | color: var(--ha-picture-card-text-color, #fff);
136 | --mdc-icon-button-size: 40px;
137 | }
138 |
139 | .footer > div {
140 | display: flex;
141 | }
142 |
143 | .both {
144 | display: flex;
145 | justify-content: space-between;
146 | }
147 |
148 | .footer span {
149 | align-self: center;
150 | margin: 0 8px;
151 | }
152 |
153 | .footer[large] {
154 | font-size: 24px;
155 | --mdc-icon-button-size: 68px;
156 | --mdc-icon-size: 42px;
157 | padding: 14px;
158 | }
159 |
160 | .footer[large] span {
161 | margin: 0 16px;
162 | }
163 | `;
164 | }
165 |
166 | updateHandler = (event: any) => {
167 | this.requestUpdate();
168 |
169 | if (sipCore.remoteVideoStream !== null) {
170 | const videoElement = this.renderRoot.querySelector("#remoteVideo") as HTMLVideoElement;
171 | if (videoElement && videoElement.srcObject !== sipCore.remoteVideoStream) {
172 | videoElement.srcObject = sipCore.remoteVideoStream;
173 | videoElement.play();
174 | }
175 | } else {
176 | const videoElement = this.renderRoot.querySelector("#remoteVideo") as HTMLVideoElement;
177 | if (videoElement) {
178 | videoElement.srcObject = null;
179 | videoElement.pause();
180 | }
181 | }
182 | };
183 |
184 | connectedCallback() {
185 | super.connectedCallback();
186 | window.addEventListener("sipcore-update", this.updateHandler);
187 | }
188 |
189 | disconnectedCallback() {
190 | super.disconnectedCallback();
191 | window.removeEventListener("sipcore-update", this.updateHandler);
192 | }
193 |
194 | render() {
195 | let camera: string = "";
196 | let statusText;
197 | let phoneIcon: string;
198 | let remoteName = this.config?.extensions[sipCore.remoteExtension || ""]?.name || sipCore.remoteName;
199 |
200 | switch (sipCore.callState) {
201 | case CALLSTATE.IDLE:
202 | statusText = "No active call";
203 | phoneIcon = "mdi:phone";
204 | break;
205 | case CALLSTATE.INCOMING:
206 | statusText = "Incoming call from " + remoteName;
207 | phoneIcon = "mdi:phone-incoming";
208 | break;
209 | case CALLSTATE.OUTGOING:
210 | statusText = "Outgoing call to " + remoteName;
211 | phoneIcon = "mdi:phone-outgoing";
212 | break;
213 | case CALLSTATE.CONNECTED:
214 | statusText = "Connected to " + remoteName;
215 | phoneIcon = "mdi:phone-in-talk";
216 | break;
217 | case CALLSTATE.CONNECTING:
218 | statusText = "Connecting to " + remoteName;
219 | phoneIcon = "mdi:phone";
220 | break;
221 | default:
222 | statusText = "Unknown call state";
223 | phoneIcon = "mdi:phone";
224 | break;
225 | }
226 |
227 | if (
228 | sipCore.callState !== CALLSTATE.IDLE &&
229 | sipCore.remoteExtension !== null &&
230 | sipCore.remoteVideoStream === null
231 | ) {
232 | camera = this.config?.extensions[sipCore.remoteExtension]?.camera_entity || "";
233 | if (!camera) {
234 | if (sipCore.remoteAudioStream !== null) {
235 | if (this.audioVisualizer === undefined) {
236 | this.audioVisualizer = new AudioVisualizer(this.renderRoot, sipCore.remoteAudioStream, 16);
237 | }
238 | } else {
239 | this.audioVisualizer = undefined;
240 | }
241 | }
242 | }
243 |
244 | return html`
245 |
246 |
251 |
254 | ${
255 | sipCore.callState === CALLSTATE.IDLE
256 | ? html`
257 |
258 | No active call
259 |
260 | `
261 | : camera
262 | ? html`
263 |
270 | `
271 | : ""
272 | }
273 |
360 |
361 | `;
362 | }
363 | }
364 |
365 | window.customCards = window.customCards || [];
366 | window.customCards.push({
367 | type: "sip-call-card",
368 | name: "SIP Call Card",
369 | preview: true,
370 | description: "Offical SIP Call Card",
371 | });
372 |
--------------------------------------------------------------------------------
/src/sip-core.ts:
--------------------------------------------------------------------------------
1 | import { UA, WebSocketInterface } from "jssip/lib/JsSIP";
2 | import { RTCSessionEvent, CallOptions } from "jssip/lib/UA";
3 | import { EndEvent, PeerConnectionEvent, IncomingEvent, IceCandidateEvent, RTCSession } from "jssip/lib/RTCSession";
4 | import pjson from "../package.json";
5 |
6 | const version = pjson.version;
7 |
8 | console.info(
9 | `%c SIP-CORE %c ${version} `,
10 | "color: white; background: dodgerblue; font-weight: 700;",
11 | "color: dodgerblue; background: white; font-weight: 700;",
12 | );
13 |
14 | /** Enum representing the various states of a SIP call */
15 | export enum CALLSTATE {
16 | IDLE = "idle",
17 | INCOMING = "incoming",
18 | OUTGOING = "outgoing",
19 | CONNECTING = "connecting",
20 | CONNECTED = "connected",
21 | }
22 |
23 | /** Enum representing the kind of audio device */
24 | export enum AUDIO_DEVICE_KIND {
25 | INPUT = "audioinput",
26 | OUTPUT = "audiooutput",
27 | }
28 |
29 | /** Mapping of a Home Assistant username to a SIP user */
30 | export interface User {
31 | ha_username: string;
32 | display_name: string;
33 | extension: string;
34 | password: string;
35 | }
36 |
37 | export interface ICEConfig extends RTCConfiguration {
38 | /** Timeout in milliseconds for ICE gathering */
39 | iceGatheringTimeout?: number;
40 | }
41 |
42 | /** Configuration for SIP Core */
43 | export interface SIPCoreConfig {
44 | ice_config: ICEConfig;
45 | backup_user: User;
46 | users: User[];
47 | /** URL for incoming call ringtone */
48 | incomingRingtoneUrl: string;
49 | /** URL for outgoing call ringtone */
50 | outgoingRingtoneUrl: string;
51 | /** Output configuration */
52 | out: String;
53 | auto_answer: boolean;
54 | popup_config: Object | null;
55 | popup_override_component: string | null;
56 | /**
57 | * Whether to use video in SIP calls.
58 | * @experimental
59 | */
60 | sip_video: boolean;
61 | pbx_server: string;
62 | /**
63 | * Custom WebSocket URL to use when ingress is not setup
64 | *
65 | * @example
66 | * "wss://sip.example.com/ws"
67 | */
68 | custom_wss_url: string;
69 | }
70 |
71 | /**
72 | * Main class for SIP Core functionality.
73 | * Handles SIP registration, call management, and audio device management.
74 | */
75 | export class SIPCore {
76 | /**
77 | * The JSSIP User Agent instance
78 | * @see {@link https://jssip.net/documentation/3.1.x/api/ua/}
79 | */
80 | public ua!: UA;
81 |
82 | /**
83 | * The current RTC session, if available
84 | * @see {@link https://jssip.net/documentation/3.1.x/api/session/}
85 | */
86 | public RTCSession: RTCSession | null = null;
87 |
88 | public version: string = version;
89 | public hass: any;
90 | public user!: User;
91 | public config!: SIPCoreConfig;
92 |
93 | private heartBeatHandle: number | null = null;
94 | private heartBeatIntervalMs: number = 30000;
95 |
96 | private callTimerHandle: number | null = null;
97 |
98 | private wssUrl!: string;
99 | private iceCandidateTimeout: number | null = null;
100 |
101 | public remoteAudioStream: MediaStream | null = null;
102 | public remoteVideoStream: MediaStream | null = null;
103 |
104 | public incomingAudio: HTMLAudioElement | null = null;
105 | public outgoingAudio: HTMLAudioElement | null = null;
106 |
107 | constructor() {
108 | // Get hass instance
109 | const homeAssistant = document.querySelector("home-assistant");
110 | if (!homeAssistant) {
111 | throw new Error("Home Assistant element not found");
112 | }
113 | this.hass = (homeAssistant as any).hass;
114 |
115 | // Bind event handlers
116 | this.handleRemoteTrackEvent = this.handleRemoteTrackEvent.bind(this);
117 | this.handleIceGatheringStateChangeEvent = this.handleIceGatheringStateChangeEvent.bind(this);
118 | }
119 |
120 | /** Returns the remote extension. Returns `null` if not in a call */
121 | get remoteExtension(): string | null {
122 | return this.RTCSession?.remote_identity.uri.user || null;
123 | }
124 |
125 | /** Returns the remote display name if available, otherwise the extension. Returns `null` if not in a call */
126 | get remoteName(): string | null {
127 | return this.RTCSession?.remote_identity.display_name || this.RTCSession?.remote_identity.uri.user || null;
128 | }
129 |
130 | get registered(): boolean {
131 | return this.ua.isRegistered();
132 | }
133 |
134 | private async fetchWSSUrl(): Promise {
135 | if (this.config.custom_wss_url) {
136 | console.debug("Using custom WSS URL:", this.config.custom_wss_url);
137 | return this.config.custom_wss_url;
138 | }
139 |
140 | // async fetch ingress entry
141 | const token = this.hass.auth.data.access_token;
142 | try {
143 | const resp = await fetch("/api/sip-core/asterisk-ingress", {
144 | method: "GET",
145 | headers: {
146 | Authorization: `Bearer ${token}`,
147 | },
148 | });
149 | if (resp.ok) {
150 | const data = await resp.json();
151 | const wssProtocol = window.location.protocol == "https:" ? "wss" : "ws";
152 | console.debug("Ingress entry fetched:", data.ingress_entry);
153 | return `${wssProtocol}://${window.location.host}${data.ingress_entry}/ws`;
154 | } else {
155 | throw new Error(`Failed to fetch ingress entry: ${resp.statusText}`);
156 | }
157 | } catch (error) {
158 | console.error("Error fetching ingress entry:", error);
159 | throw new Error("No ingress entry or custom WSS URL provided");
160 | }
161 | }
162 |
163 | private async callOptions(): Promise {
164 | let micStream: MediaStream | undefined = undefined;
165 | if (this.AudioInputId !== null) {
166 | console.debug(`Using audio input device: ${this.AudioInputId}`);
167 | try {
168 | micStream = await navigator.mediaDevices.getUserMedia({
169 | audio: {
170 | deviceId: { exact: this.AudioInputId },
171 | },
172 | video: this.config.sip_video,
173 | });
174 | } catch (err) {
175 | console.error(`Error getting audio input: ${err}`);
176 | micStream = undefined;
177 | }
178 | }
179 |
180 | if (this.AudioOutputId !== null) {
181 | console.debug(`Using audio output device: ${this.AudioOutputId}`);
182 | let audioElement = document.getElementById("remoteAudio") as any;
183 | try {
184 | await audioElement.setSinkId(this.AudioOutputId);
185 | } catch (err) {
186 | console.error(`Error setting audio output: ${err}`);
187 | }
188 | }
189 |
190 | return {
191 | mediaConstraints: {
192 | audio: true,
193 | video: this.config.sip_video,
194 | },
195 | mediaStream: micStream,
196 | rtcConstraints: {
197 | offerToReceiveAudio: true,
198 | offerToReceiveVideo: this.config.sip_video,
199 | },
200 | pcConfig: this.config.ice_config,
201 | };
202 | }
203 |
204 | get callState(): CALLSTATE {
205 | if (this.RTCSession?.isEstablished()) {
206 | return CALLSTATE.CONNECTED;
207 | } else if (this.RTCSession?.connection?.connectionState === "connecting") {
208 | return CALLSTATE.CONNECTING;
209 | } else if (this.RTCSession?.isInProgress()) {
210 | return this.RTCSession?.direction === "incoming" ? CALLSTATE.INCOMING : CALLSTATE.OUTGOING;
211 | }
212 | return CALLSTATE.IDLE;
213 | }
214 |
215 | /** Returns call duration in format `0:00` */
216 | get callDuration(): string {
217 | if (this.RTCSession?.start_time) {
218 | var delta = Math.floor((Date.now() - this.RTCSession.start_time.getTime()) / 1000);
219 | var minutes = Math.floor(delta / 60);
220 | var seconds = delta % 60;
221 | return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
222 | }
223 | return "0:00";
224 | }
225 |
226 | get AudioOutputId(): string | null {
227 | return localStorage.getItem("sipcore-audio-output");
228 | }
229 |
230 | set AudioOutputId(deviceId: string | null) {
231 | if (deviceId === null) {
232 | localStorage.removeItem("sipcore-audio-output");
233 | } else {
234 | localStorage.setItem("sipcore-audio-output", deviceId);
235 | }
236 | console.debug(`Audio output set to ${deviceId}`);
237 | }
238 |
239 | get AudioInputId(): string | null {
240 | return localStorage.getItem("sipcore-audio-input");
241 | }
242 |
243 | set AudioInputId(deviceId: string | null) {
244 | if (deviceId === null) {
245 | localStorage.removeItem("sipcore-audio-input");
246 | } else {
247 | localStorage.setItem("sipcore-audio-input", deviceId);
248 | }
249 | console.debug(`Audio input set to ${deviceId}`);
250 | }
251 |
252 | private async setupAudio() {
253 | this.incomingAudio = new Audio(this.config.incomingRingtoneUrl);
254 | this.outgoingAudio = new Audio(this.config.outgoingRingtoneUrl);
255 | this.incomingAudio.loop = true;
256 | this.outgoingAudio.loop = true;
257 |
258 | let audioElement = document.createElement("audio") as any;
259 | audioElement.id = "remoteAudio";
260 | audioElement.autoplay = true;
261 | audioElement.style.display = "none";
262 | document.body.appendChild(audioElement);
263 | }
264 |
265 | private setupPopup() {
266 | let POPUP_COMPONENT = this.config.popup_override_component || "sip-call-dialog";
267 | if (document.getElementsByTagName(POPUP_COMPONENT).length < 1) {
268 | document.body.appendChild(document.createElement(POPUP_COMPONENT));
269 | }
270 | }
271 |
272 | private startCallTimer() {
273 | this.callTimerHandle = setInterval(() => {
274 | this.triggerUpdate();
275 | }, 1000);
276 | }
277 |
278 | private stopCallTimer() {
279 | if (this.callTimerHandle) {
280 | clearInterval(this.callTimerHandle);
281 | this.callTimerHandle = null;
282 | }
283 | }
284 |
285 | async init() {
286 | this.config = await this.fetchConfig(this.hass);
287 | this.wssUrl = await this.fetchWSSUrl();
288 | await this.createHassioSession();
289 | await this.setupAudio();
290 | await this.setupUser();
291 |
292 | console.debug(`Connecting to ${this.wssUrl}...`);
293 | this.ua.start();
294 | if (this.config.popup_config !== null) {
295 | this.setupPopup();
296 | }
297 | this.triggerUpdate();
298 |
299 | // autocall if set
300 | const autocall_extension = new URLSearchParams(window.location.search).get("call");
301 | if (autocall_extension) {
302 | console.info(`Autocalling ${autocall_extension}...`);
303 | this.startCall(autocall_extension);
304 | }
305 | }
306 |
307 | private async setupUser(): Promise {
308 | try {
309 | const persons = await this.hass.callWS({ type: "person/list" });
310 | const currentUsername = persons.storage.find((person: any) => person.user_id === this.hass.user.id).id;
311 | this.user = this.config.users.find((user) => user.ha_username === currentUsername) || this.config.backup_user;
312 | } catch (error) {
313 | console.error("Error fetching persons from Home Assistant:", error);
314 | this.user = this.config.backup_user;
315 | }
316 | console.debug(`Selected user: ${this.user.ha_username} (${this.user.extension})`);
317 | this.ua = this.setupUA();
318 | }
319 |
320 | private async fetchConfig(hass: any): Promise {
321 | const token = hass.auth.data.access_token;
322 | const resp = await fetch("/api/sip-core/config?t=" + Date.now(), {
323 | method: "GET",
324 | headers: {
325 | Authorization: `Bearer ${token}`,
326 | },
327 | });
328 | if (resp.ok) {
329 | const config: SIPCoreConfig = await resp.json();
330 | console.debug("SIP-Core Config fetched:", config);
331 | return config;
332 | } else {
333 | console.error("No SIP-Core config found at /config/www/sip-config.json!");
334 | throw new Error(`Failed to fetch sip-config.json: ${resp.statusText}`);
335 | }
336 | }
337 |
338 | playIncomingRingtone(): void {
339 | if (this.incomingAudio) {
340 | this.incomingAudio.play().catch((error) => {
341 | console.error("Incoming ringtone failed:", error);
342 | });
343 | }
344 | }
345 |
346 | stopIncomingRingtone(): void {
347 | if (this.incomingAudio) {
348 | this.incomingAudio.pause();
349 | this.incomingAudio.currentTime = 0;
350 | }
351 | }
352 |
353 | playOutgoingTone(): void {
354 | if (this.outgoingAudio) {
355 | this.outgoingAudio.play().catch((error) => {
356 | console.error("Incoming ringtone failed:", error);
357 | });
358 | }
359 | }
360 |
361 | stopOutgoingTone(): void {
362 | if (this.outgoingAudio) {
363 | this.outgoingAudio.pause();
364 | this.outgoingAudio.currentTime = 0;
365 | }
366 | }
367 |
368 | async answerCall() {
369 | if (this.callState !== CALLSTATE.INCOMING) {
370 | console.warn("Not in incoming call state. Cannot answer.");
371 | return;
372 | }
373 | this.RTCSession?.answer(await this.callOptions());
374 | this.triggerUpdate();
375 | }
376 |
377 | async endCall() {
378 | this.RTCSession?.terminate();
379 | this.triggerUpdate();
380 | }
381 |
382 | async startCall(extension: string) {
383 | this.ua.call(extension, await this.callOptions());
384 | }
385 |
386 | /** Dispatches a `sipcore-update` event */
387 | triggerUpdate() {
388 | window.dispatchEvent(new Event("sipcore-update"));
389 | }
390 |
391 | private setupUA(): UA {
392 | const socket = new WebSocketInterface(this.wssUrl);
393 | const ua = new UA({
394 | sockets: [socket],
395 | uri: `${this.user.extension}@${this.config.pbx_server || window.location.host}`,
396 | authorization_user: this.user.extension,
397 | display_name: this.user.display_name || this.user.ha_username,
398 | password: this.user.password,
399 | register: true,
400 | });
401 |
402 | ua.on("registered", (e) => {
403 | console.info("Registered");
404 | this.triggerUpdate();
405 |
406 | if (this.heartBeatHandle != null) {
407 | clearInterval(this.heartBeatHandle);
408 | }
409 | this.heartBeatHandle = setInterval(() => {
410 | console.debug("Sending heartbeat");
411 | socket.send("\n\n");
412 | }, this.heartBeatIntervalMs);
413 | });
414 | ua.on("unregistered", (e) => {
415 | console.warn("Unregistered");
416 | this.triggerUpdate();
417 | if (this.heartBeatHandle != null) {
418 | clearInterval(this.heartBeatHandle);
419 | }
420 | });
421 | ua.on("registrationFailed", (e) => {
422 | console.error("Registration failed:", e);
423 | this.triggerUpdate();
424 | if (this.heartBeatHandle != null) {
425 | clearInterval(this.heartBeatHandle);
426 | }
427 |
428 | if (e.cause === "Connection Error") {
429 | console.error("Connection error. Retrying...");
430 | setTimeout(() => {
431 | this.ua.start();
432 | }, 5000);
433 | }
434 | });
435 | ua.on("newRTCSession", (e: RTCSessionEvent) => {
436 | console.debug(`New RTC Session: ${e.originator}`);
437 |
438 | if (this.RTCSession !== null) {
439 | console.debug("Terminating new RTC session");
440 | e.session.terminate();
441 | return;
442 | }
443 | this.RTCSession = e.session;
444 |
445 | e.session.on("failed", (e: EndEvent) => {
446 | console.warn("Call failed:", e);
447 | window.dispatchEvent(new Event("sipcore-call-ended"));
448 | this.RTCSession = null;
449 | this.remoteVideoStream = null;
450 | this.remoteAudioStream = null;
451 | this.stopCallTimer();
452 | this.stopOutgoingTone();
453 | this.stopIncomingRingtone();
454 | this.triggerUpdate();
455 | });
456 | e.session.on("ended", (e: EndEvent) => {
457 | console.info("Call ended:", e);
458 | window.dispatchEvent(new Event("sipcore-call-ended"));
459 | this.RTCSession = null;
460 | this.remoteVideoStream = null;
461 | this.remoteAudioStream = null;
462 | this.stopCallTimer();
463 | this.stopOutgoingTone();
464 | this.stopIncomingRingtone();
465 | this.triggerUpdate();
466 | });
467 | e.session.on("accepted", (e: IncomingEvent) => {
468 | console.info("Call accepted");
469 | this.startCallTimer();
470 | this.stopOutgoingTone();
471 | this.stopIncomingRingtone();
472 | this.triggerUpdate();
473 | });
474 |
475 | e.session.on("icecandidate", (e: IceCandidateEvent) => {
476 | console.debug("ICE candidate:", e.candidate?.candidate);
477 | if (this.iceCandidateTimeout != null) {
478 | clearTimeout(this.iceCandidateTimeout);
479 | }
480 |
481 | this.iceCandidateTimeout = setTimeout(() => {
482 | console.debug("ICE stopped gathering candidates due to timeout");
483 | e.ready();
484 | }, this.config.ice_config.iceGatheringTimeout || 5000);
485 | });
486 |
487 | window.dispatchEvent(new Event("sipcore-call-started"));
488 |
489 | switch (e.session.direction) {
490 | case "incoming":
491 | console.info("Incoming call");
492 | this.triggerUpdate();
493 | this.playIncomingRingtone();
494 |
495 | e.session.on("peerconnection", (e: PeerConnectionEvent) => {
496 | console.debug("Incoming call peer connection established");
497 |
498 | e.peerconnection.addEventListener("track", this.handleRemoteTrackEvent);
499 | e.peerconnection.addEventListener(
500 | "icegatheringstatechange",
501 | this.handleIceGatheringStateChangeEvent,
502 | );
503 | });
504 |
505 | if (this.config.auto_answer) {
506 | console.info("Auto answering call...");
507 | this.answerCall();
508 | }
509 | break;
510 |
511 | case "outgoing":
512 | console.info("Outgoing call");
513 | this.playOutgoingTone();
514 | this.triggerUpdate();
515 |
516 | e.session.connection.addEventListener("track", this.handleRemoteTrackEvent);
517 | e.session.connection.addEventListener(
518 | "icegatheringstatechange",
519 | this.handleIceGatheringStateChangeEvent,
520 | );
521 | break;
522 | }
523 | });
524 | return ua;
525 | }
526 |
527 | private handleIceGatheringStateChangeEvent(e: any) {
528 | console.debug("ICE gathering state changed:", e.target?.iceGatheringState);
529 | if (e.target?.iceGatheringState === "complete") {
530 | console.debug("ICE gathering complete");
531 | if (this.iceCandidateTimeout != null) {
532 | clearTimeout(this.iceCandidateTimeout);
533 | }
534 | }
535 | }
536 |
537 | private async handleRemoteTrackEvent(e: RTCTrackEvent) {
538 | let stream: MediaStream;
539 | if (e.streams.length > 0) {
540 | console.debug(`Received remote streams amount: ${e.streams.length}. Using first stream...`);
541 | stream = e.streams[0];
542 | } else {
543 | console.debug("No associated streams. Creating new stream...");
544 | stream = new MediaStream();
545 | stream.addTrack(e.track);
546 | }
547 |
548 | let remoteAudio = document.getElementById("remoteAudio") as HTMLAudioElement;
549 | if (e.track.kind === "audio" && remoteAudio.srcObject != stream) {
550 | this.remoteAudioStream = stream;
551 | remoteAudio.srcObject = stream;
552 | try {
553 | await remoteAudio.play();
554 | } catch (err) {
555 | console.error("Error starting audio playback: " + err);
556 | }
557 | }
558 |
559 | if (e.track.kind === "video") {
560 | console.debug("Received remote video track");
561 | this.remoteVideoStream = stream;
562 | }
563 |
564 | this.triggerUpdate();
565 | }
566 |
567 | // borrowed from https://github.com/lovelylain/ha-addon-iframe-card/blob/main/src/hassio-ingress.ts
568 | private setIngressCookie(session: string): string {
569 | document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${
570 | location.protocol === "https:" ? ";Secure" : ""
571 | }`;
572 | return session;
573 | }
574 |
575 | private async createHassioSession(): Promise {
576 | try {
577 | const resp: { session: string } = await this.hass.callWS({
578 | type: "supervisor/api",
579 | endpoint: "/ingress/session",
580 | method: "post",
581 | });
582 | this.setIngressCookie(resp.session);
583 | } catch (error) {
584 | if ((error as any)?.code === "unknown_command") {
585 | console.info("Home Assistant Supervisor API not available. Assuming not running on Home Assistant OS.");
586 | } else {
587 | console.error("Error creating Hass.io session:", error);
588 | throw error;
589 | }
590 | }
591 | }
592 |
593 | private async validateHassioSession(session: string) {
594 | await this.hass.callWS({
595 | type: "supervisor/api",
596 | endpoint: "/ingress/validate_session",
597 | method: "post",
598 | data: { session },
599 | });
600 | this.setIngressCookie(session);
601 | }
602 |
603 | /** Returns a list of audio devices of the specified kind */
604 | async getAudioDevices(audioKind: AUDIO_DEVICE_KIND) {
605 | console.debug(`Fetching audio devices of kind: ${audioKind}`);
606 | // first get permission to use audio devices
607 | let stream = await navigator.mediaDevices.getUserMedia({ audio: true });
608 | stream.getTracks().forEach((track) => track.stop());
609 |
610 | const devices = await navigator.mediaDevices.enumerateDevices();
611 | return devices.filter((device) => device.kind == audioKind);
612 | }
613 | }
614 |
615 | /** @hidden */
616 | const sipCore = new SIPCore();
617 | await sipCore.init().catch((error) => {
618 | console.error("Error initializing SIP Core:", error);
619 | });
620 | (window as any).sipCore = sipCore;
621 | export { sipCore };
622 |
--------------------------------------------------------------------------------
/src/sip-call-dialog.ts:
--------------------------------------------------------------------------------
1 | import { LitElement, html, css } from "lit";
2 | import { customElement, property, state } from "lit/decorators.js";
3 | import { sipCore, CALLSTATE, AUDIO_DEVICE_KIND } from "./sip-core";
4 | import { AudioVisualizer } from "./audio-visualizer";
5 |
6 | interface Extension {
7 | name: string;
8 | extension: string;
9 | camera_entity: string | null;
10 | }
11 |
12 | enum ButtonType {
13 | SERVICE_CALL = "service_call",
14 | DTMF = "dtmf"
15 | }
16 |
17 | interface Button {
18 | label: string;
19 | icon: string;
20 | type: ButtonType;
21 | data: any;
22 | }
23 |
24 | interface PopupConfig {
25 | buttons: Button[];
26 | extensions: { [key: string]: Extension };
27 | large: boolean | undefined;
28 | auto_open: boolean;
29 | hide_header_button?: boolean;
30 | }
31 |
32 | @customElement("sip-call-dialog")
33 | class SIPCallDialog extends LitElement {
34 | @property()
35 | public open = false;
36 |
37 | @property()
38 | public configuratorOpen = false;
39 |
40 | @state()
41 | private outputDevices: MediaDeviceInfo[] = [];
42 |
43 | @state()
44 | private inputDevices: MediaDeviceInfo[] = [];
45 |
46 | @property()
47 | public hass = sipCore.hass;
48 |
49 | @property()
50 | public config = sipCore.config.popup_config as PopupConfig;
51 |
52 | @state()
53 | private audioVisualizer: AudioVisualizer | undefined;
54 |
55 | @state()
56 | private buttonListenerActive = false;
57 |
58 | constructor() {
59 | super();
60 | this.setupButton();
61 |
62 | // bind openPopup and closePopup to this instance
63 | this.openPopup = this.openPopup.bind(this);
64 | this.closePopup = this.closePopup.bind(this);
65 | }
66 |
67 | static get styles() {
68 | return css`
69 | ha-icon[slot="meta"] {
70 | width: 18px;
71 | height: 18px;
72 | }
73 |
74 | ha-icon {
75 | display: flex;
76 | align-items: center;
77 | justify-content: center;
78 | }
79 |
80 | #audioVisualizer {
81 | min-height: 10em;
82 | white-space: nowrap;
83 | align-items: center;
84 | display: flex;
85 | justify-content: center;
86 | padding-top: 2em;
87 | }
88 |
89 | #audioVisualizer div {
90 | display: inline-block;
91 | width: 3px;
92 | height: 100px;
93 | margin: 0 7px;
94 | background: currentColor;
95 | transform: scaleY(0.5);
96 | opacity: 0.25;
97 | }
98 |
99 | ha-dialog {
100 | --dialog-content-padding: 0;
101 | --mdc-dialog-min-width: 600px;
102 | }
103 |
104 | ha-dialog[large] {
105 | --dialog-content-padding: 0;
106 | --mdc-dialog-min-width: 90vw;
107 | --mdc-dialog-max-width: 90vw;
108 | --mdc-dialog-max-height: 90vh;
109 | }
110 |
111 | ha-camera-stream {
112 | height: 100%;
113 | width: 100%;
114 | display: block;
115 | }
116 |
117 | #remoteVideo {
118 | height: 100%;
119 | width: 100%;
120 | }
121 |
122 | @media (max-width: 600px), (max-height: 600px) {
123 | ha-dialog {
124 | --dialog-surface-margin-top: 0px;
125 | --mdc-dialog-min-width: calc(100vw - env(safe-area-inset-right) - env(safe-area-inset-left));
126 | --mdc-dialog-max-width: calc(100vw - env(safe-area-inset-right) - env(safe-area-inset-left));
127 | --mdc-dialog-min-height: 100%;
128 | --mdc-dialog-max-height: 100%;
129 | --vertical-align-dialog: flex-end;
130 | --ha-dialog-border-radius: 0;
131 | }
132 | }
133 |
134 | .accept-button {
135 | color: var(--label-badge-green);
136 | }
137 |
138 | .deny-button {
139 | color: var(--label-badge-red);
140 | }
141 |
142 | .deny-button,
143 | .accept-button,
144 | .audio-button {
145 | --mdc-icon-button-size: 64px;
146 | --mdc-icon-size: 32px;
147 | }
148 |
149 | .row {
150 | display: flex;
151 | flex-direction: row;
152 | justify-content: space-between;
153 | }
154 |
155 | .bottom-row {
156 | display: flex;
157 | justify-content: space-between;
158 | padding: 12px 16px;
159 | border-top: 1px solid var(--divider-color);
160 | }
161 |
162 | .content {
163 | display: flex;
164 | justify-content: center;
165 | align-items: center;
166 | min-height: 300px;
167 | width: 100%;
168 | }
169 |
170 | .form {
171 | display: flex;
172 | flex-direction: column;
173 | padding: 16px;
174 | }
175 |
176 | ha-select {
177 | margin: 8px 0;
178 | }
179 | `;
180 | }
181 |
182 | updateHandler = (event: any) => {
183 | this.requestUpdate();
184 |
185 | if (sipCore.remoteVideoStream !== null) {
186 | const videoElement = this.renderRoot.querySelector("#remoteVideo") as HTMLVideoElement;
187 | if (videoElement && videoElement.srcObject !== sipCore.remoteVideoStream) {
188 | videoElement.srcObject = sipCore.remoteVideoStream;
189 | videoElement.play();
190 | }
191 | } else {
192 | const videoElement = this.renderRoot.querySelector("#remoteVideo") as HTMLVideoElement;
193 | if (videoElement) {
194 | videoElement.srcObject = null;
195 | videoElement.pause();
196 | }
197 | }
198 | };
199 |
200 | connectedCallback() {
201 | super.connectedCallback();
202 | window.addEventListener("sipcore-update", this.updateHandler);
203 |
204 | if (this.config.auto_open !== false) {
205 | window.addEventListener("sipcore-call-started", this.openPopup);
206 | window.addEventListener("sipcore-call-ended", this.closePopup);
207 | } else {
208 | window.addEventListener("sipcore-call-started", this.updateHandler);
209 | window.addEventListener("sipcore-call-ended", this.updateHandler);
210 | }
211 | }
212 |
213 | disconnectedCallback() {
214 | super.disconnectedCallback();
215 | window.removeEventListener("sipcore-update", this.updateHandler);
216 |
217 | if (this.config.auto_open !== false) {
218 | window.removeEventListener("sipcore-call-started", this.openPopup);
219 | window.removeEventListener("sipcore-call-ended", this.closePopup);
220 | } else {
221 | window.removeEventListener("sipcore-call-started", this.updateHandler);
222 | window.removeEventListener("sipcore-call-ended", this.updateHandler);
223 | }
224 | }
225 |
226 | openPopup() {
227 | this.open = true;
228 | this.requestUpdate();
229 | }
230 |
231 | closePopup() {
232 | this.open = false;
233 | this.requestUpdate();
234 | }
235 |
236 | render() {
237 | let camera: string = "";
238 | let statusText;
239 | let phoneIcon: string;
240 | let remoteName = this.config?.extensions[sipCore.remoteExtension || ""]?.name || sipCore.remoteName;
241 |
242 | switch (sipCore.callState) {
243 | case CALLSTATE.IDLE:
244 | statusText = "No active call";
245 | phoneIcon = "mdi:phone";
246 | break;
247 | case CALLSTATE.INCOMING:
248 | statusText = "Incoming call from " + remoteName;
249 | phoneIcon = "mdi:phone-incoming";
250 | break;
251 | case CALLSTATE.OUTGOING:
252 | statusText = "Outgoing call to " + remoteName;
253 | phoneIcon = "mdi:phone-outgoing";
254 | break;
255 | case CALLSTATE.CONNECTED:
256 | statusText = "Connected to " + remoteName;
257 | phoneIcon = "mdi:phone-in-talk";
258 | break;
259 | case CALLSTATE.CONNECTING:
260 | statusText = "Connecting to " + remoteName;
261 | phoneIcon = "mdi:phone";
262 | break;
263 | default:
264 | statusText = "Unknown call state";
265 | phoneIcon = "mdi:phone";
266 | break;
267 | }
268 |
269 | if (
270 | sipCore.callState !== CALLSTATE.IDLE &&
271 | sipCore.remoteExtension !== null &&
272 | sipCore.remoteVideoStream === null
273 | ) {
274 | camera = this.config.extensions[sipCore.remoteExtension]?.camera_entity || "";
275 | if (!camera) {
276 | if (sipCore.remoteAudioStream !== null) {
277 | if (this.audioVisualizer === undefined) {
278 | this.audioVisualizer = new AudioVisualizer(this.renderRoot, sipCore.remoteAudioStream, 16);
279 | }
280 | } else {
281 | this.audioVisualizer = undefined;
282 | }
283 | }
284 | }
285 |
286 | return html`
287 | {
288 | this.configuratorOpen = false;
289 | if (!this.open) this.closePopup();
290 | }} hideActions flexContent .heading=${true} data-domain="camera" ?large=${this.config.large}>
291 |
292 | {
295 | this.configuratorOpen = false;
296 | this.openPopup();
297 | }}>
298 |
299 | SIP Call Settings
300 |
301 |
302 | any }) => event.stopPropagation()}">
310 |
314 | Default Output
315 |
316 |
317 | ${this.outputDevices.map(
318 | (device) => html`
319 |
324 | ${device.label}
325 |
326 |
327 | `,
328 | )}
329 |
330 |
331 | any }) => event.stopPropagation()}">
339 |
343 | Default Input
344 |
345 |
346 | ${this.inputDevices.map(
347 | (device) => html`
348 |
353 | ${device.label}
354 |
355 |
356 | `,
357 | )}
358 |
359 |
360 |
361 | Logged in as ${sipCore.user.ha_username} (${
362 | sipCore.user.extension
363 | })
364 | The current user used to log in to the SIP server. You can configure users in the SIP Core options
365 |
366 |
367 | Is ${
368 | sipCore.registered ? "registered" : "not registered"
369 | } (${sipCore.registered ? "true" : "false"})
370 | The current registration status of the SIP client. If not registered, check browser console and Asterisk logs for more information
371 |
372 |
373 | Call state is ${sipCore.callState}
374 | The current call state of the SIP client
375 |
376 |
377 | SIP-Core v${sipCore.version}
378 | The main SIP call system, created by Jordy Kuhne
379 |
380 |
381 |
382 |
383 |
386 |
387 |
391 |
392 |
393 |
394 | ${statusText}
395 | ${sipCore.callDuration}
396 |
397 | {
402 | this.outputDevices = await sipCore.getAudioDevices(AUDIO_DEVICE_KIND.OUTPUT);
403 | this.inputDevices = await sipCore.getAudioDevices(AUDIO_DEVICE_KIND.INPUT);
404 | this.configuratorOpen = true;
405 | this.requestUpdate();
406 | }}">
407 |
408 |
409 | any }) => event.stopPropagation()}"
415 | >
416 |
419 |
420 |
421 | {
425 | window.open("https://tech7fox.github.io/sip-hass-docs", "_blank");
426 | }}">
427 | Documentation
428 |
429 |
430 |
431 | {
435 | window.open("https://github.com/TECH7Fox/sipcore-hass-integration", "_blank");
436 | }}">
437 | Github
438 |
439 |
440 |
441 |
442 |
443 |
444 |
445 |
450 |
453 | ${
454 | sipCore.callState === CALLSTATE.IDLE
455 | ? html`
456 |
457 | No active call
458 |
459 | `
460 | : camera
461 | ? html`
462 |
468 | `
469 | : ""
470 | }
471 |
472 |
473 |
sipCore.answerCall()}">
477 |
478 |
479 |
480 | ${this.config.buttons.map((button) => {
481 | if (button.type === ButtonType.SERVICE_CALL) {
482 | return html`
483 | {
487 | const { domain, service, ...service_data } = button.data;
488 | this.hass.callService(domain, service, service_data);
489 | }}"
490 | >
491 |
492 |
493 | `;
494 | } else if (button.type === ButtonType.DTMF) {
495 | return html`
496 | {
500 | sipCore.RTCSession?.sendDTMF(button.data);
501 | }}"
502 | >
503 |
504 |
505 | `;
506 | }
507 | })}
508 |
509 |
510 | {
515 | if (sipCore.RTCSession?.isMuted().audio)
516 | sipCore.RTCSession?.unmute({ audio: true });
517 | else sipCore.RTCSession?.mute({ audio: true });
518 | this.requestUpdate();
519 | }}">
520 |
527 |
528 | {
534 | if (sipCore.RTCSession?.isMuted().video)
535 | sipCore.RTCSession?.unmute({ video: true });
536 | else sipCore.RTCSession?.mute({ video: true });
537 | this.requestUpdate();
538 | }}">
539 |
546 |
547 |
548 |
{
552 | sipCore.endCall();
553 | this.closePopup();
554 | }}">
555 |
556 |
557 |
558 |
559 |
560 | `;
561 | }
562 |
563 | async firstUpdated() {
564 |
565 | }
566 |
567 | private handleAudioInputChange(event: Event) {
568 | const select = event.target as HTMLSelectElement;
569 | sipCore.AudioInputId = select.value === "null" ? null : select.value;
570 | this.requestUpdate();
571 | }
572 |
573 | private handleAudioOutputChange(event: Event) {
574 | const select = event.target as HTMLSelectElement;
575 | sipCore.AudioOutputId = select.value === "null" ? null : select.value;
576 | this.requestUpdate();
577 | }
578 |
579 | setupButton() {
580 | // Check if the header button should be hidden
581 | if (this.config.hide_header_button === true) {
582 | console.debug("Header button is disabled by configuration");
583 | return;
584 | }
585 |
586 | const homeAssistant = document.getElementsByTagName("home-assistant")[0];
587 | const panel = homeAssistant?.shadowRoot
588 | ?.querySelector("home-assistant-main")
589 | ?.shadowRoot?.querySelector("ha-panel-lovelace");
590 |
591 | if (panel === null) {
592 | console.debug("panel not found!");
593 | return;
594 | }
595 |
596 | const actionItems = panel?.shadowRoot?.querySelector("hui-root")?.shadowRoot?.querySelector(".action-items");
597 |
598 | if (actionItems?.querySelector("#sipcore-call-button")) {
599 | return;
600 | }
601 |
602 | const callButton = document.createElement("ha-icon-button") as any;
603 | callButton.label = "Open Call Popup";
604 | const icon = document.createElement("ha-icon") as any;
605 | icon.style = "display: flex; align-items: center; justify-content: center;";
606 | (icon as any).icon = "mdi:phone";
607 | callButton.slot = "actionItems";
608 | callButton.id = "sipcore-call-button";
609 | callButton.appendChild(icon);
610 | callButton.addEventListener("click", () => {
611 | this.open = true;
612 | this.requestUpdate();
613 | });
614 | actionItems?.appendChild(callButton);
615 |
616 | if (!this.buttonListenerActive) {
617 | this.buttonListenerActive = true;
618 | window.addEventListener("location-changed", () => {
619 | console.debug("View changed, setting up button again...");
620 | this.setupButton();
621 | });
622 | }
623 | }
624 | }
625 |
--------------------------------------------------------------------------------