├── .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 | 66 | 67 | 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 | SIP Core Logo 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 | [![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white)](https://discordapp.com/invite/qxnDtHbwuD) 21 | [![SIP-HASS Docs](https://img.shields.io/badge/SIP_HASS_Docs-%233ECC5F.svg?style=for-the-badge&logo=bookstack&logoColor=white)](https://tech7fox.github.io/sip-hass-docs/) 22 | [![HACS Repository](https://img.shields.io/badge/HACS_Repository-%2341BDF5.svg?style=for-the-badge&logo=homeassistant&logoColor=white)](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 | popup 41 | popup_config 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 | call_card 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 | contacts_card 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 | Star History Chart 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 | 145 | CALL 156 |
157 | `; 158 | } else { 159 | return html` 160 |
161 | 166 |
${extension.name}
167 | 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 | 310 | 314 | Default Output 315 | 316 | 317 | ${this.outputDevices.map( 318 | (device) => html` 319 | 324 | ${device.label} 325 | 326 | 327 | `, 328 | )} 329 | 330 | 331 | 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 | 407 | 408 | 409 | 416 | 419 | 420 | 421 | 427 | Documentation 428 | 429 | 430 | 431 | 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 | 477 | 478 | 479 |
480 | ${this.config.buttons.map((button) => { 481 | if (button.type === ButtonType.SERVICE_CALL) { 482 | return html` 483 | 491 | 492 | 493 | `; 494 | } else if (button.type === ButtonType.DTMF) { 495 | return html` 496 | 503 | 504 | 505 | `; 506 | } 507 | })} 508 |
509 |
510 | 520 | 527 | 528 | 539 | 546 | 547 |
548 | 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 | --------------------------------------------------------------------------------