├── .gitattributes ├── .github ├── FUNDING.yml └── workflows │ ├── hassfest.yml │ └── tests.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── README.md ├── custom_components ├── __init__.py └── asterisk │ ├── __init__.py │ ├── base.py │ ├── binary_sensor.py │ ├── config_flow.py │ ├── const.py │ ├── diagnostics.py │ ├── manifest.json │ ├── sensor.py │ ├── services.yaml │ └── translations │ └── en.json ├── dev-requirements.txt ├── images ├── icon.png ├── icon@2x.png ├── logo.png └── logo@2x.png ├── setup.cfg └── tests ├── __init__.py ├── conftest.py ├── mock_ami_client.py ├── test_config_flow.py └── test_sensors.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [TECH7Fox] 2 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | validate: 9 | runs-on: "ubuntu-latest" 10 | steps: 11 | - uses: "actions/checkout@v2" 12 | - uses: "home-assistant/actions/hassfest@master" 13 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ["3.10.11", "3.11.3"] 13 | fail-fast: false 14 | 15 | steps: 16 | - uses: actions/checkout@v1 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install -r dev-requirements.txt 25 | - name: Run pytest 26 | run: | 27 | pytest 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | vendor 3 | .coverage 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: check-json 6 | - id: check-toml 7 | - id: check-yaml 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | - repo: https://github.com/pycqa/isort 11 | rev: 5.12.0 12 | hooks: 13 | - id: isort 14 | name: isort (python) 15 | - repo: https://github.com/psf/black 16 | rev: 22.12.0 17 | hooks: 18 | - id: black 19 | - repo: https://github.com/pycqa/flake8 20 | rev: 5.0.4 21 | hooks: 22 | - id: flake8 23 | additional_dependencies: 24 | - flake8-bugbear 25 | - flake8-comprehensions 26 | - flake8-simplify 27 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.10.6 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Asterisk-integration 2 | **Asterisk integration for Home Assistant** 3 | 4 | This integration finds and adds all SIP and PJSIP devices to your Home Assistant. 5 | 6 | ## Roadmap 7 | Things that are coming soon: 8 | * Device triggers and conditions 9 | 10 | I am open for suggestions! 11 | 12 | ## Asterisk add-on 13 | 14 | The [Asterisk add-on](https://github.com/TECH7Fox/asterisk-hass-addons) fully supports this integration. 15 | 16 | ## Requirements 17 | For this to work you will need the following: 18 | * A sip/pbx server. (I recommend the Asterisk add-on to get started) 19 | * HACS on your Home Assistant. 20 | * Create an AMI manager. Make sure your IP is allowed. (Add-on comes preconfigured for this) 21 | 22 | ## Installation 23 | Download using **HACS** 24 | 1. Go to HACS 25 | 2. Click on the 3 points in the upper right corner and click on `Custom repositories` 26 | 3. Paste https://github.com/TECH7Fox/Asterisk-integration/ into `Add custom repository URL` and by category choose Integration 27 | 4. Click on add and check if the repository is there. 28 | 5. You should now see Asterisk integration. Click `INSTALL` 29 | 6. Restart Home Assistant. 30 | 7. Go to integrations and find Asterisk. 31 | 8. Fill in the fields and click add. If succesful, you should now see your PJSIP/SIP devices. 32 | 33 | 34 | ## Troubleshooting 35 | Most problems is because your PBX server is not configured correct. 36 | 37 | * For DTMF signalling to work, in FreePBX, change the dmtf signaling. For intercom purposes, "SIP-INFO DTMF-Relay" is needed. 38 | 39 | If you are still having problems you can make an issue, ask on the [discord server](https://discordapp.com/invite/qxnDtHbwuD) or send me a email. 40 | 41 | ## Contact 42 | **jordy.kuhne@gmail.com** 43 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TECH7Fox/asterisk-hass-integration/34923a6a8a21c4e7f7b624fba267b3ee3e0e549b/custom_components/__init__.py -------------------------------------------------------------------------------- /custom_components/asterisk/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from asterisk.ami import AMIClient, AutoReconnect, Event, SimpleAction 5 | from homeassistant.config_entries import ConfigEntry 6 | from homeassistant.const import ( 7 | CONF_DEVICES, 8 | CONF_HOST, 9 | CONF_PASSWORD, 10 | CONF_PORT, 11 | CONF_USERNAME, 12 | ) 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady 15 | 16 | from .const import AUTO_RECONNECT, CLIENT, DOMAIN, PLATFORMS, SIP_LOADED, PJSIP_LOADED 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 22 | """Setup up a config entry.""" 23 | 24 | def create_PJSIP_device(event: Event, **kwargs): 25 | _LOGGER.debug("Creating PJSIP device: %s", event) 26 | device = { 27 | "extension": event["ObjectName"], 28 | "tech": "PJSIP", 29 | "status": event["DeviceState"], 30 | } 31 | hass.data[DOMAIN][entry.entry_id][CONF_DEVICES].append(device) 32 | 33 | def create_SIP_device(event: Event, **kwargs): 34 | _LOGGER.debug("Creating SIP device: %s", event) 35 | device = { 36 | "extension": event["ObjectName"], 37 | "tech": "SIP", 38 | "status": event["Status"], 39 | } 40 | hass.data[DOMAIN][entry.entry_id][CONF_DEVICES].append(device) 41 | 42 | def devices_complete(event: Event, **kwargs): 43 | sip_loaded = hass.data[DOMAIN][entry.entry_id][SIP_LOADED] 44 | pjsip_loaded = hass.data[DOMAIN][entry.entry_id][PJSIP_LOADED] 45 | if event.name == "PeerlistComplete": 46 | _LOGGER.debug("SIP loaded.") 47 | sip_loaded = True 48 | hass.data[DOMAIN][entry.entry_id][SIP_LOADED] = True 49 | elif event.name == "EndpointListComplete": 50 | _LOGGER.debug("PJSIP loaded.") 51 | pjsip_loaded = True 52 | hass.data[DOMAIN][entry.entry_id][PJSIP_LOADED] = True 53 | 54 | if sip_loaded and pjsip_loaded: 55 | _LOGGER.debug("Both SIP and PJSIP loaded. Loading platforms.") 56 | asyncio.run_coroutine_threadsafe( 57 | hass.config_entries.async_forward_entry_setups(entry, PLATFORMS), 58 | hass.loop 59 | ) 60 | 61 | async def send_action_service(call) -> None: 62 | "Send action service." 63 | 64 | action = SimpleAction(call.data.get("action"), **call.data.get("parameters")) 65 | _LOGGER.debug("Sending action: %s", action) 66 | 67 | try: 68 | f = hass.data[DOMAIN][entry.entry_id][CLIENT].send_action(action) 69 | _LOGGER.debug("Action response: %s", f.response) 70 | except BrokenPipeError: 71 | _LOGGER.warning("Failed to send action: AMI Disconnected") 72 | 73 | client = AMIClient( 74 | address=entry.data[CONF_HOST], 75 | port=entry.data[CONF_PORT], 76 | timeout=10, 77 | ) 78 | auto_reconnect = AutoReconnect(client, delay=3) 79 | try: 80 | future = client.login( 81 | username=entry.data[CONF_USERNAME], 82 | secret=entry.data[CONF_PASSWORD], 83 | ) 84 | _LOGGER.debug("Login response: %s", future.response) 85 | if future.response.is_error(): 86 | raise ConfigEntryAuthFailed(future.response.keys["Message"]) 87 | except ConfigEntryAuthFailed: 88 | raise 89 | except Exception as e: 90 | raise ConfigEntryNotReady(e) 91 | 92 | hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { 93 | CLIENT: client, 94 | AUTO_RECONNECT: auto_reconnect, 95 | CONF_DEVICES: [], 96 | SIP_LOADED: False, 97 | PJSIP_LOADED: False, 98 | } 99 | hass.services.async_register(DOMAIN, "send_action", send_action_service) 100 | 101 | client.add_event_listener(create_SIP_device, white_list=["PeerEntry"]) 102 | client.add_event_listener(devices_complete, white_list=["PeerlistComplete"]) 103 | f = client.send_action(SimpleAction("SIPpeers")) 104 | if f.response.is_error(): 105 | _LOGGER.debug("SIP module not loaded. Skipping SIP devices.") 106 | hass.data[DOMAIN][entry.entry_id][SIP_LOADED] = True 107 | 108 | client.add_event_listener(create_PJSIP_device, white_list=["EndpointList"]) 109 | client.add_event_listener(devices_complete, white_list=["EndpointListComplete"]) 110 | f = client.send_action(SimpleAction("PJSIPShowEndpoints")) 111 | if f.response.is_error(): 112 | _LOGGER.debug("PJSIP module not loaded. Skipping PJSIP devices.") 113 | hass.data[DOMAIN][entry.entry_id][PJSIP_LOADED] = True 114 | 115 | return True 116 | 117 | 118 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 119 | """Unload a config entry.""" 120 | data = hass.data[DOMAIN][entry.entry_id] 121 | client = data[CLIENT] 122 | 123 | client.logoff() 124 | client.disconnect() 125 | 126 | unloaded = all( 127 | await asyncio.gather( 128 | *[ 129 | hass.config_entries.async_forward_entry_unload(entry, component) 130 | for component in PLATFORMS 131 | ] 132 | ) 133 | ) 134 | 135 | if unloaded: 136 | hass.data[DOMAIN].pop(entry.entry_id) 137 | 138 | return unloaded 139 | 140 | 141 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 142 | """Reload a config entry.""" 143 | await async_unload_entry(hass, entry) 144 | return await async_setup_entry(hass, entry) 145 | -------------------------------------------------------------------------------- /custom_components/asterisk/base.py: -------------------------------------------------------------------------------- 1 | from asterisk.ami import AMIClient 2 | 3 | from .const import CLIENT, DOMAIN 4 | 5 | 6 | class AsteriskDeviceEntity: 7 | """Base entity for Asterisk devices.""" 8 | 9 | def __init__(self, hass, entry, device): 10 | """Initialize the sensor.""" 11 | self._device = device 12 | self._entry = entry 13 | self._unique_id_prefix = f"{entry.entry_id}_{device['extension']}" 14 | self._ami_client: AMIClient = hass.data[DOMAIN][entry.entry_id][CLIENT] 15 | self._name: str 16 | self._unique_id: str 17 | 18 | @property 19 | def device_info(self): 20 | """Return the device info.""" 21 | return { 22 | "identifiers": {(DOMAIN, self._unique_id_prefix)}, 23 | "name": f"{self._device['tech']}/{self._device['extension']}", 24 | "manufacturer": "Asterisk", 25 | "model": self._device["tech"], 26 | "via_device": (DOMAIN, f"{self._entry.entry_id}_server"), 27 | } 28 | 29 | @property 30 | def name(self) -> str: 31 | """Return the name of the sensor.""" 32 | return self._name 33 | 34 | @property 35 | def unique_id(self) -> str: 36 | """Return a unique ID.""" 37 | return self._unique_id 38 | -------------------------------------------------------------------------------- /custom_components/asterisk/binary_sensor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from asterisk.ami import AMIClient, AutoReconnect, Event, SimpleAction 4 | from homeassistant.components.binary_sensor import ( 5 | BinarySensorDeviceClass, 6 | BinarySensorEntity, 7 | ) 8 | from homeassistant.const import CONF_DEVICES 9 | 10 | from .base import AsteriskDeviceEntity 11 | from .const import AUTO_RECONNECT, CLIENT, DOMAIN 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | async def async_setup_entry(hass, entry, async_add_entities): 17 | """Set up the Asterisk sensor platform.""" 18 | devices = hass.data[DOMAIN][entry.entry_id][CONF_DEVICES] 19 | 20 | entities = [AMIConnected(hass, entry)] 21 | 22 | for device in devices: 23 | entities.append(RegisteredSensor(hass, entry, device)) 24 | 25 | async_add_entities(entities, False) 26 | 27 | 28 | class RegisteredSensor(AsteriskDeviceEntity, BinarySensorEntity): 29 | """Binary entity for the registered state.""" 30 | 31 | def __init__(self, hass, entry, device): 32 | """Initialize the sensor.""" 33 | super().__init__(hass, entry, device) 34 | self._unique_id = f"{self._unique_id_prefix}_registered" 35 | self._name = f"{device['extension']} Registered" 36 | self._state = ( 37 | device["status"] != "Unavailable" and device["status"] != "Unknown" 38 | ) 39 | self._ami_client.add_event_listener( 40 | self.handle_state_change, 41 | white_list=["DeviceStateChange"], 42 | Device=f"{device['tech']}/{device['extension']}", 43 | ) 44 | 45 | def handle_state_change(self, event: Event, **kwargs): 46 | """Handle an device state change event.""" 47 | state = event["State"] 48 | self._state = state != "UNAVAILABLE" and state != "UNKNOWN" 49 | self.schedule_update_ha_state() 50 | 51 | @property 52 | def is_on(self) -> bool: 53 | """Return registered state.""" 54 | return self._state 55 | 56 | @property 57 | def icon(self) -> str: 58 | """Return the icon of the sensor.""" 59 | return "mdi:phone-check" if self._state else "mdi:phone-off" 60 | 61 | 62 | class AMIConnected(BinarySensorEntity): 63 | """Binary entity for the AMI connection state.""" 64 | 65 | def __init__(self, hass, entry): 66 | """Initialize the sensor.""" 67 | self._entry = entry 68 | self._unique_id = f"{self._entry.entry_id}_connected" 69 | self._name = "AMI Connected" 70 | self._state: bool = True 71 | self._ami_client: AMIClient = hass.data[DOMAIN][entry.entry_id][CLIENT] 72 | self._auto_reconnect: AutoReconnect = hass.data[DOMAIN][entry.entry_id][ 73 | AUTO_RECONNECT 74 | ] 75 | self._auto_reconnect.on_disconnect = self.on_disconnect 76 | self._auto_reconnect.on_reconnect = self.on_reconnect 77 | f = self._ami_client.send_action(SimpleAction("CoreSettings")) 78 | self._asterisk_version = f.response.keys["AsteriskVersion"] 79 | 80 | def on_disconnect(self, client, response): 81 | _LOGGER.debug(f"Disconnected from AMI: {response}") 82 | client.disconnect() 83 | self._state = False 84 | self.schedule_update_ha_state() 85 | 86 | def on_reconnect(self, client, response): 87 | _LOGGER.debug(f"Reconnected to AMI: {response}") 88 | self._state = True 89 | self.schedule_update_ha_state() 90 | 91 | @property 92 | def device_info(self): 93 | """Return the device info.""" 94 | return { 95 | "identifiers": {(DOMAIN, f"{self._entry.entry_id}_server")}, 96 | "name": "Asterisk Server", 97 | "manufacturer": "Asterisk", 98 | "model": "PBX", 99 | "configuration_url": f"http://{self._entry.data['host']}", 100 | "sw_version": self._asterisk_version, 101 | } 102 | 103 | @property 104 | def name(self) -> str: 105 | """Return the name of the sensor.""" 106 | return self._name 107 | 108 | @property 109 | def unique_id(self) -> str: 110 | """Return a unique ID.""" 111 | return self._unique_id 112 | 113 | @property 114 | def is_on(self) -> bool: 115 | """Return connected state.""" 116 | return self._state 117 | 118 | @property 119 | def device_class(self) -> str: 120 | """Return the device class of the sensor.""" 121 | return BinarySensorDeviceClass.CONNECTIVITY 122 | -------------------------------------------------------------------------------- /custom_components/asterisk/config_flow.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from asterisk.ami import AMIClient 4 | from homeassistant import config_entries 5 | from homeassistant.config_entries import ConfigEntry 6 | from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME 7 | from homeassistant.data_entry_flow import AbortFlow, FlowResult 8 | import voluptuous as vol 9 | 10 | from .const import DOMAIN 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | class AsteriskConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 16 | """Handle a config flow for Asterisk.""" 17 | 18 | VERSION = 1 19 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL 20 | reauth_entry: ConfigEntry | None = None 21 | 22 | async def _show_form(self, errors: dict[str, str] | None = None) -> FlowResult: 23 | """Show the form to the user.""" 24 | return self.async_show_form( 25 | step_id="user", 26 | data_schema=vol.Schema( 27 | { 28 | vol.Required(CONF_HOST): str, 29 | vol.Required(CONF_PORT, default=5038): int, 30 | vol.Required(CONF_USERNAME, default="admin"): str, 31 | vol.Optional(CONF_PASSWORD, default=""): str, 32 | } 33 | ), 34 | errors=errors or {}, 35 | ) 36 | 37 | async def _test_ami(self, host, port, username, password): 38 | """Tests the AMI connection.""" 39 | errors = {} 40 | client = AMIClient(address=host, port=port) 41 | try: 42 | future = client.login(username=username, secret=password) 43 | if future.response.is_error(): 44 | _LOGGER.debug( 45 | "Failed to connect to AMI: %s", future.response.keys["Message"] 46 | ) 47 | errors["base"] = "invalid_auth" 48 | 49 | client.logoff() 50 | client.disconnect() 51 | except Exception as e: 52 | _LOGGER.debug("Failed to connect to AMI: %s", e) 53 | errors["base"] = "cannot_connect" 54 | 55 | return errors 56 | 57 | async def async_step_user(self, user_input=None): 58 | """Handle the initial step.""" 59 | if user_input is None: 60 | return await self._show_form() 61 | 62 | host = user_input[CONF_HOST] 63 | port = user_input[CONF_PORT] 64 | username = user_input[CONF_USERNAME] 65 | password = user_input[CONF_PASSWORD] 66 | 67 | errors = {} 68 | 69 | await self.async_set_unique_id(f"{host}:{port}") 70 | 71 | if self.reauth_entry is None: 72 | try: 73 | self._abort_if_unique_id_configured() 74 | except AbortFlow: 75 | errors["base"] = "already_configured" 76 | return await self._show_form(errors) 77 | 78 | result = await self._test_ami(host, port, username, password) 79 | if result: 80 | return await self._show_form(result) 81 | 82 | return self.async_create_entry(title=f"AMI {host}:{port}", data=user_input) 83 | 84 | async def async_step_import(self, import_config): 85 | """Import a config entry from configuration.yaml.""" 86 | return await self.async_step_user(import_config) 87 | 88 | reauth_entry: ConfigEntry | None = None 89 | 90 | async def async_step_reauth(self, user_input=None): 91 | """Perform reauth upon an API authentication error.""" 92 | self.reauth_entry = self.hass.config_entries.async_get_entry( 93 | self.context["entry_id"] 94 | ) 95 | return await self.async_step_reauth_confirm() 96 | 97 | async def async_step_reauth_confirm(self, user_input=None): 98 | """Dialog that informs the user that reauth is required.""" 99 | if user_input is None: 100 | return self.async_show_form( 101 | step_id="reauth_confirm", 102 | data_schema=vol.Schema( 103 | { 104 | vol.Required(CONF_USERNAME, default="admin"): str, 105 | vol.Optional(CONF_PASSWORD, default=""): str, 106 | } 107 | ), 108 | ) 109 | 110 | user_input[CONF_HOST] = self.reauth_entry.data[CONF_HOST] 111 | user_input[CONF_PORT] = self.reauth_entry.data[CONF_PORT] 112 | 113 | result = await self._test_ami( 114 | user_input[CONF_HOST], 115 | user_input[CONF_PORT], 116 | user_input[CONF_USERNAME], 117 | user_input[CONF_PASSWORD], 118 | ) 119 | if result: 120 | return self.async_show_form( 121 | step_id="reauth_confirm", 122 | data_schema=vol.Schema( 123 | { 124 | vol.Required(CONF_USERNAME, default="admin"): str, 125 | vol.Optional(CONF_PASSWORD, default=""): str, 126 | } 127 | ), 128 | errors=result, 129 | ) 130 | 131 | self.hass.config_entries.async_update_entry(self.reauth_entry, data=user_input) 132 | await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) 133 | return self.async_abort(reason="reauth_successful") 134 | -------------------------------------------------------------------------------- /custom_components/asterisk/const.py: -------------------------------------------------------------------------------- 1 | DOMAIN = "asterisk" 2 | CLIENT = "client" 3 | AUTO_RECONNECT = "auto_reconnect" 4 | PLATFORMS = [ 5 | "binary_sensor", 6 | "sensor", 7 | ] 8 | STATES = { 9 | "NOT_INUSE": "Not in use", 10 | "INUSE": "In use", 11 | "BUSY": "Busy", 12 | "UNAVAILABLE": "Unavailable", 13 | "RINGING": "Ringing", 14 | "RINGINUSE": "Ringing in use", 15 | "ONHOLD": "On hold", 16 | "UNKNOWN": "Unknown", 17 | } 18 | STATE_ICONS = { 19 | "Not in use": "mdi:phone-hangup", 20 | "In use": "mdi:phone-in-talk", 21 | "Busy": "mdi:phone-in-talk", 22 | "Unavailable": "mdi:phone-off", 23 | "Ringing": "mdi:phone-ring", 24 | "Ringing in use": "mdi:phone-ring", 25 | "On hold": "mdi:phone-paused", 26 | "Unknown": "mdi:phone-off", 27 | } 28 | SIP_LOADED = "sip_loaded" 29 | PJSIP_LOADED = "pjsip_loaded" -------------------------------------------------------------------------------- /custom_components/asterisk/diagnostics.py: -------------------------------------------------------------------------------- 1 | from asterisk.ami import AMIClient, AutoReconnect 2 | from homeassistant.config_entries import ConfigEntry 3 | from homeassistant.core import HomeAssistant 4 | 5 | from .const import CLIENT, DOMAIN, AUTO_RECONNECT 6 | 7 | 8 | async def async_get_config_entry_diagnostics( 9 | hass: HomeAssistant, config_entry: ConfigEntry 10 | ) -> dict[str, any]: 11 | """Return diagnostics for a config entry.""" 12 | client: AMIClient = hass.data[DOMAIN][config_entry.entry_id][CLIENT] 13 | auto_reconnect: AutoReconnect = hass.data[DOMAIN][config_entry.entry_id][AUTO_RECONNECT] 14 | return { 15 | "AMI Client": { 16 | "Address": client._address, 17 | "Port": client._port, 18 | "AMI version": client._ami_version, 19 | }, 20 | "Auto Reconnect": { 21 | "Alive": auto_reconnect.is_alive(), 22 | "Delay": auto_reconnect.delay, 23 | "Name": auto_reconnect.name, 24 | "Deamon": auto_reconnect.daemon, 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /custom_components/asterisk/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "asterisk", 3 | "name": "Asterisk", 4 | "codeowners": [ 5 | "@TECH7Fox" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [], 9 | "documentation": "https://github.com/TECH7Fox/Asterisk-integration", 10 | "homekit": {}, 11 | "iot_class": "local_polling", 12 | "issue_tracker": "https://github.com/TECH7Fox/Asterisk-integration/issues", 13 | "loggers": ["custom_components.asterisk"], 14 | "requirements": ["asterisk-ami==0.1.6"], 15 | "ssdp": [], 16 | "version": "1.0.4", 17 | "zeroconf": [] 18 | } 19 | -------------------------------------------------------------------------------- /custom_components/asterisk/sensor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from asterisk.ami import Event 4 | from homeassistant.components.sensor import SensorDeviceClass, SensorEntity 5 | from homeassistant.const import CONF_DEVICES 6 | from homeassistant.util.dt import now 7 | 8 | from .base import AsteriskDeviceEntity 9 | from .const import DOMAIN, STATE_ICONS, STATES 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | async def async_setup_entry(hass, entry, async_add_entities): 15 | """Set up the Asterisk sensor platform.""" 16 | devices = hass.data[DOMAIN][entry.entry_id][CONF_DEVICES] 17 | 18 | entities = [] 19 | 20 | for device in devices: 21 | entities.append(DeviceStateSensor(hass, entry, device)) 22 | entities.append(ConnectedLineSensor(hass, entry, device)) 23 | entities.append(DTMFSentSensor(hass, entry, device)) 24 | entities.append(DTMFReceivedSensor(hass, entry, device)) 25 | 26 | async_add_entities(entities, False) 27 | 28 | 29 | class DeviceStateSensor(AsteriskDeviceEntity, SensorEntity): 30 | """Sensor entity for the device state.""" 31 | 32 | def __init__(self, hass, entry, device): 33 | """Initialize the sensor.""" 34 | super().__init__(hass, entry, device) 35 | self._unique_id = f"{self._unique_id_prefix}_state" 36 | self._name = f"{device['extension']} State" 37 | self._state = device["status"] 38 | self._ami_client.add_event_listener( 39 | self.handle_event, 40 | white_list=["DeviceStateChange"], 41 | Device=f"{device['tech']}/{device['extension']}", 42 | ) 43 | 44 | def handle_event(self, event: Event, **kwargs): 45 | """Handle an endpoint update event.""" 46 | state = event["State"] 47 | self._state = STATES.get(state, STATES["UNKNOWN"]) 48 | self.schedule_update_ha_state() 49 | 50 | @property 51 | def state(self) -> str: 52 | """Return registered state.""" 53 | return self._state 54 | 55 | @property 56 | def icon(self) -> str: 57 | """Return the icon of the sensor.""" 58 | return STATE_ICONS.get(self._state, STATE_ICONS["Unknown"]) 59 | 60 | 61 | class ConnectedLineSensor(AsteriskDeviceEntity, SensorEntity): 62 | """Sensor entity for the connected line number.""" 63 | 64 | def __init__(self, hass, entry, device): 65 | """Initialize the sensor.""" 66 | super().__init__(hass, entry, device) 67 | self._unique_id = f"{self._unique_id_prefix}_connected_line" 68 | self._name = f"{device['extension']} Connected Line" 69 | self._state = "None" 70 | self._extra_attributes = {} 71 | self._ami_client.add_event_listener( 72 | self.handle_new_connected_line, 73 | white_list=["NewConnectedLine"], 74 | CallerIDNum=device["extension"], 75 | ) 76 | self._ami_client.add_event_listener( 77 | self.handle_new_connected_line, 78 | white_list=["NewConnectedLine"], 79 | ConnectedLineNum=device["extension"], 80 | ) 81 | self._ami_client.add_event_listener( 82 | self.handle_hangup, 83 | white_list=["Hangup"], 84 | CallerIDNum=device["extension"], 85 | ) 86 | self._ami_client.add_event_listener( 87 | self.handle_new_channel, 88 | white_list=["Newchannel"], 89 | CallerIDNum=device["extension"], 90 | ) 91 | 92 | def handle_new_connected_line(self, event: Event, **kwargs): 93 | """Handle an NewConnectedLine event.""" 94 | if event["ConnectedLineNum"] != self._device["extension"]: 95 | self._state = event["ConnectedLineNum"] 96 | else: 97 | self._state = event["CallerIDNum"] 98 | self._extra_attributes = { 99 | "Channel": event["Channel"], 100 | "ChannelState": event["ChannelState"], 101 | "ChannelStateDesc": event["ChannelStateDesc"], 102 | "CallerIDNum": event["CallerIDNum"], 103 | "CallerIDName": event["CallerIDName"], 104 | "ConnectedLineNum": event["ConnectedLineNum"], 105 | "ConnectedLineName": event["ConnectedLineName"], 106 | "Exten": event["Exten"], 107 | "Context": event["Context"], 108 | } 109 | self.schedule_update_ha_state() 110 | 111 | def handle_hangup(self, event: Event, **kwargs): 112 | """Handle an Hangup event.""" 113 | if event["Cause"] != "26": 114 | self._state = "None" 115 | self._extra_attributes = { 116 | "Channel": event["Channel"], 117 | "ChannelState": event["ChannelState"], 118 | "ChannelStateDesc": event["ChannelStateDesc"], 119 | "CallerIDNum": event["CallerIDNum"], 120 | "CallerIDName": event["CallerIDName"], 121 | "ConnectedLineNum": event["ConnectedLineNum"], 122 | "ConnectedLineName": event["ConnectedLineName"], 123 | "Exten": event["Exten"], 124 | "Context": event["Context"], 125 | "Cause": event["Cause"], 126 | "Cause-txt": event["Cause-txt"], 127 | } 128 | self.schedule_update_ha_state() 129 | 130 | def handle_new_channel(self, event: Event, **kwargs): 131 | """Handle an NewChannel event.""" 132 | self._state = "None" 133 | self._extra_attributes = { 134 | "Channel": event["Channel"], 135 | "ChannelState": event["ChannelState"], 136 | "ChannelStateDesc": event["ChannelStateDesc"], 137 | "CallerIDNum": event["CallerIDNum"], 138 | "CallerIDName": event["CallerIDName"], 139 | "ConnectedLineNum": event["ConnectedLineNum"], 140 | "ConnectedLineName": event["ConnectedLineName"], 141 | "Exten": event["Exten"], 142 | "Context": event["Context"], 143 | } 144 | self.schedule_update_ha_state() 145 | 146 | @property 147 | def state(self) -> str: 148 | """Return registered state.""" 149 | return self._state 150 | 151 | @property 152 | def extra_state_attributes(self): 153 | """Return the state attributes.""" 154 | return self._extra_attributes 155 | 156 | @property 157 | def icon(self) -> str: 158 | """Return the icon of the sensor.""" 159 | return ( 160 | "mdi:phone-remove" 161 | if self._state == "None" 162 | else "mdi:phone-incoming-outgoing" 163 | ) 164 | 165 | 166 | class DTMFSentSensor(AsteriskDeviceEntity, SensorEntity): 167 | """Sensor entity with the latest DTMF sent.""" 168 | 169 | def __init__(self, hass, entry, device): 170 | """Initialize the sensor.""" 171 | super().__init__(hass, entry, device) 172 | self._unique_id = f"{self._unique_id_prefix}_dtmf_sent" 173 | self._name = f"{device['extension']} DTMF Sent" 174 | self._state = None 175 | self._extra_attributes = {} 176 | self._ami_client.add_event_listener( 177 | self.handle_dtmf, 178 | white_list=["DTMFBegin"], 179 | ConnectedLineNum=device["extension"], 180 | Direction="Sent", 181 | ) 182 | 183 | def handle_dtmf(self, event: Event, **kwargs): 184 | """Handle an DTMF event.""" 185 | self._state = now() 186 | self._extra_attributes = { 187 | "Channel": event["Channel"], 188 | "Digit": event["Digit"], 189 | "CallerIDNum": event["CallerIDNum"], 190 | "CallerIDName": event["CallerIDName"], 191 | "ConnectedLineNum": event["ConnectedLineNum"], 192 | "ConnectedLineName": event["ConnectedLineName"], 193 | "Context": event["Context"], 194 | } 195 | self.schedule_update_ha_state() 196 | 197 | @property 198 | def state(self) -> str: 199 | """Return registered state.""" 200 | return self._state 201 | 202 | @property 203 | def device_class(self) -> SensorDeviceClass: 204 | return SensorDeviceClass.TIMESTAMP 205 | 206 | @property 207 | def extra_state_attributes(self): 208 | """Return the state attributes.""" 209 | return self._extra_attributes 210 | 211 | 212 | class DTMFReceivedSensor(AsteriskDeviceEntity, SensorEntity): 213 | """Sensor entity with the latest DTMF received.""" 214 | 215 | def __init__(self, hass, entry, device): 216 | """Initialize the sensor.""" 217 | super().__init__(hass, entry, device) 218 | self._unique_id = f"{self._unique_id_prefix}_dtmf_received" 219 | self._name = f"{device['extension']} DTMF Received" 220 | self._state = None 221 | self._extra_attributes = {} 222 | self._ami_client.add_event_listener( 223 | self.handle_dtmf, 224 | white_list=["DTMFBegin"], 225 | ConnectedLineNum=device["extension"], 226 | Direction="Received", 227 | ) 228 | 229 | def handle_dtmf(self, event: Event, **kwargs): 230 | """Handle an DTMF event.""" 231 | self._state = now() 232 | self._extra_attributes = { 233 | "Channel": event["Channel"], 234 | "Digit": event["Digit"], 235 | "ConnectedLineNum": event["ConnectedLineNum"], 236 | "ConnectedLineName": event["ConnectedLineName"], 237 | "Context": event["Context"], 238 | } 239 | self.schedule_update_ha_state() 240 | 241 | @property 242 | def state(self) -> str: 243 | """Return registered state.""" 244 | return self._state 245 | 246 | @property 247 | def device_class(self) -> SensorDeviceClass: 248 | return SensorDeviceClass.TIMESTAMP 249 | 250 | @property 251 | def extra_state_attributes(self): 252 | """Return the state attributes.""" 253 | return self._extra_attributes 254 | -------------------------------------------------------------------------------- /custom_components/asterisk/services.yaml: -------------------------------------------------------------------------------- 1 | send_action: 2 | name: Send Action 3 | description: Send an action to the Asterisk Manager Interface. 4 | fields: 5 | action: 6 | name: Action 7 | description: Action to send. 8 | required: true 9 | example: "Ping" 10 | selector: 11 | text: 12 | parameters: 13 | name: Parameters 14 | description: Parameters to send with the action. 15 | required: true 16 | example: "key: val" 17 | selector: 18 | object: 19 | -------------------------------------------------------------------------------- /custom_components/asterisk/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "There is already a configuration for this host and port.", 5 | "reauth_successful": "Succesfully authenticated with the AMI." 6 | }, 7 | "error": { 8 | "cannot_connect": "Cannot connect to the AMI.", 9 | "invalid_auth": "Unauthorized. Please check your username and password, and make sure your Home Assistant address is allowed in the manager.conf.", 10 | "unknown": "Unknown error. Please check your configuration and try again. Check the debug logs for more information.", 11 | "already_configured": "There is already a configuration for this host and port." 12 | }, 13 | "step": { 14 | "user": { 15 | "title": "Connect to Asterisk AMI", 16 | "description": "Please enter the details for your Asterisk AMI server.", 17 | "data": { 18 | "host": "host", 19 | "port": "port", 20 | "username": "username", 21 | "password": "password" 22 | } 23 | }, 24 | "reauth_confirm": { 25 | "title": "Reauthenticate", 26 | "description": "Authentication failed. Please reauthenticate.", 27 | "data": { 28 | "username": "username", 29 | "password": "password" 30 | } 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | asterisk-ami==0.1.7 2 | pytest-homeassistant-custom-component 3 | pre-commit 4 | -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TECH7Fox/asterisk-hass-integration/34923a6a8a21c4e7f7b624fba267b3ee3e0e549b/images/icon.png -------------------------------------------------------------------------------- /images/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TECH7Fox/asterisk-hass-integration/34923a6a8a21c4e7f7b624fba267b3ee3e0e549b/images/icon@2x.png -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TECH7Fox/asterisk-hass-integration/34923a6a8a21c4e7f7b624fba267b3ee3e0e549b/images/logo.png -------------------------------------------------------------------------------- /images/logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TECH7Fox/asterisk-hass-integration/34923a6a8a21c4e7f7b624fba267b3ee3e0e549b/images/logo@2x.png -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | source = 3 | custom_components 4 | 5 | [coverage:report] 6 | exclude_lines = 7 | pragma: no cover 8 | raise NotImplemented() 9 | if __name__ == '__main__': 10 | main() 11 | show_missing = true 12 | 13 | [tool:pytest] 14 | testpaths = tests 15 | norecursedirs = .git 16 | addopts = 17 | --cov=custom_components 18 | asyncio_mode = auto 19 | 20 | [flake8] 21 | # https://github.com/ambv/black#line-length 22 | max-line-length = 88 23 | # E501: line too long 24 | # W503: Line break occurred before a binary operator 25 | # E203: Whitespace before ':' 26 | # D202 No blank lines allowed after function docstring 27 | # W504 line break after binary operator 28 | ignore = 29 | E501, 30 | W503, 31 | E203, 32 | D202, 33 | W504 34 | 35 | [isort] 36 | # https://github.com/timothycrosley/isort 37 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 38 | # splits long import on multiple lines indented by 4 spaces 39 | multi_line_output = 3 40 | include_trailing_comma=True 41 | force_grid_wrap=0 42 | use_parentheses=True 43 | line_length=88 44 | indent = " " 45 | # by default isort don't check module indexes 46 | not_skip = __init__.py 47 | # will group `import x` and `from x import` of the same module. 48 | force_sort_within_sections = true 49 | sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 50 | default_section = THIRDPARTY 51 | known_first_party = custom_components,tests 52 | forced_separate = tests 53 | combine_as_imports = true 54 | 55 | [mypy] 56 | python_version = 3.7 57 | ignore_errors = true 58 | follow_imports = silent 59 | ignore_missing_imports = true 60 | warn_incomplete_stub = true 61 | warn_redundant_casts = true 62 | warn_unused_configs = true 63 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TECH7Fox/asterisk-hass-integration/34923a6a8a21c4e7f7b624fba267b3ee3e0e549b/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """pytest fixtures.""" 2 | import pytest 3 | from pytest_homeassistant_custom_component.common import MockConfigEntry 4 | 5 | from custom_components.asterisk.const import DOMAIN 6 | 7 | 8 | @pytest.fixture(autouse=True) 9 | def auto_enable_custom_integrations(enable_custom_integrations): 10 | """Enable custom integrations defined in the test dir.""" 11 | yield 12 | 13 | 14 | @pytest.fixture 15 | def config_entry(): 16 | """Fixture representing a config entry.""" 17 | return MockConfigEntry( 18 | domain=DOMAIN, 19 | unique_id="test", 20 | data={ 21 | "host": "localhost", 22 | "port": 5038, 23 | "username": "admin", 24 | "password": "admin", 25 | }, 26 | ) 27 | -------------------------------------------------------------------------------- /tests/mock_ami_client.py: -------------------------------------------------------------------------------- 1 | class MockAMIClient: 2 | 3 | event_handlers = {} 4 | 5 | def add_event_listener(self, func, white_list, **kwargs): 6 | for event in white_list: 7 | if self.event_handlers.get(event) is None: 8 | self.event_handlers[event] = [] 9 | self.event_handlers[event].append(func) 10 | 11 | def trigger_event(self, event): 12 | handlers = self.event_handlers[event["Event"]] 13 | for handler in handlers: 14 | handler(event) 15 | 16 | def send_action(self, action): 17 | pass 18 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | from homeassistant import config_entries 2 | from homeassistant.core import HomeAssistant 3 | 4 | from custom_components.asterisk.const import DOMAIN 5 | 6 | 7 | async def test_config_flow(hass: HomeAssistant): 8 | """Test config flow.""" 9 | 10 | result = await hass.config_entries.flow.async_init( 11 | DOMAIN, context={"source": config_entries.SOURCE_USER} 12 | ) 13 | 14 | assert result["type"] == "form" 15 | assert result["errors"] == {} 16 | assert result["step_id"] == "user" 17 | -------------------------------------------------------------------------------- /tests/test_sensors.py: -------------------------------------------------------------------------------- 1 | from homeassistant.const import CONF_DEVICES 2 | from homeassistant.core import HomeAssistant 3 | from pytest_homeassistant_custom_component.common import MockConfigEntry 4 | 5 | from custom_components.asterisk.const import CLIENT, DOMAIN 6 | 7 | from .mock_ami_client import MockAMIClient 8 | 9 | 10 | # async def test_device_state_sensor(hass: HomeAssistant, config_entry: MockConfigEntry): 11 | # """Test DeviceStateSensor.""" 12 | # client = MockAMIClient() 13 | # hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = { 14 | # CLIENT: client, 15 | # CONF_DEVICES: [ 16 | # { 17 | # "tech": "PJSIP", 18 | # "extension": "100", 19 | # "status": "IN_USE", 20 | # } 21 | # ], 22 | # } 23 | # await hass.config_entries.async_forward_entry_setup(config_entry, "sensor") 24 | # await hass.async_block_till_done() 25 | 26 | # client.trigger_event( 27 | # { 28 | # "Event": "DeviceStateChange", 29 | # "State": "NOT_INUSE", 30 | # } 31 | # ) 32 | 33 | # await hass.async_block_till_done() 34 | # sensor = hass.states.get("sensor.100_state") 35 | # assert sensor is not None 36 | # assert sensor.state == "Not in use" 37 | 38 | 39 | # async def test_connected_line_sensor( 40 | # hass: HomeAssistant, config_entry: MockConfigEntry 41 | # ): 42 | # """Test ConnectedLineSensor.""" 43 | # client = MockAMIClient() 44 | # hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = { 45 | # CLIENT: client, 46 | # CONF_DEVICES: [ 47 | # { 48 | # "tech": "PJSIP", 49 | # "extension": "100", 50 | # "status": "IN_USE", 51 | # } 52 | # ], 53 | # } 54 | # await hass.config_entries.async_forward_entry_setup(config_entry, "sensor") 55 | # await hass.async_block_till_done() 56 | 57 | # client.trigger_event( 58 | # { 59 | # "Event": "NewConnectedLine", 60 | # "ConnectedLineNum": "100", 61 | # "ConnectedLineName": "Test", 62 | # "CallerIDNum": "101", 63 | # "CallerIDName": "Test", 64 | # "Exten": "102", 65 | # "Context": "from-internal", 66 | # "Channel": "PJSIP/100-00000000", 67 | # "ChannelState": "6", 68 | # "ChannelStateDesc": "Up", 69 | # "State": "NOT_INUSE", 70 | # } 71 | # ) 72 | 73 | # await hass.async_block_till_done() 74 | # sensor = hass.states.get("sensor.100_connected_line") 75 | # assert sensor is not None 76 | # assert sensor.state == "101" 77 | 78 | 79 | # async def test_dtmf_sent_sensor(hass: HomeAssistant, config_entry: MockConfigEntry): 80 | # """Test DTMFSentSensor.""" 81 | # client = MockAMIClient() 82 | # hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = { 83 | # CLIENT: client, 84 | # CONF_DEVICES: [ 85 | # { 86 | # "tech": "PJSIP", 87 | # "extension": "100", 88 | # "status": "IN_USE", 89 | # } 90 | # ], 91 | # } 92 | # await hass.config_entries.async_forward_entry_setup(config_entry, "sensor") 93 | # await hass.async_block_till_done() 94 | 95 | # sensor = hass.states.get("sensor.100_dtmf_sent") 96 | # assert sensor is not None 97 | # assert sensor.state == "unknown" 98 | 99 | # client.trigger_event( 100 | # { 101 | # "Event": "DTMFBegin", 102 | # "Direction": "Outbound", 103 | # "Digit": "1", 104 | # "Channel": "PJSIP/100-00000000", 105 | # "CallerIDNum": "100", 106 | # "CallerIDName": "Test", 107 | # "ConnectedLineNum": "100", 108 | # "ConnectedLineName": "Test", 109 | # "Context": "from-internal", 110 | # } 111 | # ) 112 | 113 | # await hass.async_block_till_done() 114 | # sensor = hass.states.get("sensor.100_dtmf_sent") 115 | # assert sensor is not None 116 | # assert sensor.state != "unknown" 117 | 118 | 119 | # async def test_dtmf_received_sensor(hass: HomeAssistant, config_entry: MockConfigEntry): 120 | # """Test DTMFReceivedSensor.""" 121 | # client = MockAMIClient() 122 | # hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = { 123 | # CLIENT: client, 124 | # CONF_DEVICES: [ 125 | # { 126 | # "tech": "PJSIP", 127 | # "extension": "100", 128 | # "status": "IN_USE", 129 | # } 130 | # ], 131 | # } 132 | # await hass.config_entries.async_forward_entry_setup(config_entry, "sensor") 133 | # await hass.async_block_till_done() 134 | 135 | # sensor = hass.states.get("sensor.100_dtmf_received") 136 | # assert sensor is not None 137 | # assert sensor.state == "unknown" 138 | 139 | # client.trigger_event( 140 | # { 141 | # "Event": "DTMFBegin", 142 | # "Direction": "Inbound", 143 | # "Digit": "1", 144 | # "Channel": "PJSIP/100-00000000", 145 | # "CallerIDNum": "100", 146 | # "CallerIDName": "Test", 147 | # "ConnectedLineNum": "100", 148 | # "ConnectedLineName": "Test", 149 | # "Context": "from-internal", 150 | # } 151 | # ) 152 | 153 | # await hass.async_block_till_done() 154 | # sensor = hass.states.get("sensor.100_dtmf_received") 155 | # assert sensor is not None 156 | # assert sensor.state != "unknown" 157 | --------------------------------------------------------------------------------