├── .devcontainer └── devcontainer.json ├── .flake8 ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ └── feature-request.md ├── stale.yml └── workflows │ └── hassfest.yaml ├── .gitignore ├── LICENSE.txt ├── README.md ├── custom_components └── browser_mod │ ├── __init__.py │ ├── binary_sensor.py │ ├── browser.py │ ├── browser_mod.js │ ├── browser_mod_panel.js │ ├── camera.py │ ├── config_flow.py │ ├── connection.py │ ├── const.py │ ├── entities.py │ ├── helpers.py │ ├── light.py │ ├── manifest.json │ ├── media_player.py │ ├── mod_view.py │ ├── sensor.py │ ├── service.py │ ├── services.yaml │ └── store.py ├── documentation ├── configuration-panel.md ├── popups.md └── services.md ├── hacs.json ├── info.md ├── js ├── config_panel │ ├── browser-mod-settings-table.ts │ ├── browser-settings-card.ts │ ├── frontend-settings-card.ts │ ├── main.ts │ ├── registered-browsers-card.ts │ └── sidebar-settings-custom-selector.ts ├── helpers.ts └── plugin │ ├── activity.ts │ ├── browser-player-editor.ts │ ├── browser-player.ts │ ├── browser.ts │ ├── browserID.ts │ ├── camera.ts │ ├── connection.ts │ ├── event-target-polyfill.js │ ├── frontend-settings.ts │ ├── fullyKiosk.ts │ ├── main.ts │ ├── mediaPlayer.ts │ ├── popup-card-editor.ts │ ├── popup-card.ts │ ├── popups.ts │ ├── require-interact.ts │ ├── screensaver.ts │ ├── services.ts │ ├── types.ts │ └── version.ts ├── package-lock.json ├── package.json ├── rollup.config.mjs ├── test ├── automations.yaml ├── configuration.yaml ├── docker-compose.yml ├── lovelace.yaml └── views │ ├── frontend-backend.yaml │ ├── more-info.yaml │ ├── notification.yaml │ ├── popup-card.yaml │ ├── popup.yaml │ └── various.yaml └── tsconfig.json /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hass-browser_mod Dev", 3 | "image": "thomasloven/hass-custom-devcontainer", 4 | "postCreateCommand": "sudo -E container setup-dev && npm add", 5 | "containerEnv": { 6 | "DEVCONTAINER": "1" 7 | }, 8 | "forwardPorts": [8123], 9 | "mounts": [ 10 | "source=${localWorkspaceFolder},target=/config/www/workspace,type=bind", 11 | "source=${localWorkspaceFolder}/test,target=/config/test,type=bind", 12 | "source=${localWorkspaceFolder}/test/configuration.yaml,target=/config/configuration.yaml,type=bind", 13 | "source=${localWorkspaceFolder}/custom_components,target=/config/custom_components,type=bind" 14 | ], 15 | "runArgs": ["--env-file", "${localWorkspaceFolder}/test/.env"], 16 | "extensions": [ 17 | "github.vscode-pull-request-github", 18 | "esbenp.prettier-vscode", 19 | "spmeesseman.vscode-taskexplorer", 20 | "ms-python.python" 21 | ], 22 | "settings": { 23 | "files.eol": "\n", 24 | "editor.tabSize": 2, 25 | "editor.formatOnPaste": false, 26 | "editor.formatOnSave": true, 27 | "editor.formatOnType": true, 28 | "[javascript]": { 29 | "editor.defaultFormatter": "esbenp.prettier-vscode" 30 | }, 31 | "[typescript]": { 32 | "editor.defaultFormatter": "esbenp.prettier-vscode" 33 | }, 34 | "files.trimTrailingWhitespace": true, 35 | "python.linting.pylintEnabled": false, 36 | "python.linting.flake8Enabled": true, 37 | "python.linting.enabled": true, 38 | "python.formatting.provider": "black" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | custom_components/browser_mod/browser_mod.js binary 2 | custom_components/browser_mod/browser_mod_panel.js binary 3 | package-lock.json binary 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: For reporting bugs or unexpected behavior 3 | title: "[Bug]: " 4 | body: 5 | - type: textarea 6 | id: ha_version 7 | attributes: 8 | label: My Home Assistant Version 9 | description: You can view your Home Assistant Version in _Settings > About_ 10 | placeholder: | 11 | Core: 2025.4.2 12 | Frontend: 20250411.0 13 | validations: 14 | required: true 15 | - type: textarea 16 | id: what_i_am_doing 17 | attributes: 18 | label: What I am doing 19 | - type: textarea 20 | id: what_i_expect_to_happen 21 | attributes: 22 | label: What I expect to happen 23 | - type: textarea 24 | id: what_happened_instead 25 | attributes: 26 | label: What happened instead 27 | - type: textarea 28 | id: minimal_steps_to_reproduce 29 | attributes: 30 | label: Minimal steps to reproduce 31 | value: | 32 | 1. 33 | 2. 34 | 3. 35 | ... 36 | - type: textarea 37 | id: minimal_code 38 | attributes: 39 | label: Include any yaml code here 40 | placeholder: Paste YAML code 41 | render: YAML 42 | - type: textarea 43 | id: console_errors 44 | attributes: 45 | label: Error messages from the browser console 46 | description: Select everything from the browser console and copy and paste below 47 | placeholder: Paste console errors 48 | render: console 49 | - type: markdown 50 | attributes: 51 | value: '---' 52 | - type: checkboxes 53 | id: checks 54 | attributes: 55 | label: By checking each box below I indicate that I ... 56 | options: 57 | - label: Understand that this is a channel for reporting bugs, not a support forum (https://community.home-assistant.io/). 58 | required: true 59 | - label: Have made sure I am using the latest version of the plugin. 60 | required: true 61 | - label: Understand that leaving one or more boxes unticked or failure to follow the template above may increase the time required to handle my bug-report, or cause it to be closed without further action. 62 | required: true 63 | - label: If this issue is to do with an Android device losing connection, sensors becoming unavailable etc., please confirm you have read, understood and tried methods described in Wiki Article [Andoird Devices 'Always On'](https://github.com/thomasloven/hass-browser_mod/wiki/Andoird-Devices-'Always-On') 64 | required: true 65 | 66 | 67 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: For suggesting new features 4 | title: '' 5 | labels: 'feature-request' 6 | assignees: '' 7 | --- 8 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | daysUntilStale: 60 2 | daysUntilClose: 7 3 | exemptLabels: 4 | - pinned 5 | - feature-request 6 | staleLabel: stale 7 | markComment: > 8 | This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. 9 | closeComment: false 10 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | #schedule: 7 | # - cron: '0 0 * * *' 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - uses: home-assistant/actions/hassfest@master 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | **/__pycache__/ 3 | .vscode 4 | .env 5 | custom_components/hacs/ -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Thomas Lovén 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # browser_mod 2 2 | 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) 4 | 5 | What if that tablet you have on your wall could open up a live feed from your front door camera when someone rings the bell? 6 | 7 | And what if you could use it as an extra security camera? 8 | 9 | Or what if you could use it to play music and videos from your Home Assistant media library? 10 | 11 | What if you could permanently hide that sidebar from your kids and lock them into a single dashboard? 12 | 13 | What if you could change the icon of the Home Assistant tab so it doesn't look the same as the forum? 14 | 15 | What if you could change the more-info dialog for some entity to a dashboard card of your own design? 16 | 17 | What if you could tap a button and have Home Assistant ask you which rooms you want the roomba to vacuum? 18 | 19 | # Installation instructions 20 | 21 | - **First make sure you have completely removed any installation of Browser Mod 1** \ 22 | I.e. remove `browser_mod:` from your configuration.yaml and delete the integration from the integrations page. 23 | 24 | - Either 25 | 26 | - Find and download [Browser Mod under `integrations`](https://my.home-assistant.io/redirect/hacs_repository/?owner=thomasloven&repository=hass-browser_mod) in [HACS](https://hacs.xyz) 27 | - OR copy the contents of `custom_components/browser_mod/` to `/custom_components/browser_mod/`. 28 | 29 | - Restart Home Assistant 30 | 31 | - Add the "Browser Mod" integration in Settings -> Devices & Services -> Add Integration or click this button: [![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=browser_mod) 32 | 33 | - Restart Home Assistant 34 | 35 | # Upgrading 36 | 37 | - Upgrade via [HACS](https://hacs.xyz) or copy new contents of `custom_components/browser_mod/` to `/custom_components/browser_mod/`. 38 | 39 | - Restart Home Assistant. If you are upgrading via [HACS](https://hacs.xyz) you will get a repair item to restart Home Assistant. 40 | 41 | - After restarting Home Assistant, all Browsers will need a reload to download the latest version of Browser Mod javascript file. Version 2.4.0 includes a notification when a Browser version mismatch is detected, so from 2.4.x onwards simply clicking __Reload__ should be sufficient. If you keep getting the notification, you may need to do a hard Browser reload `SHIFT+F5` or in some cases [clear your Browser cache](https://github.com/thomasloven/hass-config/wiki/Clearing-your-browser-cache). 42 | 43 | > Note: If you are upgrading from Browser Mod 1, it is likely that you will get some errors in your log during a transition period. They will say something along the lines of `Error handling message: extra keys not allowed @ data['deviceID']`. 44 | > 45 | > They appear when a browser which has an old version of Browser Mod cached tries to connect and should disappear once you have cleared all your caches properly. 46 | 47 | 48 | # Quickstart 49 | 50 | 1. [Install browser mod](#installation-instructions) 51 | 2. Thoroughly [clear your browser cache](https://github.com/thomasloven/hass-config/wiki/Clearing-your-browser-cache) 52 | 3. Go to the Browser Mod panel in your sidebar 53 | 4. Make sure the "Register" toggle is checked.\ 54 | **This is required in order to enable backend services to target this browser.** 55 | 5. Refresh the page (F5) 56 | 6. Go to Developer Tools -> Services [![Open your Home Assistant instance and show your service developer tools.](https://my.home-assistant.io/badges/developer_services.svg)](https://my.home-assistant.io/redirect/developer_services/) 57 | 7. Select service "Browser Mod: popup (browser_mod.popup)" 58 | 8. Check the "Title" checkbar and write something as a title 59 | 9. Enter some text in the "Content" text box\ 60 | Not yaml or anything, just any text for now. 61 | 10. Click "CALL SERVICE" \ 62 | The button is likely grayed out. That's a Home Assistant visual bug. Click it anyway. 63 | 11. A popup dialog should now open on all your Registered Browsers. 64 | 65 | ![Screenshot of a popup dialog according to the setup above](https://user-images.githubusercontent.com/1299821/188604118-ed2ad79c-0286-4392-b7be-cbc9c3f2ffd8.png) 66 | 67 | 68 | Here's a great overview of the functionality by [Smart Home Scene](https://smarthomescene.com/guides/how-to-setup-browser-mod-2-0-in-home-assistant/). 69 | 70 | 71 | \ 72 |   \ 73 |   74 | 75 | # Browser Mod Configuration Panel 76 | 77 | After installing Browser Mod you should see a new panel called _Browser Mod_ in the sidebar. This is where you controll any Browser Mod settings. 78 | 79 | ### See [Configuration Panel](documentation/configuration-panel.md) for more info 80 | \ 81 |   82 | 83 | # Browser Mod Services 84 | 85 | Browser Mod has a number of services you can call to cause things to happen in the target Browser, such as opening a popup or navigating to a certain dashboard. 86 | 87 | ### See [Services](documentation/services.md) for more info 88 | \ 89 |   90 | 91 | 92 | ## Popup card 93 | 94 | A popup card can be used to replace the more-info dialog of an entity with something of your choosing. 95 | 96 | To use it, add a "Custom: Popup card" to a dashboard view via the GUI, pick the entity you want to override, configure the card and set up the popup like for the [`browser_mod.popup` service](documentation/services.md). 97 | 98 | The card will be visible only while you're in Edit mode. 99 | 100 | Custom popup cards are either local to the current Dashboard view (default) or can be used across all views of the Dashboard. Use the `Popup card is available for use in all views` GUI switch or `popup_card_all_views` optional parameter in Yaml. Using global view custom popup cards allows you to use a sub view to store your custom popup cards for a Dashboard, if that fits your use case. 101 | 102 | Yaml configuration: 103 | 104 | ```yaml 105 | type: custom:popup-card 106 | entity: 107 | card: 108 | type: ...etc... 109 | [popup_card_all_views: [true/FALSE]] 110 | [any parameter from the browser_mod.popup service call except "content"] 111 | ``` 112 | 113 | > *Note:* It's advisable to use a `fire-dom-event` tap action instead as far as possible. Popup card is for the few cases where that's not possible. See [`services`](documentation/services.md) for more info. 114 | 115 | ## Browser Player 116 | 117 | Browser player is a card that allows you to controll the volume and playback on the current Browsers media player. 118 | 119 | Add it to a dashboard via the GUI or through yaml: 120 | 121 | ```yaml 122 | type: custom:browser-player 123 | ``` 124 | 125 | 126 | # FAQ 127 | 128 | ### **How do I access the FullyKiosk Browser special functions?** 129 | 130 | Make sure to activate the [Javascript Interface](https://www.fully-kiosk.com/en/#websiteintegration). 131 | The browser-mod panel will guide you through the rest of the required and suggested settings. 132 | 133 | ### **Why doesn't ANYTHING that used to work with Browser Mod 1.0 work with Browser Mod 2.0?** 134 | 135 | Browser Mod 2.0 has been rewritten ENTIRELY from the ground up. This allows it to be more stable and less resource intensive. At the same time I took the opportunity to rename a lot of things in ways that are more consistent with Home Assistant nomenclature. 136 | 137 | In short, things are hopefully much easier now for new users of Browser Mod at the unfortunate cost of a one-time inconvenience for veteran expert users such as yourself. 138 | 139 | 140 | ### **Why does my Browser ID keep changing?** 141 | There's just no way around this. I've used every trick in the book and invented a handful of new ones in order to save the Browser ID as far as possible. It should be much better in Browser Mod 2.0 than earlier, but it's still not perfect. At least it's easy to change it back now... 142 | 143 | Note that you can also set the browser ID to e.g. `whatever` by adding `?BrowserID=whatever` (N.B. capital B) to any Home Assistant URL. 144 | 145 | 146 | ### **How do I update a popup from the Browser mod 1.5?** 147 | If you have used `fire-dom-event` it's really simple. Just change 148 | 149 | ```yaml 150 | action: fire-dom-event 151 | browser_mod: 152 | command: popup 153 | title: My title 154 | card: 155 | type: ...etc... 156 | ``` 157 | 158 | to 159 | 160 | ```yaml 161 | action: fire-dom-event 162 | browser_mod: 163 | service: browser_mod.popup 164 | data: 165 | title: My title 166 | content: 167 | type: ...etc... 168 | ``` 169 | 170 | ### **How do I uninstall Browser Mod?** 171 | Besides removing the integration as usual, there is one extra step needed. 172 | In order to work well with [Home Assistant Cast](https://www.home-assistant.io/integrations/cast/#home-assistant-cast) Browser Mod will add itself to your Dashboard Resources, and you will need to remove it from there manually: 173 | 174 | [![Open your Home Assistant instance and show your dashboard resources.](https://my.home-assistant.io/badges/lovelace_resources.svg)](https://my.home-assistant.io/redirect/lovelace_resources/) 175 | 176 | --- 177 | 178 | Buy Me A Coffee 179 | -------------------------------------------------------------------------------- /custom_components/browser_mod/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import homeassistant.helpers.config_validation as cv 3 | from homeassistant.const import Platform 4 | 5 | from .store import BrowserModStore 6 | from .mod_view import async_setup_view 7 | from .connection import async_setup_connection 8 | from .const import DOMAIN, DATA_BROWSERS, DATA_ADDERS, DATA_STORE 9 | from .service import async_setup_services 10 | from .helpers import get_version 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) 16 | 17 | PLATFORMS = [ 18 | Platform.SENSOR, 19 | Platform.BINARY_SENSOR, 20 | Platform.LIGHT, 21 | Platform.MEDIA_PLAYER, 22 | Platform.CAMERA 23 | ] 24 | 25 | async def async_setup(hass, config): 26 | 27 | store = BrowserModStore(hass) 28 | await store.load() 29 | 30 | version = await hass.async_add_executor_job(get_version, hass) 31 | await store.set_version(version) 32 | 33 | hass.data[DOMAIN] = { 34 | DATA_BROWSERS: {}, 35 | DATA_ADDERS: {}, 36 | DATA_STORE: store, 37 | } 38 | 39 | return True 40 | 41 | 42 | async def async_setup_entry(hass, config_entry): 43 | 44 | await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) 45 | 46 | await async_setup_connection(hass) 47 | await async_setup_view(hass) 48 | await async_setup_services(hass) 49 | 50 | return True 51 | -------------------------------------------------------------------------------- /custom_components/browser_mod/binary_sensor.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.binary_sensor import BinarySensorEntity 2 | from homeassistant.helpers.entity import EntityCategory 3 | 4 | from .const import DOMAIN, DATA_ADDERS 5 | from .entities import BrowserModEntity 6 | 7 | 8 | async def async_setup_platform( 9 | hass, config_entry, async_add_entities, discoveryInfo=None 10 | ): 11 | hass.data[DOMAIN][DATA_ADDERS]["binary_sensor"] = async_add_entities 12 | 13 | 14 | async def async_setup_entry(hass, config_entry, async_add_entities): 15 | await async_setup_platform(hass, {}, async_add_entities) 16 | 17 | 18 | class BrowserBinarySensor(BrowserModEntity, BinarySensorEntity): 19 | def __init__(self, coordinator, browserID, parameter, name, icon=None): 20 | BrowserModEntity.__init__(self, coordinator, browserID, name, icon) 21 | BinarySensorEntity.__init__(self) 22 | self.parameter = parameter 23 | 24 | @property 25 | def is_on(self): 26 | return self._data.get("browser", {}).get(self.parameter, None) 27 | 28 | @property 29 | def entity_category(self): 30 | return EntityCategory.DIAGNOSTIC 31 | 32 | @property 33 | def extra_state_attributes(self): 34 | retval = super().extra_state_attributes 35 | if self.parameter == "fullyKiosk": 36 | retval["data"] = self._data.get("browser", {}).get("fully_data") 37 | return retval 38 | 39 | 40 | class ActivityBinarySensor(BrowserModEntity, BinarySensorEntity): 41 | def __init__(self, coordinator, browserID): 42 | BrowserModEntity.__init__(self, coordinator, browserID, None) 43 | BinarySensorEntity.__init__(self) 44 | 45 | @property 46 | def unique_id(self): 47 | return f"{self.browserID}-activity" 48 | 49 | @property 50 | def entity_registry_visible_default(self): 51 | return True 52 | 53 | @property 54 | def device_class(self): 55 | return "motion" 56 | 57 | @property 58 | def is_on(self): 59 | return self._data.get("activity", False) 60 | -------------------------------------------------------------------------------- /custom_components/browser_mod/camera.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from homeassistant.components.camera import Camera 4 | 5 | from .entities import BrowserModEntity 6 | from .const import DOMAIN, DATA_ADDERS 7 | 8 | import logging 9 | 10 | LOGGER = logging.Logger(__name__) 11 | 12 | 13 | async def async_setup_platform( 14 | hass, config_entry, async_add_entities, discoveryInfo=None 15 | ): 16 | hass.data[DOMAIN][DATA_ADDERS]["camera"] = async_add_entities 17 | 18 | 19 | async def async_setup_entry(hass, config_entry, async_add_entities): 20 | await async_setup_platform(hass, {}, async_add_entities) 21 | 22 | 23 | class BrowserModCamera(BrowserModEntity, Camera): 24 | def __init__(self, coordinator, browserID): 25 | BrowserModEntity.__init__(self, coordinator, browserID, None) 26 | Camera.__init__(self) 27 | 28 | @property 29 | def unique_id(self): 30 | return f"{self.browserID}-camera" 31 | 32 | @property 33 | def entity_registry_visible_default(self): 34 | return True 35 | 36 | def camera_image(self, width=None, height=None): 37 | if "camera" not in self._data: 38 | return None 39 | return base64.b64decode(self._data["camera"].split(",")[-1]) 40 | -------------------------------------------------------------------------------- /custom_components/browser_mod/config_flow.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from homeassistant import config_entries 3 | 4 | from .const import DOMAIN 5 | 6 | _LOGGER = logging.getLogger(__name__) 7 | 8 | 9 | @config_entries.HANDLERS.register(DOMAIN) 10 | class BrowserModConfigFlow(config_entries.ConfigFlow): 11 | 12 | VERSION = 2 13 | 14 | async def async_step_user(self, user_input=None): 15 | if self._async_current_entries(): 16 | return self.async_abort(reason="single_instance_allowed") 17 | return self.async_create_entry(title="Browser Mod", data={}) 18 | -------------------------------------------------------------------------------- /custom_components/browser_mod/connection.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import voluptuous as vol 3 | from datetime import datetime, timezone 4 | 5 | from homeassistant.components.websocket_api import ( 6 | event_message, 7 | async_register_command, 8 | ) 9 | 10 | from homeassistant.components import websocket_api 11 | 12 | from homeassistant.core import callback 13 | 14 | from .const import ( 15 | BROWSER_ID, 16 | DATA_STORE, 17 | WS_CONNECT, 18 | WS_LOG, 19 | WS_RECALL_ID, 20 | WS_REGISTER, 21 | WS_SETTINGS, 22 | WS_UNREGISTER, 23 | WS_UPDATE, 24 | DOMAIN, 25 | ) 26 | 27 | from .browser import getBrowser, deleteBrowser, getBrowserByConnection 28 | 29 | _LOGGER = logging.getLogger(__name__) 30 | 31 | 32 | async def async_setup_connection(hass): 33 | @websocket_api.websocket_command( 34 | { 35 | vol.Required("type"): WS_CONNECT, 36 | vol.Required("browserID"): str, 37 | } 38 | ) 39 | @websocket_api.async_response 40 | async def handle_connect(hass, connection, msg): 41 | """Connect to Browser Mod and subscribe to settings updates.""" 42 | browserID = msg[BROWSER_ID] 43 | store = hass.data[DOMAIN][DATA_STORE] 44 | 45 | @callback 46 | def send_update(data): 47 | connection.send_message(event_message(msg["id"], {"result": data})) 48 | 49 | store_listener = store.add_listener(send_update) 50 | 51 | def close_connection(): 52 | store_listener() 53 | dev = getBrowser(hass, browserID, create=False) 54 | if dev: 55 | dev.close_connection(hass, connection) 56 | 57 | connection.subscriptions[msg["id"]] = close_connection 58 | connection.send_result(msg["id"]) 59 | 60 | if store.get_browser(browserID).registered: 61 | dev = getBrowser(hass, browserID) 62 | dev.update_settings(hass, store.get_browser(browserID).asdict()) 63 | dev.open_connection(hass, connection, msg["id"]) 64 | await store.set_browser( 65 | browserID, 66 | last_seen=datetime.now(tz=timezone.utc).isoformat(), 67 | ) 68 | send_update(store.asdict()) 69 | 70 | @websocket_api.websocket_command( 71 | { 72 | vol.Required("type"): WS_REGISTER, 73 | vol.Required("browserID"): str, 74 | vol.Optional("data"): dict, 75 | } 76 | ) 77 | @websocket_api.async_response 78 | async def handle_register(hass, connection, msg): 79 | """Register a Browser.""" 80 | browserID = msg[BROWSER_ID] 81 | store = hass.data[DOMAIN][DATA_STORE] 82 | 83 | browserSettings = {"registered": True} 84 | data = msg.get("data", {}) 85 | if "last_seen" in data: 86 | del data["last_seen"] 87 | if BROWSER_ID in data: 88 | # Change ID of registered browser 89 | newBrowserID = data[BROWSER_ID] 90 | del data[BROWSER_ID] 91 | 92 | # Copy data from old browser and delete it from store 93 | if oldBrowserSettings := store.get_browser(browserID): 94 | browserSettings = oldBrowserSettings.asdict() 95 | await store.delete_browser(browserID) 96 | 97 | # Delete the old Browser device 98 | deleteBrowser(hass, browserID) 99 | 100 | # Use the new browserID from now on 101 | browserID = newBrowserID 102 | 103 | # Create and/or update Browser device 104 | dev = getBrowser(hass, browserID) 105 | dev.update_settings(hass, data) 106 | 107 | # Create or update store data 108 | if data is not None: 109 | browserSettings.update(data) 110 | await store.set_browser(browserID, **browserSettings) 111 | 112 | @websocket_api.websocket_command( 113 | { 114 | vol.Required("type"): WS_UNREGISTER, 115 | vol.Required("browserID"): str, 116 | } 117 | ) 118 | @websocket_api.async_response 119 | async def handle_unregister(hass, connection, msg): 120 | """Unregister a Browser.""" 121 | browserID = msg[BROWSER_ID] 122 | store = hass.data[DOMAIN][DATA_STORE] 123 | 124 | deleteBrowser(hass, browserID) 125 | await store.delete_browser(browserID) 126 | 127 | connection.send_result(msg["id"]) 128 | 129 | @websocket_api.websocket_command( 130 | { 131 | vol.Required("type"): WS_UPDATE, 132 | vol.Required("browserID"): str, 133 | vol.Optional("data"): dict, 134 | } 135 | ) 136 | @websocket_api.async_response 137 | async def handle_update(hass, connection, msg): 138 | """Receive state updates from a Browser.""" 139 | browserID = msg[BROWSER_ID] 140 | store = hass.data[DOMAIN][DATA_STORE] 141 | 142 | if store.get_browser(browserID).registered: 143 | dev = getBrowser(hass, browserID) 144 | dev.update(hass, msg.get("data", {})) 145 | 146 | @websocket_api.websocket_command( 147 | { 148 | vol.Required("type"): WS_SETTINGS, 149 | vol.Required("key"): str, 150 | vol.Optional("value"): vol.Any(int, str, bool, list, object, None), 151 | vol.Optional("user"): str, 152 | } 153 | ) 154 | @websocket_api.async_response 155 | async def handle_settings(hass, connection, msg): 156 | """Change user or global settings.""" 157 | store = hass.data[DOMAIN][DATA_STORE] 158 | if "user" in msg: 159 | # Set user setting 160 | await store.set_user_settings( 161 | msg["user"], **{msg["key"]: msg.get("value", None)} 162 | ) 163 | else: 164 | # Set global setting 165 | await store.set_global_settings(**{msg["key"]: msg.get("value", None)}) 166 | pass 167 | 168 | @websocket_api.websocket_command( 169 | { 170 | vol.Required("type"): WS_RECALL_ID, 171 | } 172 | ) 173 | def handle_recall_id(hass, connection, msg): 174 | """Recall browserID of Browser with the current connection.""" 175 | dev = getBrowserByConnection(hass, connection) 176 | if dev: 177 | connection.send_message( 178 | websocket_api.result_message(msg["id"], dev.browserID) 179 | ) 180 | connection.send_message(websocket_api.result_message(msg["id"], None)) 181 | 182 | @websocket_api.websocket_command( 183 | { 184 | vol.Required("type"): WS_LOG, 185 | vol.Required("message"): str, 186 | } 187 | ) 188 | def handle_log(hass, connection, msg): 189 | """Print a debug message.""" 190 | _LOGGER.info(f"LOG MESSAGE: {msg['message']}") 191 | 192 | async_register_command(hass, handle_connect) 193 | async_register_command(hass, handle_register) 194 | async_register_command(hass, handle_unregister) 195 | async_register_command(hass, handle_update) 196 | async_register_command(hass, handle_settings) 197 | async_register_command(hass, handle_recall_id) 198 | async_register_command(hass, handle_log) 199 | -------------------------------------------------------------------------------- /custom_components/browser_mod/const.py: -------------------------------------------------------------------------------- 1 | DOMAIN = "browser_mod" 2 | 3 | BROWSER_ID = "browserID" 4 | 5 | FRONTEND_SCRIPT_URL = "/browser_mod.js" 6 | SETTINGS_PANEL_URL = "/browser_mod_panel.js" 7 | 8 | DATA_BROWSERS = "browsers" 9 | DATA_ADDERS = "adders" 10 | DATA_STORE = "store" 11 | 12 | WS_ROOT = DOMAIN 13 | WS_CONNECT = f"{WS_ROOT}/connect" 14 | WS_REGISTER = f"{WS_ROOT}/register" 15 | WS_UNREGISTER = f"{WS_ROOT}/unregister" 16 | WS_UPDATE = f"{WS_ROOT}/update" 17 | WS_SETTINGS = f"{WS_ROOT}/settings" 18 | WS_RECALL_ID = f"{WS_ROOT}/recall_id" 19 | WS_LOG = f"{WS_ROOT}/log" 20 | 21 | BROWSER_MOD_BROWSER_SERVICES = [ 22 | "sequence", 23 | "delay", 24 | "popup", 25 | "more_info", 26 | "close_popup", 27 | "notification", 28 | "navigate", 29 | "refresh", 30 | "set_theme", 31 | "console", 32 | "javascript", 33 | ] 34 | 35 | BROWSER_MOD_COMPONENT_SERVICES = [ 36 | "deregister_browser", 37 | ] 38 | -------------------------------------------------------------------------------- /custom_components/browser_mod/entities.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 4 | 5 | from .const import ( 6 | DOMAIN, 7 | ) 8 | 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | class BrowserModEntity(CoordinatorEntity): 14 | def __init__(self, coordinator, browserID, name, icon=None): 15 | super().__init__(coordinator) 16 | self.browserID = browserID 17 | self._name = name 18 | self._icon = icon 19 | 20 | @property 21 | def _data(self): 22 | return self.coordinator.data or {} 23 | 24 | @property 25 | def device_info(self): 26 | config_url = {} 27 | if ip := self._data.get("browser", {}).get("ip_address"): 28 | config_url = {"configuration_url": f"http://{ip}:2323"} 29 | return { 30 | "identifiers": {(DOMAIN, self.browserID)}, 31 | "name": self.browserID, 32 | "manufacturer": "Browser Mod", 33 | **config_url, 34 | } 35 | 36 | @property 37 | def extra_state_attributes(self): 38 | return { 39 | "type": "browser_mod", 40 | "browserID": self.browserID, 41 | } 42 | 43 | @property 44 | def available(self): 45 | return self._data.get("connected", False) 46 | 47 | @property 48 | def name(self): 49 | return self._name 50 | 51 | @property 52 | def has_entity_name(self): 53 | return True 54 | 55 | # @property 56 | # def entity_registry_visible_default(self): 57 | # return False 58 | 59 | @property 60 | def unique_id(self): 61 | return f"{self.browserID}-{self._name.replace(' ','_')}" 62 | 63 | @property 64 | def icon(self): 65 | return self._icon 66 | -------------------------------------------------------------------------------- /custom_components/browser_mod/helpers.py: -------------------------------------------------------------------------------- 1 | import json 2 | from homeassistant.core import HomeAssistant 3 | 4 | def get_version(hass: HomeAssistant): 5 | with open(hass.config.path("custom_components/browser_mod/manifest.json"), "r") as fp: 6 | manifest = json.load(fp) 7 | return manifest["version"] -------------------------------------------------------------------------------- /custom_components/browser_mod/light.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.light import LightEntity, ColorMode 2 | 3 | from .entities import BrowserModEntity 4 | from .const import DOMAIN, DATA_ADDERS 5 | 6 | 7 | async def async_setup_platform( 8 | hass, config_entry, async_add_entities, discoveryInfo=None 9 | ): 10 | hass.data[DOMAIN][DATA_ADDERS]["light"] = async_add_entities 11 | 12 | 13 | async def async_setup_entry(hass, config_entry, async_add_entities): 14 | await async_setup_platform(hass, {}, async_add_entities) 15 | 16 | 17 | class BrowserModLight(BrowserModEntity, LightEntity): 18 | def __init__(self, coordinator, browserID, browser): 19 | BrowserModEntity.__init__(self, coordinator, browserID, "Screen") 20 | LightEntity.__init__(self) 21 | self.browser = browser 22 | 23 | @property 24 | def entity_registry_visible_default(self): 25 | return True 26 | 27 | @property 28 | def is_on(self): 29 | return self._data.get("screen_on", None) 30 | 31 | @property 32 | def supported_color_modes(self): 33 | return {ColorMode.BRIGHTNESS} 34 | 35 | @property 36 | def color_mode(self): 37 | return ColorMode.BRIGHTNESS 38 | 39 | @property 40 | def brightness(self): 41 | return self._data.get("screen_brightness", 1) 42 | 43 | async def async_turn_on(self, **kwargs): 44 | await self.browser.send("screen_on", **kwargs) 45 | 46 | async def async_turn_off(self, **kwargs): 47 | await self.browser.send("screen_off") 48 | -------------------------------------------------------------------------------- /custom_components/browser_mod/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "browser_mod", 3 | "name": "Browser mod", 4 | "codeowners": [], 5 | "config_flow": true, 6 | "dependencies": [ 7 | "panel_custom", 8 | "websocket_api", 9 | "http", 10 | "frontend", 11 | "lovelace" 12 | ], 13 | "documentation": "https://github.com/thomasloven/hass-browser_mod/blob/master/README.md", 14 | "iot_class": "local_push", 15 | "requirements": [], 16 | "version": "2.4.0" 17 | } -------------------------------------------------------------------------------- /custom_components/browser_mod/media_player.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components import media_source 2 | from homeassistant.components.media_player import ( 3 | MediaPlayerEntity, 4 | MediaType, 5 | MediaPlayerEntityFeature 6 | ) 7 | from homeassistant.components.media_player.browse_media import ( 8 | async_process_play_media_url, 9 | ) 10 | from homeassistant.const import ( 11 | STATE_UNAVAILABLE, 12 | STATE_PAUSED, 13 | STATE_PLAYING, 14 | STATE_IDLE, 15 | STATE_UNKNOWN, 16 | STATE_ON, 17 | STATE_OFF, 18 | ) 19 | 20 | from homeassistant.util import dt 21 | 22 | from .entities import BrowserModEntity 23 | from .const import DOMAIN, DATA_ADDERS 24 | 25 | 26 | async def async_setup_platform( 27 | hass, config_entry, async_add_entities, discoveryInfo=None 28 | ): 29 | hass.data[DOMAIN][DATA_ADDERS]["media_player"] = async_add_entities 30 | 31 | 32 | async def async_setup_entry(hass, config_entry, async_add_entities): 33 | await async_setup_platform(hass, {}, async_add_entities) 34 | 35 | 36 | class BrowserModPlayer(BrowserModEntity, MediaPlayerEntity): 37 | def __init__(self, coordinator, browserID, browser): 38 | BrowserModEntity.__init__(self, coordinator, browserID, None) 39 | MediaPlayerEntity.__init__(self) 40 | self.browser = browser 41 | 42 | @property 43 | def unique_id(self): 44 | return f"{self.browserID}-player" 45 | 46 | @property 47 | def entity_registry_visible_default(self): 48 | return True 49 | 50 | @property 51 | def state(self): 52 | state = self._data.get("player", {}).get("state") 53 | return { 54 | "playing": STATE_PLAYING, 55 | "paused": STATE_PAUSED, 56 | "stopped": STATE_IDLE, 57 | "unavailable": STATE_UNAVAILABLE, 58 | "on": STATE_ON, 59 | "off": STATE_OFF, 60 | }.get(state, STATE_UNKNOWN) 61 | 62 | @property 63 | def supported_features(self): 64 | return ( 65 | MediaPlayerEntityFeature.PLAY 66 | | MediaPlayerEntityFeature.PLAY_MEDIA 67 | | MediaPlayerEntityFeature.PAUSE 68 | | MediaPlayerEntityFeature.STOP 69 | | MediaPlayerEntityFeature.VOLUME_SET 70 | | MediaPlayerEntityFeature.VOLUME_MUTE 71 | | MediaPlayerEntityFeature.BROWSE_MEDIA 72 | | MediaPlayerEntityFeature.SEEK 73 | | MediaPlayerEntityFeature.TURN_OFF 74 | | MediaPlayerEntityFeature.TURN_ON 75 | ) 76 | 77 | @property 78 | def volume_level(self): 79 | return self._data.get("player", {}).get("volume", 0) 80 | 81 | @property 82 | def is_volume_muted(self): 83 | return self._data.get("player", {}).get("muted", False) 84 | 85 | @property 86 | def source(self): 87 | return self._data.get("player", {}).get("src", None) 88 | 89 | @property 90 | def media_duration(self): 91 | duration = self._data.get("player", {}).get("media_duration", None) 92 | return float(duration) if duration is not None else None 93 | 94 | @property 95 | def media_position(self): 96 | position = self._data.get("player", {}).get("media_position", None) 97 | return float(position) if position is not None else None 98 | 99 | @property 100 | def media_position_updated_at(self): 101 | return dt.utcnow() 102 | 103 | @property 104 | def media_content_id(self): 105 | return self._data.get("player", {}).get("extra", {}).get("media_content_id", None) 106 | 107 | @property 108 | def media_content_type(self): 109 | return self._data.get("player", {}).get("extra", {}).get("media_content_type", None) 110 | 111 | @property 112 | def media_title(self): 113 | return self._data.get("player", {}).get("extra", {}).get("title", None) 114 | 115 | @property 116 | def media_image_remotely_accessible(self): 117 | return True if self._data.get("player", {}).get("extra", {}).get("thumb", None) else False 118 | 119 | @property 120 | def media_image_url(self): 121 | return self._data.get("player", {}).get("extra", {}).get("thumb", None) 122 | 123 | async def async_set_volume_level(self, volume): 124 | await self.browser.send("player-set-volume", volume_level=volume) 125 | 126 | async def async_mute_volume(self, mute): 127 | await self.browser.send("player-mute", mute=mute) 128 | 129 | async def async_play_media(self, media_type, media_id, **kwargs): 130 | kwargs["extra"]["media_content_id"] = media_id 131 | kwargs["extra"]["media_content_type"] = media_type 132 | if media_source.is_media_source_id(media_id): 133 | play_item = await media_source.async_resolve_media( 134 | self.hass, media_id, self.entity_id 135 | ) 136 | media_type = play_item.mime_type 137 | media_id = play_item.url 138 | media_id = async_process_play_media_url(self.hass, media_id) 139 | if media_type in (MediaType.URL, MediaType.MUSIC): 140 | media_id = async_process_play_media_url(self.hass, media_id) 141 | await self.browser.send( 142 | "player-play", media_content_id=media_id, media_type=media_type, **kwargs 143 | ) 144 | 145 | async def async_browse_media(self, media_content_type=None, media_content_id=None): 146 | """Implement the websocket media browsing helper.""" 147 | return await media_source.async_browse_media( 148 | self.hass, 149 | media_content_id, 150 | # content_filter=lambda item: item.media_content_type.startswith("audio/"), 151 | ) 152 | 153 | async def async_media_play(self): 154 | await self.browser.send("player-play") 155 | 156 | async def async_media_pause(self): 157 | await self.browser.send("player-pause") 158 | 159 | async def async_media_stop(self): 160 | await self.browser.send("player-stop") 161 | 162 | async def async_media_seek(self, position): 163 | await self.browser.send("player-seek", position=position) 164 | 165 | async def async_turn_off(self): 166 | await self.browser.send("player-turn-off") 167 | 168 | async def async_turn_on(self, **kwargs): 169 | await self.browser.send("player-turn-on", **kwargs) 170 | -------------------------------------------------------------------------------- /custom_components/browser_mod/mod_view.py: -------------------------------------------------------------------------------- 1 | from homeassistant.core import HomeAssistant 2 | from homeassistant.components.frontend import add_extra_js_url, async_register_built_in_panel 3 | from homeassistant.components.http import StaticPathConfig 4 | from homeassistant.components.lovelace.resources import ResourceStorageCollection 5 | 6 | from .const import FRONTEND_SCRIPT_URL, SETTINGS_PANEL_URL 7 | from .helpers import get_version 8 | 9 | import logging 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | async def async_setup_view(hass: HomeAssistant): 14 | 15 | version = await hass.async_add_executor_job(get_version, hass) 16 | 17 | # Serve the Browser Mod controller and add it as extra_module_url 18 | await hass.http.async_register_static_paths( 19 | [ 20 | StaticPathConfig( 21 | FRONTEND_SCRIPT_URL, 22 | hass.config.path("custom_components/browser_mod/browser_mod.js"), 23 | True, 24 | ) 25 | ] 26 | ) 27 | add_extra_js_url(hass, FRONTEND_SCRIPT_URL + "?" + version) 28 | 29 | # Serve the Browser Mod Settings panel and register it as a panel 30 | await hass.http.async_register_static_paths( 31 | [ 32 | StaticPathConfig( 33 | SETTINGS_PANEL_URL, 34 | hass.config.path("custom_components/browser_mod/browser_mod_panel.js"), 35 | True, 36 | ) 37 | ] 38 | ) 39 | async_register_built_in_panel( 40 | hass=hass, 41 | component_name="custom", 42 | sidebar_title="Browser Mod", 43 | sidebar_icon="mdi:server", 44 | frontend_url_path="browser-mod", 45 | require_admin=False, 46 | config={ 47 | "_panel_custom": { 48 | "name": "browser-mod-panel", 49 | "js_url": SETTINGS_PANEL_URL + "?" + version, 50 | } 51 | }, 52 | ) 53 | 54 | # Also load Browser Mod as a lovelace resource so it's accessible to Cast 55 | resources = hass.data["lovelace"].resources 56 | resourceUrl = FRONTEND_SCRIPT_URL + "?automatically-added" + "&" + version 57 | if resources: 58 | if not resources.loaded: 59 | await resources.async_load() 60 | resources.loaded = True 61 | 62 | frontend_added = False 63 | for r in resources.async_items(): 64 | if r["url"].startswith(FRONTEND_SCRIPT_URL): 65 | frontend_added = True 66 | if not r["url"].endswith(version): 67 | if isinstance(resources, ResourceStorageCollection): 68 | await resources.async_update_item( 69 | r["id"], 70 | { 71 | "res_type": "module", 72 | "url": resourceUrl 73 | } 74 | ) 75 | else: 76 | # not the best solution, but what else can we do 77 | r["url"] = resourceUrl 78 | 79 | continue 80 | 81 | # While going through the resources, also preload card-mod if it is found 82 | if "card-mod.js" in r["url"]: 83 | add_extra_js_url(hass, r["url"]) 84 | 85 | if not frontend_added: 86 | if getattr(resources, "async_create_item", None): 87 | await resources.async_create_item( 88 | { 89 | "res_type": "module", 90 | "url": resourceUrl, 91 | } 92 | ) 93 | elif getattr(resources, "data", None) and getattr( 94 | resources.data, "append", None 95 | ): 96 | resources.data.append( 97 | { 98 | "type": "module", 99 | "url": resourceUrl, 100 | 101 | } 102 | ) -------------------------------------------------------------------------------- /custom_components/browser_mod/sensor.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.sensor import SensorEntity 2 | from homeassistant.helpers.entity import EntityCategory 3 | 4 | from .const import DOMAIN, DATA_ADDERS 5 | from .entities import BrowserModEntity 6 | 7 | 8 | async def async_setup_platform( 9 | hass, config_entry, async_add_entities, discoveryInfo=None 10 | ): 11 | hass.data[DOMAIN][DATA_ADDERS]["sensor"] = async_add_entities 12 | 13 | 14 | async def async_setup_entry(hass, config_entry, async_add_entities): 15 | await async_setup_platform(hass, {}, async_add_entities) 16 | 17 | 18 | class BrowserSensor(BrowserModEntity, SensorEntity): 19 | def __init__( 20 | self, 21 | coordinator, 22 | browserID, 23 | parameter, 24 | name, 25 | unit_of_measurement=None, 26 | device_class=None, 27 | icon=None, 28 | ): 29 | BrowserModEntity.__init__(self, coordinator, browserID, name, icon) 30 | SensorEntity.__init__(self) 31 | self.parameter = parameter 32 | self._device_class = device_class 33 | self._unit_of_measurement = unit_of_measurement 34 | 35 | @property 36 | def native_value(self): 37 | val = self._data.get("browser", {}).get(self.parameter, None) 38 | if len(str(val)) > 255: 39 | val = str(val)[:250] + "..." 40 | return val 41 | 42 | @property 43 | def device_class(self): 44 | return self._device_class 45 | 46 | @property 47 | def native_unit_of_measurement(self): 48 | return self._unit_of_measurement 49 | 50 | @property 51 | def entity_category(self): 52 | return EntityCategory.DIAGNOSTIC 53 | 54 | @property 55 | def extra_state_attributes(self): 56 | retval = super().extra_state_attributes 57 | 58 | if self.parameter == "currentUser": 59 | retval["userData"] = self._data.get("browser", {}).get("userData") 60 | 61 | if self.parameter == "path": 62 | retval["pathSegments"] = ( 63 | self._data.get("browser", {}).get("path", "").split("/") 64 | ) 65 | 66 | if self.parameter == "userAgent": 67 | retval["userAgent"] = self._data.get("browser", {}).get("userAgent") 68 | 69 | return retval 70 | -------------------------------------------------------------------------------- /custom_components/browser_mod/service.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from homeassistant.helpers import device_registry, template 4 | from homeassistant.util import dt as dt_util 5 | from homeassistant.exceptions import ServiceValidationError 6 | 7 | from .const import ( 8 | BROWSER_MOD_BROWSER_SERVICES, 9 | BROWSER_MOD_COMPONENT_SERVICES, 10 | DOMAIN, 11 | DATA_BROWSERS, 12 | DATA_STORE, 13 | ) 14 | 15 | from .browser import deleteBrowsers 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | async def async_setup_services(hass): 21 | def get_browser_ids(data, selectorSuffix=""): 22 | browserIDSelector = "browser_id" 23 | deviceIDSelector = "device_id" 24 | areaIDSelector = "area_id" 25 | if selectorSuffix: 26 | browserIDSelector += f"_{selectorSuffix}" 27 | deviceIDSelector += f"_{selectorSuffix}" 28 | areaIDSelector += f"_{selectorSuffix}" 29 | 30 | browsers = data.pop(browserIDSelector, []) 31 | if isinstance(browsers, str): 32 | browsers = [browsers] 33 | 34 | # Support service targets 35 | deviceIDs = data.pop(deviceIDSelector, []) 36 | if isinstance(deviceIDs, str): 37 | deviceIDs = [deviceIDs] 38 | browsers.extend(deviceIDs) 39 | dr = device_registry.async_get(hass) 40 | areaIDs = data.pop(areaIDSelector, []) 41 | if isinstance(areaIDs, str): 42 | areaIDs = [areaIDs] 43 | for area in areaIDs: 44 | for dev in device_registry.async_entries_for_area(dr, area): 45 | for identifier in dev.identifiers: 46 | if identifier[0] == DOMAIN: 47 | browsers.append(identifier[1]) 48 | break 49 | 50 | browserIDs = None 51 | if len(browsers): 52 | browserIDs = set() 53 | for br in browsers: 54 | dev = dr.async_get(br) 55 | if dev: 56 | browserIDs.add(list(dev.identifiers)[0][1]) 57 | else: 58 | browserIDs.add(br) 59 | 60 | browserIDs = set(browserIDs) 61 | 62 | return browserIDs 63 | 64 | def call_service(service, targets, data): 65 | browserTargets = targets["browsers"] 66 | userTargets = targets["users"] 67 | 68 | browsers = hass.data[DOMAIN][DATA_BROWSERS] 69 | 70 | # If no targets were specified, send to all browsers 71 | if browserTargets is None and userTargets is None: 72 | browserTargets = browsers.keys() 73 | elif browserTargets is None and userTargets is not None: 74 | browserTargets = [] 75 | 76 | if userTargets is not None and len(userTargets): 77 | for userId in userTargets: 78 | for key, browser in browsers.items(): 79 | if browser.data.get("browser", {}).get("userData", {}).get("id") == userId: 80 | browserTargets.append(key) 81 | 82 | for browserTarget in browserTargets: 83 | if browserTarget not in browsers: 84 | continue 85 | browser = browsers[browserTarget] 86 | hass.create_task(browser.send(service, **data)) 87 | 88 | def handle_browser_service(call): 89 | service = call.service 90 | data = {**call.data} 91 | 92 | browserIDs = get_browser_ids(data) 93 | 94 | # Support User Targets 95 | users = data.pop("user_id", []) 96 | if isinstance(users, str): 97 | users = [users] 98 | 99 | userIDs = None 100 | if len(users): 101 | userIDs = set() 102 | for user in users: 103 | userId = template.state_attr(hass, user, "user_id") 104 | if userId: 105 | userIDs.add(userId) 106 | else: 107 | userIDs.add(user) 108 | 109 | targets = {"browsers": browserIDs, "users": userIDs} 110 | 111 | call_service(service, targets, data) 112 | 113 | # Hanldler for server browser_mod.deregister_browser 114 | async def deregister_browser(call): 115 | data = {**call.data} 116 | 117 | browserIDs = get_browser_ids(data) 118 | if browserIDs is None: 119 | browserIDs = set() 120 | 121 | excludedBrowserIDs = get_browser_ids(data, "exclude") 122 | if excludedBrowserIDs is None: 123 | excludedBrowserIDs = set() 124 | 125 | if not browserIDs and not excludedBrowserIDs: 126 | raise ServiceValidationError( 127 | "No browsers to include or exclude" 128 | ) 129 | 130 | _LOGGER.debug("browser_mod.deregister_browser: included: %s", browserIDs) 131 | _LOGGER.debug("browser_mod.deregister_browser: excluded: %s", excludedBrowserIDs) 132 | deleteBrowsers(hass, browserIDs, excludedBrowserIDs) 133 | 134 | store = hass.data[DOMAIN][DATA_STORE] 135 | await store.cleanup(browserIDs, excludedBrowserIDs) 136 | 137 | for service in BROWSER_MOD_BROWSER_SERVICES: 138 | hass.services.async_register(DOMAIN, service, handle_browser_service) 139 | 140 | handlerFunctions = locals() 141 | for service in BROWSER_MOD_COMPONENT_SERVICES: 142 | if service in handlerFunctions: 143 | hass.services.async_register(DOMAIN, service, handlerFunctions[service]) 144 | else: 145 | _LOGGER.error("Component handler service %s not found", service) 146 | -------------------------------------------------------------------------------- /custom_components/browser_mod/store.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import attr 3 | from homeassistant.helpers.storage import Store 4 | 5 | STORAGE_VERSION = 1 6 | STORAGE_KEY = "browser_mod.storage" 7 | 8 | LISTENER_STORAGE_KEY = "browser_mod.config_listeners" 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | @attr.s 14 | class SettingsStoreData: 15 | hideSidebar = attr.ib(type=bool, default=None) 16 | hideHeader = attr.ib(type=bool, default=None) 17 | defaultPanel = attr.ib(type=str, default=None) 18 | defaultAction = attr.ib(type=object, default=None) 19 | sidebarPanelOrder = attr.ib(type=list, default=None) 20 | sidebarHiddenPanels = attr.ib(type=list, default=None) 21 | sidebarTitle = attr.ib(type=str, default=None) 22 | faviconTemplate = attr.ib(type=str, default=None) 23 | titleTemplate = attr.ib(type=str, default=None) 24 | hideInteractIcon = attr.ib(type=bool, default=None) 25 | autoRegister = attr.ib(type=bool, default=None) 26 | lockRegister = attr.ib(type=bool, default=None) 27 | saveScreenState = attr.ib(type=bool, default=None) 28 | 29 | @classmethod 30 | def from_dict(cls, data): 31 | class_attributes = attr.fields_dict(cls).keys() 32 | valid = {key: value for key, value in data.items() if key in class_attributes} 33 | return cls(**valid) 34 | 35 | def asdict(self): 36 | return attr.asdict(self) 37 | 38 | 39 | @attr.s 40 | class BrowserStoreData: 41 | last_seen = attr.ib(type=int, default=0) 42 | registered = attr.ib(type=bool, default=False) 43 | locked = attr.ib(type=bool, default=False) 44 | camera = attr.ib(type=bool, default=False) 45 | settings = attr.ib(type=SettingsStoreData, factory=SettingsStoreData) 46 | meta = attr.ib(type=str, default="default") 47 | 48 | @classmethod 49 | def from_dict(cls, data): 50 | class_attributes = attr.fields_dict(cls).keys() 51 | valid = {key: value for key, value in data.items() if key in class_attributes} 52 | settings = SettingsStoreData.from_dict(data.get("settings", {})) 53 | return cls( 54 | **( 55 | valid 56 | | { 57 | "settings": settings, 58 | } 59 | ) 60 | ) 61 | 62 | def asdict(self): 63 | return attr.asdict(self) 64 | 65 | 66 | @attr.s 67 | class ConfigStoreData: 68 | browsers = attr.ib(type=dict[str:BrowserStoreData], factory=dict) 69 | version = attr.ib(type=str, default="2.0") 70 | settings = attr.ib(type=SettingsStoreData, factory=SettingsStoreData) 71 | user_settings = attr.ib(type=dict[str:SettingsStoreData], factory=dict) 72 | 73 | @classmethod 74 | def from_dict(cls, data={}): 75 | browsers = { 76 | k: BrowserStoreData.from_dict(v) 77 | for k, v in data.get("browsers", {}).items() 78 | } 79 | user_settings = { 80 | k: SettingsStoreData.from_dict(v) 81 | for k, v in data.get("user_settings", {}).items() 82 | } 83 | settings = SettingsStoreData.from_dict(data.get("settings", {})) 84 | return cls( 85 | **( 86 | data 87 | | { 88 | "browsers": browsers, 89 | "settings": settings, 90 | "user_settings": user_settings, 91 | } 92 | ) 93 | ) 94 | 95 | def asdict(self): 96 | return attr.asdict(self) 97 | 98 | 99 | class BrowserModStore: 100 | def __init__(self, hass): 101 | self.store = Store(hass, STORAGE_VERSION, STORAGE_KEY) 102 | self.listeners = [] 103 | self.data = None 104 | self.dirty = False 105 | 106 | async def save(self): 107 | if self.dirty: 108 | await self.store.async_save(attr.asdict(self.data)) 109 | self.dirty = False 110 | 111 | async def load(self): 112 | stored = await self.store.async_load() 113 | if stored: 114 | self.data = ConfigStoreData.from_dict(stored) 115 | if self.data is None: 116 | self.data = ConfigStoreData() 117 | await self.save() 118 | self.dirty = False 119 | 120 | async def updated(self): 121 | self.dirty = True 122 | for listener in self.listeners: 123 | listener(attr.asdict(self.data)) 124 | await self.save() 125 | 126 | def asdict(self): 127 | return self.data.asdict() 128 | 129 | def add_listener(self, callback): 130 | self.listeners.append(callback) 131 | 132 | def remove_listener(): 133 | self.listeners.remove(callback) 134 | 135 | return remove_listener 136 | 137 | def get_version(self): 138 | return self.data.version 139 | 140 | async def set_version(self, version): 141 | if self.data.version != version: 142 | self.data.version = version 143 | await self.updated() 144 | 145 | def get_browser(self, browserID): 146 | return self.data.browsers.get(browserID, BrowserStoreData()) 147 | 148 | async def set_browser(self, browserID, **data): 149 | browser = self.data.browsers.get(browserID, BrowserStoreData()) 150 | browser.__dict__.update(data) 151 | self.data.browsers[browserID] = browser 152 | await self.updated() 153 | 154 | async def delete_browser(self, browserID): 155 | del self.data.browsers[browserID] 156 | await self.updated() 157 | 158 | def get_user_settings(self, name): 159 | return self.data.user_settings.get(name, SettingsStoreData()) 160 | 161 | async def set_user_settings(self, name, **data): 162 | settings = self.data.user_settings.get(name, SettingsStoreData()) 163 | settings.__dict__.update(data) 164 | self.data.user_settings[name] = settings 165 | await self.updated() 166 | 167 | def get_global_settings(self): 168 | return self.data.settings 169 | 170 | async def set_global_settings(self, **data): 171 | self.data.settings.__dict__.update(data) 172 | await self.updated() 173 | 174 | async def cleanup(self, browser_include, browser_exclude): 175 | """Cleanup old browsers from data store.""" 176 | if browser_include: 177 | for browserID in browser_include: 178 | if self.data.browsers.get(browserID): 179 | _LOGGER.debug("Data Store Cleanup: Deleting browser %s (included)", browserID) 180 | del self.data.browsers[browserID] 181 | self.dirty = True 182 | 183 | if browser_exclude: 184 | for browserID in list(self.data.browsers.keys()): 185 | if browserID not in browser_exclude and self.data.browsers.get(browserID): 186 | _LOGGER.debug("Data Store Cleanup: %s (not excluded)", browserID) 187 | del self.data.browsers[browserID] 188 | self.dirty = True 189 | 190 | await self.updated() 191 | -------------------------------------------------------------------------------- /documentation/configuration-panel.md: -------------------------------------------------------------------------------- 1 | # The Browser Mod Configuration Panel 2 | 3 | ## This browser 4 | 5 | The most important concept for Browser Mod is the _Browser_. A _Browser_ is identified by a unique `BrowserID` stored in the browsers [LocalStorage](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API). 6 | 7 | Browser Mod will initially assigning a random `BrowserID` to each _Browser_ that connects, but you can change this if you want. 8 | 9 | LocalStorage works basically like cookies in that the information is stored locally on your device. Unlike a cookie, though, the information is bound to a URL. Therefore you may get different `BrowserID`s in the same browser if you e.g. access Home Assistant through different URLs inside and outside of your LAN, or through Home Assistant Cloud. 10 | 11 | ### Register 12 | 13 | Registering a _Browser_ as a device will create a Home Assistant Device associated with that browser. The device has the following entities: 14 | 15 | - A `media_player` entitiy which will play sound and video through the browser. 16 | - A `light` entity will turn the screen on or off and controll the brightness if you are using [Fully Kiosk Browser](https://www.fully-kiosk.com/) (FKB). If you are not using FKB the function will be simulated by covering the screen with a black (or semitransparent) box. There is a [Frontend Setting](#frontend-settings-admin-only) to optionally save the browser screen state for a browser. 17 | - A motion `binary_sensor` which reacts to mouse and/or keyboard activity in the Browser. In FKB this can also react to motion in front of the devices camera. 18 | - A number of `sensor` and `binary_sensor` entities providing different bits of information about the Browser which you may or may not find useful. 19 | 20 | Registering a browser also enables it to act as a target for Browser Mod _services_. 21 | 22 | ### Browser ID 23 | 24 | This box lets you set the `BrowserID` for the current _Browser_. 25 | Note that it is possible to assign the same `BrowserID` to several browsers, but unpredictable things _may_ happen if several of them are open at the same time. 26 | There may be benefits to using the same `BrowserID` in some cases, so you'll have to experiment with what works for you. 27 | 28 | Browser Mod is trying hard to keep the Browser ID constant 29 | 30 | ### Enable camera 31 | 32 | If your device has a camera, this will allow it to be forwarded as a `camera` entity to Home Assistant. 33 | 34 | ## Registered Browsers (admin only) 35 | 36 | This section shows all currently registered _Browsers_ and allows you to unregister them. This is useful e.g. if a `BrowserID` has changed or if you do not have access to a device anymore. 37 | 38 | You can also lock browsers so they cannot be unregistered by a non-admin user. 39 | 40 | ### Register CAST browser 41 | 42 | If you are using [Home Assistant Cast](https://www.home-assistant.io/integrations/cast/#home-assistant-cast) to display a lovelace view on a Chromecast device it will get a BrowserID of "`CAST`". Since you can't access the Browser Mod config panel from the device, clicking this button will register the `CAST` browser. Most Browser Mod services will work under Home Assistant Cast. 43 | 44 | ## Frontend Settings (admin only) 45 | 46 | This section is for settings that change the default behavior of the Home Assistant frontend. 47 | 48 | For each option the first applicable value will be applied. 49 | 50 | In the screenshot below, for example, the sidebar title would be set to "My home" - the GLOBAL setting - for any user on any browser (even unregistered). For any user logged in on the "kitchen-dashboard" browser, the sidebar title would instead be set to "FOOD", except for the user "dev" for whom the sidebar title would always be "DEV MODE". 51 | ![Example of a frontend setting being applied for a user, a browser and globally](https://user-images.githubusercontent.com/1299821/187984798-04e72fff-7cce-4394-ba69-42e62c5e0acb.png) 52 | 53 | ### Title template 54 | 55 | This allows you to set and dynamically update the title text of the browser tab/window by means on a Jinja [template](https://www.home-assistant.io/docs/configuration/templating/). 56 | 57 | > Ex: 58 | > 59 | > ```jinja 60 | > {{ states.persistent_notification | list | count}} - Home Assistant 61 | > ``` 62 | 63 | ### Favicon template 64 | 65 | This allows you to set and dynamically update the favicon of the browser tab/window. I.e. the little icon next to the page title. Favicons can be .png or .ico files and should be placed in your `/www` directory. The box here should then contain a jinja [template](https://www.home-assistant.io/docs/configuration/templating/) which resolves to the path of the icon with `/www/` replaced by `/local/` (see [Hosting files](https://www.home-assistant.io/integrations/http/#hosting-files)). 66 | 67 | > Ex: 68 | > 69 | > ```jinja 70 | > {% if is_state("light.bed_light", "on") %} 71 | > /local/icons/green.png 72 | > {% else %} 73 | > /local/icons/red.png 74 | > {% endif %} 75 | > ``` 76 | 77 | Note that this _only_ applies to the current favicon of the page, not any manifest icons such as the loading icon or the icon you get if you save the page to your smartphones homescreen. For those, please see the [hass-favicon](https://github.com/thomasloven/hass-favicon) custom integration. 78 | 79 | 80 | ### Hide Sidebar 81 | 82 | This will hide the sidebar wit the navigation links. You can still access all the pages via normal links. 83 | 84 | > Tip: add `/browser-mod` to the end of your home assistant URL when you need to turn this off again... 85 | 86 | ### Hide header 87 | 88 | This will hide the header bar. Completely. It does not care if there are useful navigation links there or not. It's gone. 89 | 90 | > Tip: See the big yellow warning box at the top of this card? For some reason, it seems to be really easy to forget you turned this on. Please do not bother the Home Assistant team about the header bar missing if you have hidden it yourself. Really, I've forgotten multiple times myself. 91 | 92 | ### Default dashboard 93 | 94 | Set the default dashboard that is shown when you access `https:///` with nothing after the `/`. 95 | 96 | > *Note:* 97 | >1. This option sets the same local setting as Home Assistants' Dashboard setting in User Settings. If this setting does not provide exactly what you are after you may wish to use a Default action with `browser_mod.navigate`. 98 | >2. This also of works with other pages than lovelace dashboards, like e.g. `logbook` or even `history?device_id=f112fd806f2520c76318406f98cd244e&start_date=2022-09-02T16%3A00%3A00.000Z&end_date=2022-09-02T19%3A00%3A00.000Z`. 99 | 100 | ### Default action 101 | 102 | Set the default action to be run when Browser is loaded or refreshed. This setting accepts the same action Config as per `browser_mod.popup` actions. For more information see the examples actions included in [Actionable popups](./popups.md#actionable-popups). If you are using Browser Mod [SERVICES](./services.md), in most cases you would omit `browser_id` or `user_id` so the service runs on the loading Browser. 103 | 104 | __IMPORTANT__: Like actions popups and notifications, this setting DOES NOT support templates. 105 | 106 | *Tips:* 107 | 1. Multiple actions can be called by using a list of actions. These are called in parallel, matching actions for popups and notifications. 108 | ```yaml 109 | - action: browser_mod.navigate 110 | data: 111 | path: /my-dashboard/second-view 112 | - action: browser_mod.notification 113 | data: 114 | message: Good Morning Dave 115 | ``` 116 | 2. For fine grained control of timing, consider using `browser_mod.sequence`. Note here that only one top level action is used. 117 | ```yaml 118 | action: browser_mod.sequence 119 | data: 120 | sequence: 121 | - service: browser_mod.navigate 122 | data: 123 | path: /my-dashboard/second-view 124 | - service: browser_mod.delay 125 | data: 126 | time: 5000 127 | - service: browser_mod.notification 128 | data: 129 | message: Good Morning Dave 130 | ``` 131 | 132 | ### Sidebar order 133 | 134 | #### Home Assistant 2025.6 and onwards 135 | 136 | Home Assistant 2025.6 introduced a sidebar settings dialog to replace the in place edit mode. Browser Mod uses this dialog for editing sdeibar order and visibility. 137 | 138 | __IMPORTANT__: Home Assistant will store sidebar settings in the Home Assistant user profile. This will prevent Browser and Global sidebar settings from being applied. If a user has sidebar settings synced to their user profile, a warning will be display with an option to clear the synced settings to allow Browser Mod to take precedence. 139 | 140 | #### Home Assistant prior to 2025.6 141 | 142 | Set the order and hidden items of the sidebar. To change this setting: 143 | 144 | - Click the "EDIT" button 145 | - Change the sidebar to how you want it 146 | - DO NOT click "DONE" 147 | - Either add a new User or Browser setting or click one of the pencil icons to overwrite an old layout 148 | - Click the "RESTORE" button 149 | 150 | ### Sidebar title 151 | 152 | This changes the "Home Assistant" text that is displayed at the top of the sidebar. 153 | Accepts Jinja [templates](https://www.home-assistant.io/docs/configuration/templating/). 154 | 155 | ### Hide interaction icon 156 | 157 | This hides the icon in the bottom right corner which indicates that you need to interact with the browser window before Browser Mod will function completely. 158 | 159 | ### Save screen state 160 | 161 | This saves the screen state on browser disconnect and restores on browser reconnect. The screen state (on/off) and brightness are both saved. The state will be saved and restored for all browsers that have this setting applied, including those running Fully Kiosk. -------------------------------------------------------------------------------- /documentation/popups.md: -------------------------------------------------------------------------------- 1 | 2 | ## Anatomy of a popup 3 | 4 | ```yaml 5 | service: browser_mod.popup 6 | data: 7 | title: The title 8 | content: The content 9 | right_button: Right button 10 | left_button: Left button 11 | ``` 12 | 13 | ![Screenshot illustrating the title, content and button placements of a popup](https://user-images.githubusercontent.com/1299821/182708739-f89e3b2b-199f-43e0-bf04-e1dfc7075b2a.png) 14 | 15 | ## Size 16 | 17 | The `size` parameter can be set to `normal`, `classic`, `wide` and `fullscreen` with results as below (background blur has been exagerated for clarity): 18 | 19 | ![Screenshot of a normal size popup](https://user-images.githubusercontent.com/1299821/182709146-439814f1-d479-4fc7-aab1-e28f5c9a13c7.png) 20 | 21 | ![Screenshot of effect of classic size popup on small device](https://github.com/user-attachments/assets/926646dd-f254-44ed-b94c-f63dcc5a335c) 22 | 23 | ![Screenshot of a wide size popup](https://user-images.githubusercontent.com/1299821/182709172-c98a9c23-5e58-4564-bcb7-1d187842948f.png) 24 | 25 | ![Screenshot of a fullscreen size popup](https://user-images.githubusercontent.com/1299821/182709224-fb2e7b92-8a23-4422-95a0-f0f2835909e0.png) 26 | 27 | 28 | ## HTML content 29 | 30 | ```yaml 31 | service: browser_mod.popup 32 | data: 33 | title: HTML content 34 | content: | 35 | An HTML string. 36 |

Pretty much any HTML works: 37 | ``` 38 | 39 | ![Screenshot of a popup rendering the HTML code above](https://user-images.githubusercontent.com/1299821/182710044-6fea3ba3-5262-4361-a131-691770340518.png) 40 | 41 | ## Dashboard card content 42 | 43 | ```yaml 44 | service: browser_mod.popup 45 | data: 46 | title: HTML content 47 | content: 48 | type: entities 49 | entities: 50 | - light.bed_light 51 | - light.ceiling_lights 52 | - light.kitchen_lights 53 | ``` 54 | 55 | ![Screenshot of a popup rendering the entities card described above](https://user-images.githubusercontent.com/1299821/182710445-f09b74b8-dd53-4d65-8eba-0945fc1d418e.png) 56 | 57 | Note that some elements of some card expect the card to have a static position on the bottom of the page, but the popup will make it float above everything else. This means things like pull-down menues or some overlays may just not work right or at all. There's unfortunately nothing that can be done about this. 58 | 59 | ## Form content 60 | `content` can be a list of ha-form schemas and the popup will then contain a form for user input: 61 | 62 | ``` 63 | : 64 | name: 65 | [label: ] 66 | [default: ] 67 | selector: 68 | ``` 69 | 70 | | | | 71 | |-|-| 72 | | `name` | A unique parameter name | 73 | | `label` | A description of the parameter | 74 | | `default` | The default value for the parameter | 75 | | `selector` | A [Home Assistant selector](https://www.home-assistant.io/docs/blueprint/selectors) | 76 | 77 | The data from the form will be forwarded as data for any `right_button_action` or `left_button_action` of the popup. 78 | 79 | ```yaml 80 | service: browser_mod.popup 81 | data: 82 | title: Form content 83 | content: 84 | - name: parameter_name 85 | label: Descriptive name 86 | selector: 87 | text: null 88 | - name: another_parameter 89 | label: A number 90 | default: 5 91 | selector: 92 | number: 93 | min: 0 94 | max: 10 95 | slider: true 96 | ``` 97 | 98 | ![Screenshot of a popup rendering the form described above](https://user-images.githubusercontent.com/1299821/182712670-f3b4fdb7-84a9-49d1-a26f-2cdaa450fa0e.png) 99 | 100 | **NOTE:** Some Home Assistant selectors may use another dialog for input. [Date](https://www.home-assistant.io/docs/blueprint/selectors/#date-selector) and [Date & Time](https://www.home-assistant.io/docs/blueprint/selectors/#date--time-selector) use a selector dialog for date & time. Browser Mod popups are not in the Home Assistant DOM hierachy so stacking will need to be adjusted by [styling](#styling-popups) the popup with th styles shown below. The Home Assistant top bar has a z-index of 4, so using a z-index of 5 will work in most cases, but your setup may vary so adjust to suit. 101 | 102 | ``` 103 | z-index: 5; 104 | position: absolute; 105 | ``` 106 | 107 | ## Actionable popups 108 | 109 | Example of a popup with actions opening more popups or calling Home Assistant services: 110 | 111 | ```yaml 112 | service: browser_mod.popup 113 | data: 114 | content: Do you want to turn the light on? 115 | right_button: "Yes" 116 | left_button: "No" 117 | right_button_action: 118 | service: light.turn_on 119 | data: 120 | entity_id: light.bed_light 121 | left_button_action: 122 | service: browser_mod.popup 123 | data: 124 | title: Really? 125 | content: Are you sure? 126 | right_button: "Yes" 127 | left_button: "No" 128 | right_button_action: 129 | service: browser_mod.popup 130 | data: 131 | content: Fine, live in darkness. 132 | dismissable: false 133 | title: Ok 134 | timeout: 3000 135 | left_button_action: 136 | service: light.turn_on 137 | data: 138 | entity_id: light.bed_light 139 | ``` 140 | 141 | ![Animated screenshot of a popup which opens other popups when one of the action buttons are pressed](https://user-images.githubusercontent.com/1299821/182713421-708d0026-bcfa-4ba6-bbcd-3b85b584162d.gif) 142 | 143 | ## Forward form data 144 | 145 | The following popup would ask the user for a list of rooms to vacuum and then populate the `params` parameter of the `vacuum.send_command` service call from the result: 146 | 147 | ```yaml 148 | service: browser_mod.popup 149 | data: 150 | title: Where to vacuum? 151 | right_button: Go! 152 | right_button_action: 153 | service: vacuum.send_command 154 | data: 155 | entity_id: vacuum.xiaomi 156 | command: app_segment_clean 157 | content: 158 | - name: params 159 | label: Rooms to clean 160 | selector: 161 | select: 162 | multiple: true 163 | options: 164 | - label: Kitchen 165 | value: 11 166 | - label: Living room 167 | value: 13 168 | - label: Bedroom 169 | value: 12 170 | ``` 171 | 172 | ![Screenshot of a popup allowing the user to choose which rooms to vacuum](https://user-images.githubusercontent.com/1299821/182713714-ef4149b1-217a-4d41-9737-714f5320c25c.png) 173 | 174 | 175 | ## Styling popups 176 | 177 | The default value for the `style` parameter is as follows: 178 | 179 | ```yaml 180 | style: | 181 | --popup-min-width: 400px; 182 | --popup-max-width: 600px; 183 | --popup-border-radius: 28px; 184 | ``` 185 | 186 | The same variables can also be set by a theme. 187 | 188 | Those variables should be enough for mostly everything, really. Try it. 189 | 190 | Otherwise, [card-mod](https://github.com/thomasloven/lovelace-card-mod) can also be used to style popups by adding a `card_mod:` parameter to the service call: 191 | 192 | ```yaml 193 | service: browser_mod.popup 194 | data: 195 | title: Teal background 196 | content: Where did the dashboard go? 197 | card_mod: 198 | style: 199 | ha-dialog$: | 200 | div.mdc-dialog div.mdc-dialog__scrim { 201 | background: rgba(0, 128, 128, 0.9); 202 | } 203 | ``` 204 | Or through `card-mod-more-info` or `card-mod-more-info-yaml` in a card-mod theme. 205 | 206 | ## Nested popups 207 | 208 | Except for standard Home Assistant more-info dialogs, nested popups are not supported. Home Assistant more-info dialogs are allowed nested by defatult. To control this option, use the `allow_nested_more_info` paramater of `browser_mod.popup` or custom popup card. 209 | 210 | __NOTE__: If a custom popup card is is in use on the dashboard, the custom popup card is never nested. If you wish to have a nested more-info dialog in this case, use `browser_mod.more_info` with `ignore_popup_card` set to `true` to nest a standard more-info dialog. -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "browser_mod", 3 | "homeassistant": "2025.5.0" 4 | } 5 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | browser\_mod 2 | ============ 3 | 4 | A Home Assistant integration to turn your browser into a controllable entity - and also an audio player. 5 | 6 | ## Example uses 7 | 8 | - Make the camera feed from your front door pop up on the tablet in your kitchen when someone rings the doorbell. 9 | - Have a message pop up on every screen in the house when it's bedtime. 10 | - Make the browser on your workstation switch to a specific tab when the kitchen light is on after midnight 11 | - Play a TTS message on your work computer when the traffic sensor tells you it's time to go home. 12 | - Make the screen on your tablet go black during the night, but wake up when you touch it. 13 | 14 | ### See [README](https://github.com/thomasloven/hass-browser_mod/blob/master/README.md) for usage instructions 15 | 16 | --- 17 | 18 | ![popup-example](https://user-images.githubusercontent.com/1299821/60288984-a7cb6b00-9915-11e9-9322-324323a9ec6e.png) 19 | ![browser-player](https://user-images.githubusercontent.com/1299821/60288980-a4d07a80-9915-11e9-88ba-e078a3aa24f4.png) 20 | 21 | -------------------------------------------------------------------------------- /js/config_panel/browser-settings-card.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from "lit"; 2 | import { property, state } from "lit/decorators.js"; 3 | 4 | class BrowserModRegisteredBrowsersCard extends LitElement { 5 | @property() hass; 6 | @property() dirty = false; 7 | 8 | toggleRegister() { 9 | if (!window.browser_mod?.ready) return; 10 | window.browser_mod.registered = !window.browser_mod.registered; 11 | this.dirty = true; 12 | } 13 | changeBrowserID(ev) { 14 | window.browser_mod.browserID = ev.target.value; 15 | this.dirty = true; 16 | } 17 | toggleCameraEnabled() { 18 | window.browser_mod.cameraEnabled = !window.browser_mod.cameraEnabled; 19 | this.dirty = true; 20 | } 21 | 22 | firstUpdated() { 23 | window.browser_mod.addEventListener("browser-mod-config-update", () => 24 | this.requestUpdate() 25 | ); 26 | } 27 | 28 | render() { 29 | return html` 30 | 31 |

32 |
This Browser
33 | ${window.browser_mod?.ready 34 | ? html` 35 | 40 | ` 41 | : html` 42 | 47 | `} 48 |

49 |
50 | ${this.dirty 51 | ? html` 52 | 53 | It is strongly recommended to refresh your browser window 54 | after changing any of the settings in this box. 55 | 56 | ` 57 | : ""} 58 |
59 |
60 | 61 | Register 62 | Enable this browser as a Device in Home Assistant 65 | 72 | 73 | 74 | 75 | Browser ID 76 | A unique identifier for this browser-device combination. 79 | 84 | 85 | 86 | ${window.browser_mod?.registered 87 | ? html` 88 | ${this._renderSuspensionAlert()} 89 | 90 | Enable camera 91 | Get camera input from this browser (hardware 93 | dependent) 95 | 100 | 101 | ${window.browser_mod?.cameraError 102 | ? html` 103 | 104 | Setting up the device camera failed. Make sure you have 105 | allowed use of the camera in your browser. 106 | 107 | ` 108 | : ""} 109 | ${this._renderInteractionAlert()} 110 | ${this._renderFKBSettingsInfo()} 111 | ` 112 | : ""} 113 |
114 | 115 | `; 116 | } 117 | 118 | private _renderSuspensionAlert() { 119 | if (!this.hass.suspendWhenHidden) return html``; 120 | return html` 121 | 122 | Home Assistant will close the websocket connection to the server 123 | automatically after 5 minutes of inactivity.

124 | While decreasing network trafic and memory usage, this may cause 125 | problems for browser_mod operation. 126 |

127 | If you find that some things stop working for this Browser after a time, 128 | try going to your 129 | Profile Settings 134 | and disabling the option 135 | "${this.hass.localize("ui.panel.profile.suspend.header") || 136 | "Automatically close connection"}". 137 |
138 | `; 139 | } 140 | 141 | private _renderInteractionAlert() { 142 | return html` 143 | 144 | For privacy reasons many browsers require the user to interact with a 145 | webpage before allowing audio playback or video capture. This may affect 146 | the 147 | media_player and camera components of Browser 148 | Mod.

149 | 150 | If you ever see a 151 | 155 | symbol at the bottom right corner of the screen, please tap or click 156 | anywhere on the page. This should allow Browser Mod to work again. 157 |
158 | `; 159 | } 160 | 161 | private _renderFKBSettingsInfo() { 162 | if (!window.browser_mod?.fully || !this.getFullySettings()) return html``; 163 | return html` 164 | ${window.browser_mod?.fully && this.getFullySettings() 165 | ? html` 166 | You are using FullyKiosk Browser. It is recommended to enable the 167 | following settings: 168 |
    169 | ${this.getFullySettings()} 170 |
171 |
` 172 | : ""} 173 | `; 174 | } 175 | 176 | private getFullySettings() { 177 | if (!window.browser_mod.fully) return null; 178 | const retval = []; 179 | const wcs = []; 180 | // Web Content Settings 181 | // Autoplay Videos 182 | if (window.fully.getBooleanSetting("autoplayVideos") !== "true") 183 | wcs.push(html`
  • Autoplay Videos
  • `); 184 | // Autoplay Audio 185 | if (window.fully.getBooleanSetting("autoplayAudio") !== "true") 186 | wcs.push(html`
  • Autoplay Audio
  • `); 187 | // Enable Webcam Access (PLUS) 188 | if (window.fully.getBooleanSetting("webcamAccess") !== "true") 189 | wcs.push(html`
  • Enable Webcam Access (PLUS)
  • `); 190 | 191 | if (wcs.length !== 0) { 192 | retval.push(html`
  • Web Content Settings
  • 193 |
      194 | ${wcs} 195 |
    `); 196 | } 197 | 198 | // Advanced Web Settings 199 | // Enable JavaScript Interface (PLUS) 200 | if (window.fully.getBooleanSetting("websiteIntegration") !== "true") 201 | retval.push(html`
  • Advanced Web Settings
  • 202 |
      203 |
    • Enable JavaScript Interface (PLUS)
    • 204 |
    `); 205 | 206 | // Device Management 207 | // Keep Screen On 208 | if (window.fully.getBooleanSetting("keepScreenOn") !== "true") 209 | retval.push(html`
  • Device Management
  • 210 |
      211 |
    • Keep Screen On
    • 212 |
    `); 213 | 214 | // Power Settings 215 | // Prevent from Sleep while Screen Off 216 | if (window.fully.getBooleanSetting("preventSleepWhileScreenOff") !== "true") 217 | retval.push(html`
  • Power Settings
  • 218 |
      219 |
    • Prevent from Sleep while Screen Off
    • 220 |
    `); 221 | 222 | const md = []; 223 | // Motion Detection (PLUS) 224 | // Enable Visual Motion Detection 225 | if (window.fully.getBooleanSetting("motionDetection") !== "true") 226 | md.push(html`
  • Enable Visual Motion Detection
  • `); 227 | // Turn Screen On on Motion 228 | if (window.fully.getBooleanSetting("screenOnOnMotion") !== "true") 229 | md.push(html`
  • Turn Screen On on Motion
  • `); 230 | // Exit Screensaver on Motion 231 | if (window.fully.getBooleanSetting("stopScreensaverOnMotion") !== "true") 232 | md.push(html`
  • Exit Screensaver on Motion
  • `); 233 | 234 | if (md.length !== 0) { 235 | retval.push(html`
  • Motion Detection (PLUS)
  • 236 |
      237 | ${md} 238 |
    `); 239 | } 240 | 241 | // Remote Administration (PLUS) 242 | // Enable Remote Administration 243 | if (window.fully.getBooleanSetting("remoteAdmin") !== "true") 244 | retval.push(html`
  • Remote Administration (PLUS)
  • 245 |
      246 |
    • Enable Remote Administration
    • 247 |
    `); 248 | 249 | return retval.length ? retval : null; 250 | } 251 | 252 | static get styles() { 253 | return css` 254 | .card-header { 255 | display: flex; 256 | justify-content: space-between; 257 | } 258 | ha-textfield { 259 | width: 250px; 260 | display: block; 261 | margin-top: 8px; 262 | } 263 | `; 264 | } 265 | } 266 | customElements.define( 267 | "browser-mod-browser-settings-card", 268 | BrowserModRegisteredBrowsersCard 269 | ); 270 | -------------------------------------------------------------------------------- /js/config_panel/main.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from "lit"; 2 | import { property } from "lit/decorators.js"; 3 | import { loadConfigDashboard } from "../helpers"; 4 | 5 | import "./browser-settings-card"; 6 | import "./registered-browsers-card"; 7 | import "./frontend-settings-card"; 8 | 9 | import pjson from "../../package.json"; 10 | 11 | const bmWindow = window as any; 12 | 13 | loadConfigDashboard().then(() => { 14 | class BrowserModPanel extends LitElement { 15 | @property() hass; 16 | @property() narrow; 17 | @property() connection; 18 | 19 | firstUpdated() { 20 | window.addEventListener("browser-mod-config-update", () => 21 | this.requestUpdate() 22 | ); 23 | } 24 | 25 | render() { 26 | if (!window.browser_mod) return html``; 27 | return html` 28 | 29 | 34 |
    Browser Mod Settings
    35 |
    36 | (${pjson.version}) 37 | 41 | 42 | 43 |
    44 | 45 | 46 | 49 | 50 | ${this.hass.user?.is_admin 51 | ? html` 52 | 55 | 56 | 59 | ` 60 | : ""} 61 | 62 |
    63 | `; 64 | } 65 | 66 | static get styles() { 67 | return [ 68 | ...((customElements.get("ha-config-dashboard") as any)?.styles ?? []), 69 | css` 70 | :host { 71 | --app-header-background-color: var(--sidebar-background-color); 72 | --app-header-text-color: var(--sidebar-text-color); 73 | --app-header-border-bottom: 1px solid var(--divider-color); 74 | --ha-card-border-radius: var(--ha-config-card-border-radius, 8px); 75 | } 76 | ha-config-section { 77 | padding: 16px 0; 78 | direction: ltr; 79 | } 80 | a { 81 | color: var(--primary-text-color); 82 | text-decoration: none; 83 | } 84 | `, 85 | ]; 86 | } 87 | } 88 | 89 | customElements.define("browser-mod-panel", BrowserModPanel); 90 | }); 91 | -------------------------------------------------------------------------------- /js/config_panel/registered-browsers-card.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from "lit"; 2 | import { property, state } from "lit/decorators.js"; 3 | 4 | class BrowserModRegisteredBrowsersCard extends LitElement { 5 | @property() hass; 6 | 7 | @property() _entity_registry?: any[]; 8 | 9 | firstUpdated() { 10 | window.browser_mod.addEventListener("browser-mod-config-update", () => 11 | this.requestUpdate() 12 | ); 13 | this._fetch_entity_registry(); 14 | } 15 | 16 | async _fetch_entity_registry() { 17 | if (this._entity_registry) return; 18 | 19 | this._entity_registry = await this.hass.callWS({ 20 | type: "config/device_registry/list", 21 | }); 22 | } 23 | 24 | _find_entity(browserID) { 25 | if (!this._entity_registry) return undefined; 26 | return this._entity_registry.find( 27 | (v) => 28 | JSON.stringify(v?.identifiers?.[0]) === 29 | JSON.stringify(["browser_mod", browserID]) 30 | ); 31 | } 32 | 33 | unregister_browser(ev) { 34 | const browserID = ev.currentTarget.browserID; 35 | 36 | const unregisterCallback = () => { 37 | if (browserID === window.browser_mod.browserID) { 38 | window.browser_mod.registered = false; 39 | } else { 40 | window.browser_mod.connection.sendMessage({ 41 | type: "browser_mod/unregister", 42 | browserID, 43 | }); 44 | } 45 | }; 46 | 47 | window.browser_mod.showPopup( 48 | "Unregister browser", 49 | `Are you sure you want to unregister Browser ${browserID}?`, 50 | { 51 | right_button: "Yes", 52 | right_button_action: unregisterCallback, 53 | left_button: "No", 54 | } 55 | ); 56 | } 57 | 58 | toggle_lock_browser(ev) { 59 | const browserID = ev.currentTarget.browserID; 60 | const browser = window.browser_mod.browsers[browserID]; 61 | window.browser_mod.connection.sendMessage({ 62 | type: "browser_mod/register", 63 | browserID, 64 | data: { 65 | ...browser, 66 | locked: !browser.locked, 67 | }, 68 | }); 69 | } 70 | 71 | toggle_auto_register(ev) { 72 | if (window.browser_mod?.global_settings["autoRegister"]) 73 | window.browser_mod.setSetting("global", null, { 74 | autoRegister: undefined, 75 | }); 76 | else window.browser_mod.setSetting("global", null, { autoRegister: true }); 77 | } 78 | toggle_lock_register(ev) { 79 | if (window.browser_mod?.global_settings["lockRegister"]) 80 | window.browser_mod.setSetting("global", null, { 81 | lockRegister: undefined, 82 | }); 83 | else 84 | window.browser_mod.setSetting("global", null, { 85 | lockRegister: true, 86 | autoRegister: undefined, 87 | }); 88 | } 89 | 90 | register_cast() { 91 | window.browser_mod.connection.sendMessage({ 92 | type: "browser_mod/register", 93 | browserID: "CAST", 94 | }); 95 | } 96 | 97 | render() { 98 | return html` 99 | 100 |
    101 | 102 | Auto-register 103 | 104 | Automatically register all new Browsers 105 | 106 | 111 | 112 | 113 | Lock register 114 | 115 | Disable registering or unregistering of all Browsers 116 | 117 | 122 | 123 | 124 | ${Object.keys(window.browser_mod.browsers).map((d) => { 125 | const browser = window.browser_mod.browsers[d]; 126 | const device = this._find_entity(d); 127 | return html` 128 | 129 | ${d} ${device?.name_by_user ? `(${device.name_by_user})` : ""} 130 | 131 | 132 | Last connected: 133 | 137 | 138 | ${device 139 | ? html` 140 | 141 | 142 | 143 | 144 | 145 | ` 146 | : ""} 147 | 151 | 154 | 155 | 156 | 157 | 158 | `; 159 | })} 160 |
    161 | ${window.browser_mod.browsers["CAST"] === undefined 162 | ? html` 163 |
    164 | 165 | Register CAST Browser 166 | 167 |
    168 | ` 169 | : ""} 170 |
    171 | `; 172 | } 173 | 174 | static get styles() { 175 | return css` 176 | ha-icon-button > * { 177 | display: flex; 178 | color: var(--primary-text-color); 179 | } 180 | `; 181 | } 182 | } 183 | customElements.define( 184 | "browser-mod-registered-browsers-card", 185 | BrowserModRegisteredBrowsersCard 186 | ); 187 | -------------------------------------------------------------------------------- /js/config_panel/sidebar-settings-custom-selector.ts: -------------------------------------------------------------------------------- 1 | import { selectTree, provideHass, hass_base_el } from "../helpers"; 2 | 3 | export class SidebarSettingsCustomSelector { 4 | _dialogAvaliable: boolean; 5 | _dialogEditSidebar: any; 6 | _type: string; 7 | _target: string; 8 | _allUsers: Array; 9 | 10 | constructor() { 11 | if (customElements.get("dialog-edit-sidebar")) { 12 | this._dialogAvaliable = true; 13 | return; 14 | } 15 | this._dialogAvaliable = false; 16 | selectTree( 17 | document.body, 18 | "home-assistant $ home-assistant-main $ ha-drawer ha-sidebar" 19 | ).then((sidebar) => { 20 | // Home Assistant 2025.6 removed editMode from sidebar 21 | // so this is the best check to see if sidebar settings dialog is available 22 | if (sidebar && sidebar.editMode === undefined) { 23 | const menu = sidebar.shadowRoot.querySelector("div.menu"); 24 | if (menu) { 25 | // Simulate hold press on the menu to open the sidebar settings dialog. 26 | // Listen once and stop propagation of the show-dialog event 27 | // so the dialogImport can be called to make available 28 | // An event method would be nice HA Team! 29 | sidebar.addEventListener("show-dialog", (ev) => { 30 | if (ev.detail?.dialogTag === "dialog-edit-sidebar") { 31 | ev.stopPropagation(); 32 | ev.detail?.dialogImport?.(); 33 | } 34 | }, {once: true}); 35 | menu.dispatchEvent(new CustomEvent("action", { detail: { action: "hold" } })); 36 | } 37 | } 38 | }); 39 | customElements.whenDefined("dialog-edit-sidebar").then(() => { 40 | this._dialogAvaliable = true; 41 | }); 42 | } 43 | 44 | get dialogAvaliable() { 45 | return this._dialogAvaliable; 46 | } 47 | 48 | get order() { 49 | const sidebarPanelOrder = window.browser_mod?.getSetting?.('sidebarPanelOrder'); 50 | const order = 51 | (this._type === "global" ? sidebarPanelOrder.global || '[]' : sidebarPanelOrder[this._type][this._target] || '[]'); 52 | return order; 53 | } 54 | 55 | get hidden() { 56 | const sidebarHiddenPanels = window.browser_mod?.getSetting?.('sidebarHiddenPanels'); 57 | const hidden = 58 | (this._type === "global" ? sidebarHiddenPanels.global || '[]': sidebarHiddenPanels[this._type][this._target] || '[]'); 59 | return hidden; 60 | } 61 | 62 | async setupDialog() { 63 | if (!this._dialogAvaliable) return; 64 | this._dialogEditSidebar = document.createElement("dialog-edit-sidebar"); 65 | const base = await hass_base_el(); 66 | if (base && this._dialogEditSidebar) { 67 | await provideHass(this._dialogEditSidebar); 68 | this._dialogEditSidebar._order = JSON.parse(this.order); 69 | this._dialogEditSidebar._hidden = JSON.parse(this.hidden); 70 | base.shadowRoot.appendChild(this._dialogEditSidebar); 71 | this._dialogEditSidebar._open = true; 72 | this._dialogEditSidebar.focus(); 73 | window.addEventListener("popstate", async (ev) => { 74 | const sidebarSettingsCustomSelectorState = ev.state?.sidebarSettingsCustomSelector; 75 | if (sidebarSettingsCustomSelectorState) { 76 | if (!sidebarSettingsCustomSelectorState.open) { 77 | if (this._dialogEditSidebar?._open) 78 | await this._dialogEditSidebar.closeDialog(); 79 | } 80 | } 81 | }); 82 | if (history.state?.sidebarSettingsCustomSelector === undefined) { 83 | history.replaceState( 84 | { 85 | sidebarSettingsCustomSelector: { 86 | open: false, 87 | }, 88 | }, 89 | "" 90 | ); 91 | } 92 | history.pushState( 93 | { 94 | sidebarSettingsCustomSelector: { 95 | open: true, 96 | }, 97 | }, 98 | "" 99 | ); 100 | this._dialogEditSidebar.addEventListener("dialog-closed", (ev) => { 101 | if (ev.detail?.dialog == "dialog-edit-sidebar" && this._dialogEditSidebar) { 102 | this._dialogEditSidebar.remove(); 103 | this._dialogEditSidebar = undefined; 104 | } 105 | }); 106 | } 107 | } 108 | 109 | async customiseDialog() { 110 | if (!this._dialogEditSidebar) return; 111 | let haMdDialog; 112 | let cnt = 0; 113 | while (!haMdDialog && cnt++ < 5) { 114 | haMdDialog = this._dialogEditSidebar.shadowRoot.querySelector("ha-md-dialog"); 115 | if (!haMdDialog) { 116 | await new Promise((resolve) => setTimeout(resolve, 500)); 117 | } 118 | } 119 | const dialogHeader = await selectTree( 120 | this._dialogEditSidebar.shadowRoot, 121 | "ha-md-dialog ha-dialog-header", 122 | ); 123 | if (dialogHeader) { 124 | const styleEl = document.createElement("style"); 125 | dialogHeader.shadowRoot.append(styleEl); 126 | const typeText = (this._type === "global") ? "Global" : this._type.charAt(0).toUpperCase() + this._type.slice(1) + " - "; 127 | let targetText = ""; 128 | if (this._type === "user") { 129 | for (const user of this._allUsers) { 130 | if (user.id === this._target) { 131 | targetText = user.name; 132 | break; 133 | } 134 | } 135 | } else { 136 | targetText = this._target ?? ""; 137 | } 138 | // Hide subtitle message about sync 139 | // Append Browser Mod details using ::after CSS styling 140 | styleEl.innerHTML = ` 141 | .header-subtitle { 142 | display: none; 143 | } 144 | .header-title::after { 145 | content: "- ${typeText}${targetText}"; 146 | } 147 | `; 148 | } 149 | } 150 | 151 | async setupSaveHandler() { 152 | if (!this._dialogEditSidebar) return; 153 | const haButtonSave = this._dialogEditSidebar.shadowRoot.querySelector( 154 | '[slot="actions"] > ha-button:nth-child(2)'); 155 | if (haButtonSave) { 156 | const buttonSave = haButtonSave.shadowRoot.querySelector("button"); 157 | if (buttonSave) { 158 | buttonSave.addEventListener("click", (ev) => { 159 | ev.stopImmediatePropagation(); 160 | ev.stopPropagation(); 161 | ev.preventDefault(); 162 | this._dialogEditSidebar.dispatchEvent(new CustomEvent("sidebar-settings-save")); 163 | }); 164 | } 165 | } 166 | } 167 | 168 | async saveSettings() { 169 | if (!this._dialogEditSidebar) return; 170 | 171 | const order = this._dialogEditSidebar._order; 172 | const hidden = this._dialogEditSidebar._hidden; 173 | 174 | window.browser_mod.setSetting(this._type, this._target, { 175 | sidebarHiddenPanels: JSON.stringify(hidden), 176 | sidebarPanelOrder: JSON.stringify(order), 177 | }); 178 | 179 | this._dialogEditSidebar.closeDialog(); 180 | } 181 | 182 | async changeSetting(type, target, allUsers) { 183 | if (!this.dialogAvaliable) { 184 | window.browser_mod?.showPopup?.( 185 | "ERROR!", 186 | "Sidebar settings dialog unavailable.", 187 | { 188 | right_button: "OK", 189 | } 190 | ); 191 | return; 192 | } 193 | this._type = type; 194 | this._target = target; 195 | this._allUsers = allUsers; 196 | 197 | await this.setupDialog(); 198 | await this.customiseDialog(); 199 | await this.setupSaveHandler(); 200 | this._dialogEditSidebar.addEventListener("sidebar-settings-save", async () => { 201 | this.saveSettings(); 202 | }); 203 | } 204 | } 205 | 206 | -------------------------------------------------------------------------------- /js/helpers.ts: -------------------------------------------------------------------------------- 1 | const TIMEOUT_ERROR = "SELECTTREE-TIMEOUT"; 2 | 3 | export async function await_element(el, hard = false) { 4 | if (el.localName?.includes("-")) 5 | await customElements.whenDefined(el.localName); 6 | if (el.updateComplete) await el.updateComplete; 7 | if (hard) { 8 | if (el.pageRendered) await el.pageRendered; 9 | if (el._panelState) { 10 | let rounds = 0; 11 | while (el._panelState !== "loaded" && rounds++ < 5) 12 | await new Promise((r) => setTimeout(r, 100)); 13 | } 14 | } 15 | } 16 | 17 | async function _selectTree(root, path, all = false) { 18 | let el = [root]; 19 | if (typeof path === "string") { 20 | path = path.split(/(\$| )/); 21 | } 22 | while (path[path.length - 1] === "") path.pop(); 23 | for (const [i, p] of path.entries()) { 24 | const e = el[0]; 25 | if (!e) return null; 26 | 27 | if (!p.trim().length) continue; 28 | 29 | await_element(e); 30 | el = p === "$" ? [e.shadowRoot] : e.querySelectorAll(p); 31 | } 32 | return all ? el : el[0]; 33 | } 34 | 35 | export async function selectTree(root, path, all = false, timeout = 10000) { 36 | return Promise.race([ 37 | _selectTree(root, path, all), 38 | new Promise((_, reject) => 39 | setTimeout(() => reject(new Error(TIMEOUT_ERROR)), timeout) 40 | ), 41 | ]).catch((err) => { 42 | if (!err.message || err.message !== TIMEOUT_ERROR) throw err; 43 | return null; 44 | }); 45 | } 46 | 47 | export async function getLovelaceRoot(document) { 48 | let _lovelaceRoot = await _getLovelaceRoot(document); 49 | while (_lovelaceRoot === null) { 50 | await new Promise((resolve) => setTimeout(resolve, 100)); 51 | _lovelaceRoot = await _getLovelaceRoot(document); 52 | } 53 | return _lovelaceRoot || null; 54 | 55 | async function _getLovelaceRoot(document) 56 | { let root = await selectTree( 57 | document, 58 | "home-assistant$home-assistant-main$ha-panel-lovelace$hui-root" 59 | ); 60 | if (!root) { 61 | let panel = await selectTree( 62 | document, 63 | "home-assistant$home-assistant-main$partial-panel-resolver>*" 64 | ); 65 | if (panel?.localName !== "ha-panel-lovelace") 66 | return false; 67 | } 68 | if (!root) 69 | root = await selectTree( 70 | document, 71 | "hc-main $ hc-lovelace $ hui-view" 72 | ); 73 | if (!root) 74 | root = await selectTree( 75 | document, 76 | "hc-main $ hc-lovelace $ hui-panel-view" 77 | ); 78 | return root; 79 | } 80 | } 81 | 82 | export async function getMoreInfoDialog(wait = false) { 83 | let _moreInfoDialog = await _getMoreInfoDialog(); 84 | while (wait && !_moreInfoDialog) { 85 | await new Promise((resolve) => setTimeout(resolve, 100)); 86 | _moreInfoDialog = await _getMoreInfoDialog(); 87 | } 88 | return _moreInfoDialog; 89 | 90 | async function _getMoreInfoDialog() 91 | { 92 | const base: any = await hass_base_el(); 93 | let moreInfoDialog; 94 | if (base) { 95 | moreInfoDialog = base.shadowRoot.querySelector( 96 | "ha-more-info-dialog" 97 | ); 98 | } 99 | return moreInfoDialog; 100 | } 101 | } 102 | 103 | export async function getMoreInfoDialogHADialog(wait = false) { 104 | let _haDialog: any = await _getMoreInfoDialogHADialog(wait); 105 | while (wait && !_haDialog) { 106 | await new Promise((resolve) => setTimeout(resolve, 100)); 107 | _haDialog = await _getMoreInfoDialogHADialog(wait); 108 | } 109 | return _haDialog; 110 | 111 | async function _getMoreInfoDialogHADialog(wait = false) 112 | { 113 | const moreInfoDialog: any = await getMoreInfoDialog(wait); 114 | let haDialog; 115 | if (moreInfoDialog) { 116 | haDialog = moreInfoDialog.shadowRoot.querySelector( 117 | "ha-dialog" 118 | ); 119 | } 120 | return haDialog; 121 | } 122 | } 123 | 124 | export async function hass_base_el() { 125 | await Promise.race([ 126 | customElements.whenDefined("home-assistant"), 127 | customElements.whenDefined("hc-main"), 128 | ]); 129 | 130 | const element = customElements.get("home-assistant") 131 | ? "home-assistant" 132 | : "hc-main"; 133 | 134 | while (!document.querySelector(element)) 135 | await new Promise((r) => window.setTimeout(r, 100)); 136 | return document.querySelector(element); 137 | } 138 | 139 | export async function hass() { 140 | const base: any = await hass_base_el(); 141 | while (!base.hass) await new Promise((r) => window.setTimeout(r, 100)); 142 | return base.hass; 143 | } 144 | 145 | export async function provideHass(el) { 146 | const base: any = await hass_base_el(); 147 | base.provideHass(el); 148 | } 149 | 150 | export const loadLoadCardHelpers = async () => { 151 | if (window.loadCardHelpers !== undefined) return; 152 | 153 | await customElements.whenDefined("partial-panel-resolver"); 154 | const ppResolver = document.createElement("partial-panel-resolver"); 155 | const routes = (ppResolver as any)._getRoutes([ 156 | { 157 | component_name: "lovelace", 158 | url_path: "a", 159 | }, 160 | ]); 161 | await routes?.routes?.a?.load?.(); 162 | // Load resources 163 | try { 164 | const llPanel = document.createElement("ha-panel-lovelace"); 165 | (llPanel as any).hass = await hass(); 166 | (llPanel as any).panel = { config: { mode: "yaml" } }; 167 | await (llPanel as any)._fetchConfig(false); 168 | } catch (e) {} 169 | }; 170 | 171 | export const loadHaForm = async () => { 172 | if (customElements.get("ha-form")) return; 173 | await loadLoadCardHelpers(); 174 | const helpers = await window.loadCardHelpers(); 175 | if (!helpers) return; 176 | const card = await helpers.createCardElement({ type: "button" }); 177 | if (!card) return; 178 | await card.constructor.getConfigElement(); 179 | }; 180 | 181 | // Loads in ha-config-dashboard which is used to copy styling 182 | // Also provides ha-settings-row 183 | export const loadConfigDashboard = async () => { 184 | await customElements.whenDefined("partial-panel-resolver"); 185 | const ppResolver = document.createElement("partial-panel-resolver"); 186 | const routes = (ppResolver as any)._getRoutes([ 187 | { 188 | component_name: "config", 189 | url_path: "a", 190 | }, 191 | ]); 192 | await routes?.routes?.a?.load?.(); 193 | await customElements.whenDefined("ha-panel-config"); 194 | const configRouter: any = document.createElement("ha-panel-config"); 195 | await configRouter?.routerOptions?.routes?.dashboard?.load?.(); // Load ha-config-dashboard 196 | await configRouter?.routerOptions?.routes?.general?.load?.(); // Load ha-settings-row 197 | await configRouter?.routerOptions?.routes?.entities?.load?.(); // Load ha-data-table 198 | await customElements.whenDefined("ha-config-dashboard"); 199 | }; 200 | 201 | export const loadDeveloperToolsTemplate = async () => { 202 | await customElements.whenDefined("partial-panel-resolver"); 203 | await customElements.whenDefined("partial-panel-resolver"); 204 | const ppResolver = document.createElement("partial-panel-resolver"); 205 | const routes = (ppResolver as any)._getRoutes([ 206 | { 207 | component_name: "developer-tools", 208 | url_path: "a", 209 | }, 210 | ]); 211 | await routes?.routes?.a?.load?.(); 212 | const dtRouter: any = document.createElement("developer-tools-router"); 213 | await dtRouter?.routerOptions?.routes?.template?.load?.(); 214 | await customElements.whenDefined("developer-tools-template"); 215 | }; 216 | 217 | export function throttle(timeout) { 218 | return function (target, propertyKey, descriptor) { 219 | const fn = descriptor.value; 220 | let cooldown = undefined; 221 | descriptor.value = function (...rest) { 222 | if (cooldown) return; 223 | cooldown = setTimeout(() => (cooldown = undefined), timeout); 224 | return fn.bind(this)(...rest); 225 | }; 226 | }; 227 | } 228 | 229 | export function runOnce(restart = false) { 230 | return function (target, propertyKey, descriptor) { 231 | const fn = descriptor.value; 232 | let running = undefined; 233 | const newfn = function (...rest) { 234 | if (restart && running === false) running = true; 235 | if (running !== undefined) return; 236 | running = false; 237 | 238 | const retval = fn.bind(this)(...rest); 239 | if (running) { 240 | running = undefined; 241 | return newfn.bind(this)(...rest); 242 | } else { 243 | running = undefined; 244 | return retval; 245 | } 246 | }; 247 | descriptor.value = newfn; 248 | }; 249 | } 250 | 251 | export async function waitRepeat(fn, times, delay) { 252 | while (times--) { 253 | await fn(); 254 | await new Promise((r) => setTimeout(r, delay)); 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /js/plugin/activity.ts: -------------------------------------------------------------------------------- 1 | export const ActivityMixin = (SuperClass) => { 2 | return class ActivityMixinClass extends SuperClass { 3 | activityTriggered = false; 4 | _activityCooldown = 15000; 5 | _activityTimeout; 6 | constructor() { 7 | super(); 8 | for (const ev of ["pointerdown", "pointermove", "keydown"]) { 9 | window.addEventListener(ev, () => this.activityTrigger(true)); 10 | } 11 | this.addEventListener("fully-update", () => { 12 | this.activityTrigger(); 13 | }); 14 | this.addEventListener("browser-mod-ready", () => 15 | this._activity_state_update() 16 | ); 17 | } 18 | 19 | _activity_state_update() { 20 | this.sendUpdate({ activity: this.activityTriggered }); 21 | } 22 | 23 | activityTrigger(touched = false) { 24 | if (!this.activityTriggered) { 25 | this.sendUpdate({ 26 | activity: true, 27 | }); 28 | } 29 | this.activityTriggered = true; 30 | if (touched) { 31 | this.fireBrowserEvent("browser-mod-activity"); 32 | } 33 | clearTimeout(this._activityTimeout); 34 | this._activityTimeout = setTimeout( 35 | () => this.activityReset(), 36 | this._activityCooldown 37 | ); 38 | } 39 | 40 | activityReset() { 41 | clearTimeout(this._activityTimeout); 42 | if (this.activityTriggered) { 43 | this.sendUpdate({ 44 | activity: false, 45 | }); 46 | } 47 | this.activityTriggered = false; 48 | } 49 | }; 50 | }; 51 | -------------------------------------------------------------------------------- /js/plugin/browser-player-editor.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from "lit"; 2 | 3 | class BrowserPlayerEditor extends LitElement { 4 | setConfig(config) {} 5 | render() { 6 | return html`
    Nothing to configure.
    `; 7 | } 8 | } 9 | 10 | // (async () => { 11 | // while (!window.browser_mod) { 12 | // await new Promise((resolve) => setTimeout(resolve, 1000)); 13 | // } 14 | // await window.browser_mod.connectionPromise; 15 | 16 | if (!customElements.get("browser-player-editor")) { 17 | customElements.define("browser-player-editor", BrowserPlayerEditor); 18 | window.customCards = window.customCards || []; 19 | window.customCards.push({ 20 | type: "browser-player", 21 | name: "Browser Player", 22 | preview: true, 23 | }); 24 | } 25 | // })(); 26 | -------------------------------------------------------------------------------- /js/plugin/browser-player.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from "lit"; 2 | import { property } from "lit/decorators.js"; 3 | 4 | import "./browser-player-editor.ts"; 5 | 6 | import "./types"; 7 | 8 | class BrowserPlayer extends LitElement { 9 | @property() hass; 10 | @property({ attribute: "edit-mode", reflect: true }) editMode; 11 | 12 | static getConfigElement() { 13 | return document.createElement("browser-player-editor"); 14 | } 15 | static getStubConfig() { 16 | return {}; 17 | } 18 | 19 | _reconnect() { 20 | if (!window.browser_mod?.registered) { 21 | if (this.parentElement.localName === "hui-card-preview") { 22 | this.removeAttribute("hidden"); 23 | } else { 24 | this.setAttribute("hidden", ""); 25 | } 26 | } 27 | } 28 | 29 | async connectedCallback() { 30 | super.connectedCallback(); 31 | await window.browser_mod?.connectionPromise; 32 | this._reconnect(); 33 | } 34 | 35 | async setConfig(config) { 36 | while (!window.browser_mod) { 37 | await new Promise((resolve) => setTimeout(resolve, 1000)); 38 | } 39 | 40 | for (const event of [ 41 | "play", 42 | "pause", 43 | "ended", 44 | "volumechange", 45 | "canplay", 46 | "loadeddata", 47 | ]) 48 | window.browser_mod?._audio_player?.addEventListener(event, () => 49 | this.requestUpdate() 50 | ); 51 | window.browser_mod?._video_player?.addEventListener(event, () => 52 | this.requestUpdate() 53 | ); 54 | window.browser_mod?.addEventListener("browser-mod-ready", () => 55 | this._reconnect() 56 | ); 57 | } 58 | handleMute(ev) { 59 | window.browser_mod.player.muted = !window.browser_mod.player.muted; 60 | } 61 | handleVolumeChange(ev) { 62 | const volume_level = parseFloat(ev.target.value); 63 | window.browser_mod.player.volume = volume_level / 100; 64 | } 65 | handleMoreInfo(ev) { 66 | this.dispatchEvent( 67 | new CustomEvent("hass-more-info", { 68 | bubbles: true, 69 | composed: true, 70 | cancelable: false, 71 | detail: { 72 | entityId: window.browser_mod.browserEntities?.player, 73 | }, 74 | }) 75 | ); 76 | } 77 | handlePlayPause(ev) { 78 | if ( 79 | !window.browser_mod.player.src || 80 | window.browser_mod.player.paused || 81 | window.browser_mod.player.ended 82 | ) { 83 | window.browser_mod.player.play(); 84 | window.browser_mod._show_video_player(); 85 | } else { 86 | window.browser_mod.player.pause(); 87 | } 88 | } 89 | handleVolumeDown(ev) { 90 | window.browser_mod.player.volume = Math.max(window.browser_mod.player.volume - 0.1, 0); 91 | } 92 | handleVolumeUp(ev) { 93 | window.browser_mod.player.volume = Math.min(window.browser_mod.player.volume + 0.1, 1); 94 | } 95 | handleReload(ev) { 96 | const wasPlaying = window.browser_mod.player.src && !window.browser_mod.player.paused && !window.browser_mod.player.ended 97 | window.browser_mod.player.load(); 98 | if (wasPlaying) { 99 | window.browser_mod.player.play(); 100 | } 101 | } 102 | 103 | render() { 104 | if (!window.browser_mod) { 105 | window.setTimeout(() => this.requestUpdate(), 100); 106 | return html``; 107 | } 108 | if (!window.browser_mod?.registered) { 109 | return html` 110 | 111 | This browser is not registered to Browser Mod. 112 | 113 | `; 114 | } 115 | return html` 116 | 117 |
    118 | 119 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 140 | 141 | ${window.browser_mod.player_state === "stopped" 142 | ? html`
    ` 143 | : html` 144 | 145 | 152 | 153 | `} 154 | 155 | 156 | 157 | 158 | 159 | 160 |
    161 | 162 |
    ${window.browser_mod.browserID}
    163 |
    164 | `; 165 | } 166 | 167 | static get styles() { 168 | return css` 169 | :host(["hidden"]) { 170 | display: none; 171 | } 172 | :host([edit-mode="true"]) { 173 | display: block !important; 174 | } 175 | paper-icon-button[highlight] { 176 | color: var(--accent-color); 177 | } 178 | .card-content { 179 | display: flex; 180 | justify-content: center; 181 | align-items: center; 182 | } 183 | .placeholder { 184 | width: 24px; 185 | padding: 8px; 186 | } 187 | .browser-id { 188 | opacity: 0.7; 189 | font-size: xx-small; 190 | margin-top: -10px; 191 | user-select: all; 192 | -webkit-user-select: all; 193 | -moz-user-select: all; 194 | -ms-user-select: all; 195 | } 196 | ha-icon-button ha-icon { 197 | display: flex; 198 | } 199 | ha-slider { 200 | flex-grow: 2; 201 | flex-shrink: 2; 202 | width: 100%; 203 | } 204 | `; 205 | } 206 | } 207 | 208 | // (async () => { 209 | // while (!window.browser_mod) { 210 | // await new Promise((resolve) => setTimeout(resolve, 1000)); 211 | // } 212 | // await window.browser_mod.connectionPromise; 213 | 214 | if (!customElements.get("browser-player")) 215 | customElements.define("browser-player", BrowserPlayer); 216 | // })(); 217 | -------------------------------------------------------------------------------- /js/plugin/browser.ts: -------------------------------------------------------------------------------- 1 | import { hass_base_el } from "../helpers"; 2 | 3 | export const BrowserStateMixin = (SuperClass) => { 4 | return class BrowserStateMixinClass extends SuperClass { 5 | constructor() { 6 | super(); 7 | document.addEventListener("visibilitychange", () => 8 | this._browser_state_update() 9 | ); 10 | window.addEventListener("location-changed", () => 11 | this._browser_state_update() 12 | ); 13 | 14 | window.addEventListener("popstate", () => 15 | // Use setTimeout as recommended by https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event 16 | setTimeout(() => { 17 | this._browser_state_update() 18 | },0) 19 | ); 20 | 21 | this.addEventListener("fully-update", () => this._browser_state_update()); 22 | this.addEventListener("browser-mod-ready", () => 23 | this._browser_state_update() 24 | ); 25 | 26 | this.connectionPromise.then(() => this._browser_state_update()); 27 | } 28 | 29 | _browser_state_update() { 30 | const update = async () => { 31 | const battery = (navigator).getBattery 32 | ? await (navigator).getBattery() 33 | : undefined; 34 | this.sendUpdate({ 35 | browser: { 36 | path: window.location.pathname, 37 | visibility: document.visibilityState, 38 | userAgent: navigator.userAgent, 39 | currentUser: this.hass?.user?.name, 40 | fullyKiosk: this.fully || false, 41 | width: window.innerWidth, 42 | height: window.innerHeight, 43 | battery_level: 44 | window.fully?.getBatteryLevel() ?? battery?.level * 100, 45 | charging: window.fully?.isPlugged() ?? battery?.charging, 46 | darkMode: this.hass?.themes?.darkMode, 47 | 48 | userData: this.hass?.user, 49 | ip_address: window.fully?.getIp4Address(), 50 | fully_data: this.fully_data, 51 | }, 52 | }); 53 | }; 54 | update(); 55 | } 56 | 57 | async browser_navigate(path) { 58 | if (!path) return; 59 | history.pushState(null, "", path); 60 | window.dispatchEvent(new CustomEvent("location-changed")); 61 | } 62 | }; 63 | }; 64 | -------------------------------------------------------------------------------- /js/plugin/browserID.ts: -------------------------------------------------------------------------------- 1 | const ID_STORAGE_KEY = "browser_mod-browser-id"; 2 | const ID_STORAGE_KEY_LOVELACE_PLAYER = "lovelace-player-device-id" 3 | 4 | export const BrowserIDMixin = (SuperClass) => { 5 | return class BrowserIDMixinClass extends SuperClass { 6 | constructor() { 7 | super(); 8 | 9 | if (Storage) { 10 | if (!Storage.prototype.browser_mod_patched) { 11 | const _clear = Storage.prototype.clear; 12 | Storage.prototype.clear = function () { 13 | const browserId = this.getItem(ID_STORAGE_KEY); 14 | const suspendWhenHidden = this.getItem("suspendWhenHidden"); 15 | _clear.apply(this); 16 | this.setItem(ID_STORAGE_KEY, browserId); 17 | this.setItem("suspendWhenHidden", suspendWhenHidden); 18 | }; 19 | Storage.prototype.browser_mod_patched = true; 20 | } 21 | } 22 | 23 | const queryString = window.location.search; 24 | const urlParams = new URLSearchParams(queryString); 25 | const newBrowserID = urlParams.get("BrowserID"); 26 | if (newBrowserID != null) this.browserID = newBrowserID; 27 | } 28 | 29 | async recall_id() { 30 | // If the connection is still open, but the BrowserID has disappeared - recall it from the backend 31 | // This happens e.g. when the frontend cache is reset in the Compainon app 32 | if (!this.connection) return; 33 | const recalledID = await this.connection.sendMessagePromise({ 34 | type: "browser_mod/recall_id", 35 | }); 36 | if (recalledID) { 37 | localStorage[ID_STORAGE_KEY] = recalledID; 38 | } 39 | } 40 | 41 | get browserID() { 42 | if (document.querySelector("hc-main")) return "CAST"; 43 | if (localStorage[ID_STORAGE_KEY]) { 44 | // set lovelace-player-device-id as used by card-tools, state-switch 45 | localStorage[ID_STORAGE_KEY_LOVELACE_PLAYER] = localStorage[ID_STORAGE_KEY]; 46 | return localStorage[ID_STORAGE_KEY]; 47 | } 48 | this.browserID = ""; 49 | this.recall_id(); 50 | return this.browserID; 51 | } 52 | set browserID(id) { 53 | function _createBrowserID() { 54 | const s4 = () => { 55 | return Math.floor((1 + Math.random()) * 100000) 56 | .toString(16) 57 | .substring(1); 58 | }; 59 | return "browser_mod_" + (window.fully?.getDeviceId() ? window.fully.getDeviceId().replace(/-/g,'_') : `${s4()}${s4()}_${s4()}${s4()}`); 60 | } 61 | 62 | if (id === "") id = _createBrowserID(); 63 | const oldID = localStorage[ID_STORAGE_KEY]; 64 | localStorage[ID_STORAGE_KEY] = id; 65 | // set lovelace-player-device-id as used by card-tools, state-switch 66 | localStorage[ID_STORAGE_KEY_LOVELACE_PLAYER] = localStorage[ID_STORAGE_KEY]; 67 | 68 | this.browserIDChanged(oldID, id); 69 | } 70 | 71 | protected browserIDChanged(oldID, newID) {} 72 | }; 73 | }; 74 | -------------------------------------------------------------------------------- /js/plugin/camera.ts: -------------------------------------------------------------------------------- 1 | export const CameraMixin = (SuperClass) => { 2 | return class CameraMixinClass extends SuperClass { 3 | private _video; 4 | private _canvas; 5 | private _framerate; 6 | public cameraError; 7 | 8 | // TODO: Enable WebRTC? 9 | // https://levelup.gitconnected.com/establishing-the-webrtc-connection-videochat-with-javascript-step-3-48d4ae0e9ea4 10 | 11 | constructor() { 12 | super(); 13 | this._framerate = 2; 14 | this.cameraError = false; 15 | 16 | this._setup_camera(); 17 | } 18 | 19 | async _setup_camera() { 20 | if (this._video) return; 21 | await this.connectionPromise; 22 | await this.firstInteraction; 23 | if (!this.cameraEnabled) return; 24 | if (this.fully) return this.update_camera(); 25 | 26 | const div = document.createElement("div"); 27 | document.body.append(div); 28 | div.classList.add("browser-mod-camera"); 29 | div.attachShadow({ mode: "open" }); 30 | 31 | const styleEl = document.createElement("style"); 32 | div.shadowRoot.append(styleEl); 33 | styleEl.innerHTML = ` 34 | :host { 35 | display: none; 36 | }`; 37 | 38 | const video = (this._video = document.createElement("video")); 39 | div.shadowRoot.append(video); 40 | video.autoplay = true; 41 | video.playsInline = true; 42 | video.style.display = "none"; 43 | 44 | const canvas = (this._canvas = document.createElement("canvas")); 45 | div.shadowRoot.append(canvas); 46 | canvas.style.display = "none"; 47 | 48 | if (!navigator.mediaDevices) return; 49 | 50 | try { 51 | const stream = await navigator.mediaDevices.getUserMedia({ 52 | video: true, 53 | audio: false, 54 | }); 55 | 56 | video.srcObject = stream; 57 | video.play(); 58 | this.update_camera(); 59 | } catch (e) { 60 | if (e.name !== "NotAllowedError") throw e; 61 | else { 62 | this.cameraError = true; 63 | this.fireBrowserEvent("browser-mod-config-update"); 64 | } 65 | } 66 | } 67 | 68 | async update_camera() { 69 | if (!this.cameraEnabled) { 70 | const stream = this._video?.srcObject; 71 | if (stream) { 72 | stream.getTracks().forEach((t) => t.stop()); 73 | this._video.scrObject = undefined; 74 | } 75 | return; 76 | } 77 | if (this.fully) { 78 | this.sendUpdate({ 79 | camera: this.fully_camera, 80 | }); 81 | } else { 82 | const video = this._video; 83 | const width = video.videoWidth; 84 | const height = video.videoHeight; 85 | this._canvas.width = width; 86 | this._canvas.height = height; 87 | const context = this._canvas.getContext("2d"); 88 | context.drawImage(video, 0, 0, width, height); 89 | 90 | this.sendUpdate({ 91 | camera: this._canvas.toDataURL("image/jpeg"), 92 | }); 93 | } 94 | 95 | const interval = Math.round(1000 / this._framerate); 96 | setTimeout(() => this.update_camera(), interval); 97 | } 98 | }; 99 | }; 100 | -------------------------------------------------------------------------------- /js/plugin/connection.ts: -------------------------------------------------------------------------------- 1 | import { hass, provideHass } from "../helpers"; 2 | 3 | export const ConnectionMixin = (SuperClass) => { 4 | class BrowserModConnection extends SuperClass { 5 | public hass; 6 | public connection; 7 | public ready = false; 8 | 9 | private _data; 10 | private _connected = false; 11 | private _connectionResolve; 12 | 13 | public connectionPromise = new Promise((resolve) => { 14 | this._connectionResolve = resolve; 15 | }); 16 | public browserEntities = {}; 17 | 18 | LOG(...args) { 19 | if (window.browser_mod_log === undefined) return; 20 | const dt = new Date(); 21 | console.log(`${dt.toLocaleTimeString()}`, ...args); 22 | 23 | if (this._connected) { 24 | try { 25 | this.connection.sendMessage({ 26 | type: "browser_mod/log", 27 | message: args[0], 28 | }); 29 | } catch (err) { 30 | console.log("Browser Mod: Error sending log:", err); 31 | } 32 | } 33 | } 34 | 35 | // Propagate internal browser event 36 | private fireBrowserEvent(event, detail = undefined) { 37 | this.dispatchEvent(new CustomEvent(event, { detail, bubbles: true })); 38 | } 39 | 40 | /* 41 | * Main state flags explained: 42 | * * `connected` and `disconnected` refers to WS connection, 43 | * * `ready` refers to established communication between browser and component counterpart. 44 | */ 45 | 46 | // Component and frontend are mutually ready 47 | private onReady = () => { 48 | this.ready = true; 49 | this.LOG("Integration ready: browser_mod loaded and update received"); 50 | this.fireBrowserEvent("browser-mod-ready"); 51 | window.setTimeout(() => this.sendUpdate({}), 1000); 52 | } 53 | 54 | // WebSocket has connected 55 | private onConnected = () => { 56 | this._connected = true; 57 | this.LOG("WebSocket connected"); 58 | } 59 | 60 | // WebSocket has disconnected 61 | private onDisconnected = () => { 62 | this.ready = false; 63 | this._connected = false; 64 | this.LOG("WebSocket disconnected"); 65 | this.fireBrowserEvent("browser-mod-disconnected"); 66 | } 67 | 68 | // Handle incoming message 69 | private incoming_message(msg) { 70 | // Set that have a connection. Allows logging 71 | if (!this._connected) { 72 | this.onConnected(); 73 | } 74 | // Handle messages 75 | if (msg.command) { 76 | this.LOG("Command:", msg); 77 | this.fireBrowserEvent(`command-${msg.command}`, msg); 78 | } else if (msg.browserEntities) { 79 | this.browserEntities = msg.browserEntities; 80 | } else if (msg.result) { 81 | this.update_config(msg.result); 82 | } 83 | // Resolve first connection promise 84 | this._connectionResolve?.(); 85 | this._connectionResolve = undefined; 86 | } 87 | 88 | private update_config(cfg) { 89 | this.LOG("Receive:", cfg); 90 | 91 | let update = false; 92 | if (!this.registered && cfg.browsers?.[this.browserID]) { 93 | update = true; 94 | } 95 | this._data = cfg; 96 | 97 | if (!this.registered && this.global_settings["autoRegister"] === true) 98 | this.registered = true; 99 | 100 | // Check for readiness (of component and browser) 101 | if (!this.ready) { 102 | this.onReady(); 103 | } 104 | this.fireBrowserEvent("browser-mod-config-update"); 105 | 106 | if (update) this.sendUpdate({}); 107 | } 108 | 109 | async connect() { 110 | const conn = (await hass()).connection; 111 | this.connection = conn; 112 | 113 | const connectBrowserModComponent = () => { 114 | this.LOG("Subscribing to browser_mod/connect events"); 115 | conn.subscribeMessage((msg) => this.incoming_message(msg), { 116 | type: "browser_mod/connect", 117 | browserID: this.browserID, 118 | }); 119 | }; 120 | 121 | // Initial connect component subscription 122 | connectBrowserModComponent(); 123 | // If this fails, then: 124 | // Observe `component_loaded` to track when `browser_mod` is dynamically added 125 | conn.subscribeEvents((haEvent) => { 126 | if (haEvent.data?.component === "browser_mod") { 127 | this.LOG("Detected browser_mod component load"); 128 | connectBrowserModComponent(); 129 | } 130 | }, "component_loaded"); 131 | 132 | // Keep connection status up to date 133 | conn.addEventListener("ready", () => { 134 | this.onConnected(); 135 | }); 136 | conn.addEventListener("disconnected", () => { 137 | this.onDisconnected(); 138 | }); 139 | window.addEventListener("connection-status", (ev: CustomEvent) => { 140 | if (ev.detail === "connected") { 141 | this.onConnected(); 142 | } 143 | if (ev.detail === "disconnected") { 144 | this.onDisconnected(); 145 | } 146 | }); 147 | 148 | provideHass(this); 149 | } 150 | 151 | get config() { 152 | return this._data?.config ?? {}; 153 | } 154 | 155 | get browsers() { 156 | return this._data?.browsers ?? []; 157 | } 158 | 159 | get registered() { 160 | return this.browsers?.[this.browserID] !== undefined; 161 | } 162 | 163 | get browser_locked() { 164 | return this.browsers?.[this.browserID]?.locked; 165 | } 166 | 167 | set registered(reg) { 168 | (async () => { 169 | if (reg) { 170 | if (this.registered || this.global_settings["lockRegister"]) return; 171 | await this.connection.sendMessage({ 172 | type: "browser_mod/register", 173 | browserID: this.browserID, 174 | }); 175 | } else { 176 | if (!this.registered) return; 177 | await this.connection.sendMessage({ 178 | type: "browser_mod/unregister", 179 | browserID: this.browserID, 180 | }); 181 | } 182 | })(); 183 | } 184 | 185 | private async _reregister(newData = {}) { 186 | await this.connection.sendMessage({ 187 | type: "browser_mod/register", 188 | browserID: this.browserID, 189 | data: { 190 | ...this.browsers[this.browserID], 191 | ...newData, 192 | }, 193 | }); 194 | } 195 | 196 | get global_settings() { 197 | const settings = {}; 198 | const global = this._data?.settings ?? {}; 199 | for (const [k, v] of Object.entries(global)) { 200 | if (v !== null) settings[k] = v; 201 | } 202 | return settings; 203 | } 204 | get user_settings() { 205 | const settings = {}; 206 | const user = this._data?.user_settings?.[this.hass?.user?.id] ?? {}; 207 | for (const [k, v] of Object.entries(user)) { 208 | if (v !== null) settings[k] = v; 209 | } 210 | return settings; 211 | } 212 | get browser_settings() { 213 | const settings = {}; 214 | const browser = this.browsers?.[this.browserID]?.settings ?? {}; 215 | for (const [k, v] of Object.entries(browser)) { 216 | if (v !== null) settings[k] = v; 217 | } 218 | return settings; 219 | } 220 | 221 | get settings() { 222 | return { 223 | ...this.global_settings, 224 | ...this.browser_settings, 225 | ...this.user_settings, 226 | }; 227 | } 228 | 229 | set_setting(key, value, level) { 230 | switch (level) { 231 | case "global": { 232 | this.connection.sendMessage({ 233 | type: "browser_mod/settings", 234 | key, 235 | value, 236 | }); 237 | break; 238 | } 239 | case "user": { 240 | const user = this.hass.user.id; 241 | this.connection.sendMessage({ 242 | type: "browser_mod/settings", 243 | user, 244 | key, 245 | value, 246 | }); 247 | break; 248 | } 249 | case "browser": { 250 | const settings = this.browsers[this.browserID]?.settings; 251 | settings[key] = value; 252 | this._reregister({ settings }); 253 | break; 254 | } 255 | } 256 | } 257 | 258 | get cameraEnabled() { 259 | if (!this.registered) return null; 260 | return this.browsers[this.browserID].camera; 261 | } 262 | set cameraEnabled(value) { 263 | this._reregister({ camera: value }); 264 | } 265 | 266 | sendUpdate(data) { 267 | if (!this.ready || !this.registered) return; 268 | 269 | const dt = new Date(); 270 | 271 | this.LOG("Send:", data); 272 | try { 273 | this.connection.sendMessage({ 274 | type: "browser_mod/update", 275 | browserID: this.browserID, 276 | data, 277 | }) 278 | } catch (err) { 279 | // As we are not sure of connection state, just log to console 280 | console.log("Browser Mod: Error sending update:", err); 281 | } 282 | } 283 | 284 | browserIDChanged(oldID, newID) { 285 | this.fireBrowserEvent("browser-mod-config-update"); 286 | 287 | if ( 288 | this.browsers?.[oldID] !== undefined && 289 | this.browsers?.[this.browserID] === undefined 290 | ) { 291 | (async () => { 292 | await this.connection.sendMessage({ 293 | type: "browser_mod/register", 294 | browserID: oldID, 295 | data: { 296 | ...this.browsers[oldID], 297 | browserID: this.browserID, 298 | }, 299 | }); 300 | })(); 301 | } 302 | } 303 | } 304 | 305 | return BrowserModConnection; 306 | }; 307 | -------------------------------------------------------------------------------- /js/plugin/event-target-polyfill.js: -------------------------------------------------------------------------------- 1 | /* 2 | This file is copied from https://github.com/benlesh/event-target-polyfill and tweaked such that it never polyfills Event 3 | 4 | MIT License 5 | 6 | Copyright (c) 2020 Ben Lesh 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | */ 26 | 27 | const root = 28 | (typeof globalThis !== "undefined" && globalThis) || 29 | (typeof self !== "undefined" && self) || 30 | (typeof global !== "undefined" && global); 31 | 32 | function isConstructor(fn) { 33 | try { 34 | new fn(); 35 | } catch (error) { 36 | return false; 37 | } 38 | return true; 39 | } 40 | 41 | if (false) { 42 | root.Event = (function () { 43 | function Event(type, options) { 44 | this.bubbles = !!options && !!options.bubbles; 45 | this.cancelable = !!options && !!options.cancelable; 46 | this.composed = !!options && !!options.composed; 47 | this.type = type; 48 | } 49 | 50 | return Event; 51 | })(); 52 | } 53 | 54 | if ( 55 | typeof root.EventTarget === "undefined" || 56 | !isConstructor(root.EventTarget) 57 | ) { 58 | root.EventTarget = (function () { 59 | function EventTarget() { 60 | this.__listeners = new Map(); 61 | } 62 | 63 | EventTarget.prototype = Object.create(Object.prototype); 64 | 65 | EventTarget.prototype.addEventListener = function ( 66 | type, 67 | listener, 68 | options 69 | ) { 70 | if (arguments.length < 2) { 71 | throw new TypeError( 72 | `TypeError: Failed to execute 'addEventListener' on 'EventTarget': 2 arguments required, but only ${arguments.length} present.` 73 | ); 74 | } 75 | const __listeners = this.__listeners; 76 | const actualType = type.toString(); 77 | if (!__listeners.has(actualType)) { 78 | __listeners.set(actualType, new Map()); 79 | } 80 | const listenersForType = __listeners.get(actualType); 81 | if (!listenersForType.has(listener)) { 82 | // Any given listener is only registered once 83 | listenersForType.set(listener, options); 84 | } 85 | }; 86 | 87 | EventTarget.prototype.removeEventListener = function ( 88 | type, 89 | listener, 90 | _options 91 | ) { 92 | if (arguments.length < 2) { 93 | throw new TypeError( 94 | `TypeError: Failed to execute 'addEventListener' on 'EventTarget': 2 arguments required, but only ${arguments.length} present.` 95 | ); 96 | } 97 | const __listeners = this.__listeners; 98 | const actualType = type.toString(); 99 | if (__listeners.has(actualType)) { 100 | const listenersForType = __listeners.get(actualType); 101 | if (listenersForType.has(listener)) { 102 | listenersForType.delete(listener); 103 | } 104 | } 105 | }; 106 | 107 | EventTarget.prototype.dispatchEvent = function (event) { 108 | if (!(event instanceof Event)) { 109 | throw new TypeError( 110 | `Failed to execute 'dispatchEvent' on 'EventTarget': parameter 1 is not of type 'Event'.` 111 | ); 112 | } 113 | const type = event.type; 114 | const __listeners = this.__listeners; 115 | const listenersForType = __listeners.get(type); 116 | if (listenersForType) { 117 | for (const [listener, options] of listenersForType.entries()) { 118 | try { 119 | if (typeof listener === "function") { 120 | // Listener functions must be executed with the EventTarget as the `this` context. 121 | listener.call(this, event); 122 | } else if (listener && typeof listener.handleEvent === "function") { 123 | // Listener objects have their handleEvent method called, if they have one 124 | listener.handleEvent(event); 125 | } 126 | } catch (err) { 127 | // We need to report the error to the global error handling event, 128 | // but we do not want to break the loop that is executing the events. 129 | // Unfortunately, this is the best we can do, which isn't great, because the 130 | // native EventTarget will actually do this synchronously before moving to the next 131 | // event in the loop. 132 | setTimeout(() => { 133 | throw err; 134 | }); 135 | } 136 | if (options && options.once) { 137 | // If this was registered with { once: true }, we need 138 | // to remove it now. 139 | listenersForType.delete(listener); 140 | } 141 | } 142 | } 143 | // Since there are no cancellable events on a base EventTarget, 144 | // this should always return true. 145 | return true; 146 | }; 147 | 148 | return EventTarget; 149 | })(); 150 | } 151 | -------------------------------------------------------------------------------- /js/plugin/fullyKiosk.ts: -------------------------------------------------------------------------------- 1 | export const FullyMixin = (C) => { 2 | return class FullyMixinClass extends C { 3 | private _fully_screensaver = false; 4 | 5 | get fully() { 6 | return window.fully !== undefined; 7 | } 8 | 9 | constructor() { 10 | super(); 11 | 12 | if (!this.fully) return; 13 | 14 | for (const ev of [ 15 | "screenOn", 16 | "screenOff", 17 | "pluggedAC", 18 | "pluggedUSB", 19 | "onBatteryLevelChanged", 20 | "unplugged", 21 | "networkReconnect", 22 | "onMotion", 23 | "onDaydreamStart", 24 | "onDaydreamStop", 25 | ]) { 26 | window.fully.bind(ev, `window.browser_mod.fullyEvent("${ev}");`); 27 | } 28 | 29 | window.fully.bind( 30 | "onScreensaverStart", 31 | `window.browser_mod._fully_screensaver = true; window.browser_mod.fullyEvent();` 32 | ); 33 | window.fully.bind( 34 | "onScreensaverStop", 35 | `window.browser_mod._fully_screensaver = false; window.browser_mod.fullyEvent();` 36 | ); 37 | 38 | return; 39 | } 40 | 41 | get fully_screen() { 42 | return this._fully_screensaver === false && window.fully?.getScreenOn(); 43 | } 44 | set fully_screen(state) { 45 | if (state) { 46 | window.fully?.turnScreenOn(); 47 | window.fully?.stopScreensaver(); 48 | } else { 49 | window.fully?.turnScreenOff(); 50 | } 51 | } 52 | 53 | get fully_brightness() { 54 | return window.fully?.getScreenBrightness(); 55 | } 56 | set fully_brightness(br) { 57 | window.fully?.setScreenBrightness(br); 58 | } 59 | 60 | get fully_camera() { 61 | return window.fully?.getCamshotJpgBase64(); 62 | } 63 | 64 | get fully_data() { 65 | const f = window.fully; 66 | if (f === undefined) return "undefined"; 67 | try { 68 | return { 69 | ip4Address: f.getIp4Address(), 70 | ip6Address: f.getIp6Address(), 71 | hostname: f.getHostname(), 72 | hostname6: f.getHostname6(), 73 | macAddress: f.getMacAddress(), 74 | wifiSsid: f.getWifiSsid(), 75 | wifiBssid: f.getWifiBssid(), 76 | wifiSignalLevel: f.getWifiSignalLevel(), 77 | serialNumber: f.getSerialNumber(), 78 | androidId: f.getAndroidId(), 79 | deviceId: f.getDeviceId(), 80 | deviceName: f.getDeviceName(), 81 | imei: f.getImei(), 82 | simSerialNumber: f.getSimSerialNumber(), 83 | batteryLevel: f.getBatteryLevel(), 84 | screenBrightness: f.getScreenBrightness(), 85 | screenOrientation: f.getScreenOrientation(), 86 | displayWidth: f.getDisplayWidth(), 87 | displayHeight: f.getDisplayHeight(), 88 | screenOn: f.getScreenOn(), 89 | plugged: f.isPlugged(), 90 | keyboardVisible: f.isKeyboardVisible(), 91 | wifiEnabled: f.isWifiEnabled(), 92 | wifiConnected: f.isWifiConnected(), 93 | networkConnected: f.isNetworkConnected(), 94 | bluetoothEnabled: f.isBluetoothEnabled(), 95 | screenRotationLocked: f.isScreenRotationLocked(), 96 | fullyVersion: f.getFullyVersion(), 97 | fullyVersionCode: f.getFullyVersionCode(), 98 | webViewVersion: f.getWebviewVersion(), 99 | androidVersion: f.getAndroidVersion(), 100 | androidSdk: f.getAndroidSdk(), 101 | deviceModel: f.getDeviceModel(), 102 | 103 | internalStorageTotalSpace: f.getInternalStorageTotalSpace(), 104 | internalStorageFreeSpace: f.getInternalStorageFreeSpace(), 105 | externalStorageTotalSpace: f.getExternalStorageTotalSpace(), 106 | externalStorageFreeSpace: f.getExternalStorageFreeSpace(), 107 | 108 | sensorInfo: f.getSensorInfo(), 109 | //getSensorValue: f.getSensorValue(), 110 | //getSensorValues: f.getSensorValues(), 111 | 112 | allRxBytesMobile: f.getAllRxBytesMobile(), 113 | allTxBytesMobile: f.getAllTxBytesMobile(), 114 | allRxBytesWifi: f.getAllRxBytesWifi(), 115 | allTxBytesWifi: f.getAllTxBytesWifi(), 116 | }; 117 | } catch (error) { 118 | return String(error); 119 | } 120 | } 121 | 122 | fullyEvent(event = undefined) { 123 | this.fireBrowserEvent("fully-update", { event }); 124 | } 125 | }; 126 | }; 127 | -------------------------------------------------------------------------------- /js/plugin/main.ts: -------------------------------------------------------------------------------- 1 | import "./event-target-polyfill.js"; 2 | import "./browser-player"; 3 | 4 | import { ConnectionMixin } from "./connection"; 5 | import { ScreenSaverMixin } from "./screensaver"; 6 | import { MediaPlayerMixin } from "./mediaPlayer"; 7 | import { CameraMixin } from "./camera"; 8 | import { RequireInteractMixin } from "./require-interact"; 9 | import { FullyMixin } from "./fullyKiosk"; 10 | import { BrowserStateMixin } from "./browser"; 11 | import { ServicesMixin } from "./services"; 12 | import { ActivityMixin } from "./activity"; 13 | import "./popups"; 14 | import { PopupMixin } from "./popups"; 15 | import pjson from "../../package.json"; 16 | import "./popup-card"; 17 | import { AutoSettingsMixin } from "./frontend-settings"; 18 | import { BrowserIDMixin } from "./browserID"; 19 | import { VersionMixin } from "./version.js"; 20 | 21 | export class BrowserMod extends ServicesMixin( 22 | VersionMixin( 23 | PopupMixin( 24 | ActivityMixin( 25 | BrowserStateMixin( 26 | CameraMixin( 27 | MediaPlayerMixin( 28 | ScreenSaverMixin( 29 | AutoSettingsMixin( 30 | FullyMixin( 31 | RequireInteractMixin( 32 | ConnectionMixin(BrowserIDMixin(EventTarget)) 33 | ) 34 | ) 35 | ) 36 | ) 37 | ) 38 | ) 39 | ) 40 | ) 41 | ) 42 | ) 43 | ) { 44 | constructor() { 45 | super(); 46 | this.connect(); 47 | 48 | window.dispatchEvent(new Event("browser-mod-bootstrap")); 49 | console.info( 50 | `%cBROWSER_MOD ${pjson.version} IS INSTALLED 51 | %cBrowserID: ${this.browserID}`, 52 | "color: green; font-weight: bold", 53 | "" 54 | ); 55 | } 56 | } 57 | 58 | if (!window.browser_mod) window.browser_mod = new BrowserMod(); 59 | -------------------------------------------------------------------------------- /js/plugin/mediaPlayer.ts: -------------------------------------------------------------------------------- 1 | import { selectTree, throttle } from "../helpers"; 2 | 3 | export const MediaPlayerMixin = (SuperClass) => { 4 | class MediaPlayerMixinClass extends SuperClass { 5 | public player; 6 | private _audio_player; 7 | private _video_player; 8 | private _player_enabled; 9 | 10 | constructor() { 11 | super(); 12 | 13 | this._audio_player = new Audio(); 14 | this._video_player = document.createElement("video"); 15 | this._video_player.controls = true; 16 | this._video_player.style.setProperty("width", "100%"); 17 | this.player = this._audio_player; 18 | this._player_enabled = false; 19 | this.extra = {} 20 | 21 | for (const ev of ["play", "pause", "ended", "volumechange"]) { 22 | this._audio_player.addEventListener(ev, () => this._player_update()); 23 | this._video_player.addEventListener(ev, () => this._player_update()); 24 | } 25 | for (const ev of ["timeupdate"]) { 26 | this._audio_player.addEventListener(ev, () => 27 | this._player_update_throttled() 28 | ); 29 | this._video_player.addEventListener(ev, () => 30 | this._player_update_throttled() 31 | ); 32 | } 33 | 34 | this.firstInteraction.then(() => { 35 | this._player_enabled = true; 36 | if (!this.player.ended) this.player.play(); 37 | }); 38 | 39 | this.addEventListener("command-player-play", (ev) => { 40 | if (this.player.src) this.player.pause(); 41 | if (ev.detail?.media_type) 42 | if (ev.detail?.media_type.startsWith("video")) 43 | this.player = this._video_player; 44 | else this.player = this._audio_player; 45 | if (ev.detail?.media_content_id) 46 | this.player.src = ev.detail.media_content_id; 47 | this.extra = ev.detail?.extra; 48 | this.player.play(); 49 | this._show_video_player(); 50 | }); 51 | this.addEventListener("command-player-pause", (ev) => 52 | this.player.pause() 53 | ); 54 | this.addEventListener("command-player-stop", (ev) => { 55 | this.player.src = null; 56 | this.player.pause(); 57 | }); 58 | this.addEventListener("command-player-set-volume", (ev) => { 59 | if (ev.detail?.volume_level === undefined) return; 60 | this.player.volume = ev.detail.volume_level; 61 | }); 62 | this.addEventListener("command-player-mute", (ev) => { 63 | if (ev.detail?.mute !== undefined) 64 | this.player.muted = Boolean(ev.detail.mute); 65 | else this.player.muted = !this.player.muted; 66 | }); 67 | this.addEventListener("command-player-seek", (ev) => { 68 | this.player.currentTime = ev.detail.position; 69 | setTimeout(() => this._player_update(), 10); 70 | }); 71 | this.addEventListener("command-player-turn-off", (ev) => { 72 | if ( 73 | this.player === this._video_player && 74 | this._video_player.isConnected 75 | ) 76 | this.closePopup(); 77 | else if (this.player.src) this.player.pause(); 78 | this.player.src = ""; 79 | this._player_update(); 80 | }); 81 | 82 | this.addEventListener("browser-mod-ready", () => 83 | this._player_update() 84 | ); 85 | 86 | this.connectionPromise.then(() => this._player_update()); 87 | } 88 | 89 | private _show_video_player() { 90 | if (this.player === this._video_player && this.player.src) { 91 | selectTree( 92 | document, 93 | "home-assistant $ dialog-media-player-browse" 94 | ).then((el) => el?.closeDialog()); 95 | this.showPopup(undefined, this._video_player, { 96 | dismiss_action: () => this._video_player.pause(), 97 | size: "wide", 98 | }); 99 | } else if ( 100 | this.player !== this._video_player && 101 | this._video_player.isConnected 102 | ) { 103 | this.closePopup(); 104 | } 105 | } 106 | 107 | @throttle(3000) 108 | _player_update_throttled() { 109 | this._player_update(); 110 | } 111 | 112 | private _player_update() { 113 | const state = this._player_enabled 114 | ? !this.player.src || this.player.src === window.location.href 115 | ? "off" 116 | : this.player.ended 117 | ? "stopped" 118 | : this.player.paused 119 | ? "paused" 120 | : "playing" 121 | : "unavailable"; 122 | this.sendUpdate({ 123 | player: { 124 | volume: this.player.volume, 125 | muted: this.player.muted, 126 | src: this.player.src, 127 | state, 128 | media_duration: this.player.duration, 129 | media_position: this.player.currentTime, 130 | extra: this.extra, 131 | }, 132 | }); 133 | } 134 | } 135 | 136 | return MediaPlayerMixinClass; 137 | }; 138 | -------------------------------------------------------------------------------- /js/plugin/popup-card-editor.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from "lit"; 2 | import { property, query, state } from "lit/decorators.js"; 3 | import { loadHaForm } from "../helpers"; 4 | 5 | const configSchema = [ 6 | { 7 | name: "entity", 8 | label: "Entity", 9 | selector: { entity: {} }, 10 | }, 11 | { 12 | name: "title", 13 | label: "Title", 14 | selector: { text: {} }, 15 | }, 16 | { 17 | name: "size", 18 | selector: { 19 | select: { mode: "dropdown", options: ["normal", "classic", "wide", "fullscreen"] }, 20 | }, 21 | }, 22 | { 23 | type: "grid", 24 | schema: [ 25 | { 26 | name: "right_button", 27 | label: "Right button", 28 | selector: { text: {} }, 29 | }, 30 | { 31 | name: "left_button", 32 | label: "Left button", 33 | selector: { text: {} }, 34 | }, 35 | ], 36 | }, 37 | { 38 | type: "grid", 39 | schema: [ 40 | { 41 | name: "right_button_action", 42 | label: "Right button action", 43 | selector: { object: {} }, 44 | }, 45 | { 46 | name: "left_button_action", 47 | label: "Left button action", 48 | selector: { object: {} }, 49 | }, 50 | ], 51 | }, 52 | { 53 | type: "grid", 54 | schema: [ 55 | { 56 | name: "right_button_close", 57 | label: "Right button closes popup", 58 | default: true, 59 | selector: { boolean: {} }, 60 | }, 61 | { 62 | name: "left_button_close", 63 | label: "Left button closes popup", 64 | default: true, 65 | selector: { boolean: {} }, 66 | }, 67 | ], 68 | }, 69 | { 70 | type: "grid", 71 | schema: [ 72 | { 73 | name: "dismissable", 74 | label: "User dismissable", 75 | selector: { boolean: {} }, 76 | }, 77 | { 78 | name: "timeout", 79 | label: "Auto close timeout (ms)", 80 | selector: { number: { mode: "box" } }, 81 | }, 82 | ], 83 | }, 84 | { 85 | name: "timeout_hide_progress" , 86 | label: "Hide timeout progress bar", 87 | selector: { boolean: {} }, 88 | }, 89 | { 90 | type: "grid", 91 | schema: [ 92 | { 93 | name: "dismiss_action", 94 | label: "Dismiss action", 95 | selector: { object: {} }, 96 | }, 97 | { 98 | name: "timeout_action", 99 | label: "Timeout action", 100 | selector: { object: {} }, 101 | }, 102 | ], 103 | }, 104 | { 105 | name: "allow_nested_more_info", 106 | label: "Allow nested more-info dialogs", 107 | default: true, 108 | selector: { boolean: {} }, 109 | }, 110 | { 111 | name: "popup_card_all_views", 112 | label: "Popup card is available for use in all views", 113 | default: false, 114 | selector: { boolean: {} }, 115 | }, 116 | { 117 | name: "style", 118 | label: "CSS style", 119 | selector: { text: { multiline: true } }, 120 | }, 121 | ]; 122 | 123 | class PopupCardEditor extends LitElement { 124 | @state() _config; 125 | 126 | @property() lovelace; 127 | @property() hass; 128 | 129 | @state() _selectedTab = 0; 130 | @state() _cardGUIMode = true; 131 | @state() _cardGUIModeAvailable = true; 132 | 133 | @query("hui-card-element-editor") private _cardEditorEl?; 134 | 135 | setConfig(config) { 136 | this._config = config; 137 | } 138 | 139 | connectedCallback() { 140 | super.connectedCallback(); 141 | loadHaForm(); 142 | } 143 | 144 | _handleSwitchTab(ev: CustomEvent) { 145 | this._selectedTab = ev.detail.name == "settings" ? 0 : 1; 146 | } 147 | 148 | _configChanged(ev: CustomEvent) { 149 | ev.stopPropagation(); 150 | if (!this._config) return; 151 | this._config = { ...ev.detail.value }; 152 | this.dispatchEvent( 153 | new CustomEvent("config-changed", { detail: { config: this._config } }) 154 | ); 155 | } 156 | 157 | _cardConfigChanged(ev: CustomEvent) { 158 | ev.stopPropagation(); 159 | if (!this._config) return; 160 | const card = { ...ev.detail.config }; 161 | this._config = { ...this._config, card }; 162 | this._cardGUIModeAvailable = ev.detail.guiModeAvailable; 163 | 164 | this.dispatchEvent( 165 | new CustomEvent("config-changed", { detail: { config: this._config } }) 166 | ); 167 | } 168 | _toggleCardMode(ev) { 169 | this._cardEditorEl?.toggleMode(); 170 | } 171 | _deleteCard(ev) { 172 | if (!this._config) return; 173 | this._config = { ...this._config }; 174 | delete this._config.card; 175 | 176 | this.dispatchEvent( 177 | new CustomEvent("config-changed", { detail: { config: this._config } }) 178 | ); 179 | } 180 | _cardGUIModeChanged(ev: CustomEvent) { 181 | ev.stopPropagation(); 182 | this._cardGUIMode = ev.detail.guiMode; 183 | this._cardGUIModeAvailable = ev.detail.guiModeAvailable; 184 | } 185 | 186 | render() { 187 | if (!this.hass || !this._config) { 188 | return html``; 189 | } 190 | 191 | return html` 192 |
    193 |
    194 | 197 | Settings 198 | Card 199 | 200 |
    201 |
    202 | ${[this._renderSettingsEditor, this._renderCardEditor][ 203 | this._selectedTab 204 | ].bind(this)()} 205 |
    206 |
    207 | `; 208 | } 209 | 210 | _renderSettingsEditor() { 211 | return html`
    212 | s.label ?? s.name} 217 | @value-changed=${this._configChanged} 218 | > 219 |
    `; 220 | } 221 | 222 | _renderCardEditor() { 223 | return html` 224 |
    225 | ${this._config.card 226 | ? html` 227 |
    228 | 233 | ${!this._cardEditorEl || this._cardGUIMode 234 | ? "Show code editor" 235 | : "Show visual editor"} 236 | 237 | 241 | Change card type 242 | 243 |
    244 | 251 | ` 252 | : html` 253 | 258 | `} 259 |
    260 | `; 261 | } 262 | 263 | static get styles() { 264 | return css` 265 | sl-tab-group { 266 | margin-bottom: 16px; 267 | } 268 | 269 | sl-tab { 270 | flex: 1; 271 | } 272 | 273 | sl-tab::part(base) { 274 | width: 100%; 275 | justify-content: center; 276 | } 277 | 278 | .box { 279 | margin-top: 8px; 280 | border: 1px solid var(--divider-color); 281 | padding: 12px; 282 | } 283 | .box .toolbar { 284 | display: flex; 285 | justify-content: flex-end; 286 | width: 100%; 287 | gap: 8px; 288 | } 289 | .gui-mode-button { 290 | margin-right: auto; 291 | } 292 | `; 293 | } 294 | } 295 | 296 | window.addEventListener("browser-mod-bootstrap", async (ev: CustomEvent) => { 297 | ev.stopPropagation(); 298 | while (!window.browser_mod) { 299 | await new Promise((resolve) => setTimeout(resolve, 1000)); 300 | } 301 | await window.browser_mod.connectionPromise; 302 | 303 | if (!customElements.get("popup-card-editor")) { 304 | customElements.define("popup-card-editor", PopupCardEditor); 305 | (window as any).customCards = (window as any).customCards || []; 306 | (window as any).customCards.push({ 307 | type: "popup-card", 308 | name: "Popup card", 309 | preview: false, 310 | description: 311 | "Replace the more-info dialog for a given entity in the view that includes this card. (Browser Mod)", 312 | }); 313 | } 314 | }); 315 | -------------------------------------------------------------------------------- /js/plugin/popup-card.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from "lit"; 2 | import { property, state } from "lit/decorators.js"; 3 | 4 | import "./popup-card-editor"; 5 | import { getLovelaceRoot, hass_base_el } from "../helpers"; 6 | 7 | class PopupCard extends LitElement { 8 | @property() hass; 9 | @state() _config; 10 | @property({ type: Boolean, reflect: true}) preview = false; 11 | @state() _element; 12 | 13 | static getConfigElement() { 14 | return document.createElement("popup-card-editor"); 15 | } 16 | 17 | static getStubConfig(hass, entities) { 18 | const entity = entities[0]; 19 | return { 20 | entity, 21 | title: "Custom popup", 22 | dismissable: true, 23 | card: { type: "markdown", content: "This replaces the more-info dialog" }, 24 | }; 25 | } 26 | 27 | setConfig(config) { 28 | this._config = config; 29 | (async () => { 30 | const ch = await window.loadCardHelpers(); 31 | this._element = await ch.createCardElement(config.card); 32 | this._element.hass = this.hass; 33 | })(); 34 | } 35 | 36 | updated(changedProperties) { 37 | super.updated(changedProperties); 38 | if (changedProperties.has("hass")) { 39 | if (this._element) this._element.hass = this.hass; 40 | } 41 | } 42 | 43 | getCardSize() { 44 | return 0; 45 | } 46 | 47 | render() { 48 | this.setHidden(!this.preview); 49 | if (!this.preview) return html``; 50 | return html` 51 |
    52 | ${this._config.dismissable 53 | ? html` 54 | 55 | 56 | 57 | ` 58 | : ""} 59 |
    ${this._config.title}
    60 |
    61 | ${this._element} 62 | 67 | ${this._config.right_button !== undefined || 68 | this._config.left_button !== undefined 69 | ? html` 70 |
    71 | 72 | ${this._config.left_button !== undefined 73 | ? html` 74 | 77 | ` 78 | : ""} 79 | 80 | 81 | ${this._config.right_button !== undefined 82 | ? html` 83 | 86 | ` 87 | : ""} 88 | 89 |
    90 | ` 91 | : ""} 92 |
    `; 93 | } 94 | 95 | private setHidden(hidden: boolean): void { 96 | if (this.hasAttribute('hidden') !== hidden) { 97 | this.toggleAttribute('hidden', hidden); 98 | this.dispatchEvent( 99 | new Event('card-visibility-changed', { 100 | bubbles: true, 101 | composed: true, 102 | }), 103 | ); 104 | } 105 | } 106 | 107 | static get styles() { 108 | return css` 109 | ha-card { 110 | background-color: var( 111 | --popup-background-color, 112 | var(--ha-card-background, var(--card-background-color, white)) 113 | ); 114 | } 115 | .app-toolbar { 116 | color: var(--primary-text-color); 117 | background-color: var( 118 | --popup-header-background-color, 119 | var(--popup-background-color, --sidebar-background-color) 120 | ); 121 | display: var(--layout-horizontal_-_display); 122 | flex-direction: var(--layout-horizontal_-_flex-direction); 123 | align-items: var(--layout-center_-_align-items); 124 | height: 64px; 125 | padding: 0 16px; 126 | font-size: var(--app-toolbar-font-size, 20px); 127 | } 128 | ha-icon-button > * { 129 | display: flex; 130 | } 131 | .main-title { 132 | margin-left: 16px; 133 | line-height: 1.3em; 134 | max-height: 2.6em; 135 | overflow: hidden; 136 | text-overflow: ellipsis; 137 | } 138 | 139 | .mdc-dialog__actions { 140 | display: flex; 141 | align-items: center; 142 | justify-content: space-between; 143 | min-height: 52px; 144 | margin: 0px; 145 | padding: 8px; 146 | border-top: 1px solid transparent; 147 | } 148 | `; 149 | } 150 | } 151 | 152 | function popupCardMatch(card, entity, viewIndex, curView) { 153 | return card.type === 'custom:popup-card' && 154 | card.entity === entity && 155 | (viewIndex === curView || card.popup_card_all_views); 156 | } 157 | 158 | function findPopupCardConfig(lovelaceRoot, entity) { 159 | const lovelaceConfig = lovelaceRoot?.lovelace?.config; 160 | if (lovelaceConfig) { 161 | const curView = lovelaceRoot?._curView ?? 0; 162 | // Place current view at the front of the view index lookup array. 163 | // This allows the current view to be checked first for local cards, 164 | // and then the rest of the views for global cards, keeping current view precedence. 165 | let viewLookup = Array.from(Array(lovelaceConfig.views.length).keys()) 166 | viewLookup.splice(curView, 1); 167 | viewLookup.unshift(curView); 168 | for (const viewIndex of viewLookup) { 169 | const view = lovelaceConfig.views[viewIndex]; 170 | if (view.cards) { 171 | for (const card of view.cards) { 172 | if (popupCardMatch(card, entity, viewIndex, curView)) return card; 173 | // Allow for card one level deep. This allows for a sub card in a panel dashboard for example. 174 | if (card.cards) { 175 | for (const subCard of card.cards) { 176 | if (popupCardMatch(subCard, entity, viewIndex, curView)) return subCard; 177 | } 178 | } 179 | } 180 | } 181 | if (view.sections) { 182 | for (const section of view.sections) { 183 | if (section.cards) { 184 | for (const card of section.cards) { 185 | if (popupCardMatch(card, entity, viewIndex, curView)) return card; 186 | // Allow for card one level deep. This allows for a sub card in a panel dashboard for example. 187 | if (card.cards) { 188 | for (const subCard of card.cards) { 189 | if (popupCardMatch(subCard, entity, viewIndex, curView)) return subCard; 190 | } 191 | } 192 | } 193 | } 194 | } 195 | } 196 | } 197 | } 198 | return null; 199 | } 200 | 201 | window.addEventListener("browser-mod-bootstrap", async (ev: CustomEvent) => { 202 | ev.stopPropagation(); 203 | while (!window.browser_mod) { 204 | await new Promise((resolve) => setTimeout(resolve, 1000)); 205 | } 206 | await window.browser_mod.connectionPromise; 207 | 208 | if (!customElements.get("popup-card")) 209 | customElements.define("popup-card", PopupCard); 210 | 211 | let rootMutationObserver = new MutationObserver((mutations) => { 212 | for (const mutation of mutations) { 213 | if (mutation.type === "childList") { 214 | for (const node of mutation.removedNodes) { 215 | if (node instanceof Element && node.localName === "hui-root") { 216 | lovelaceRoot = null; 217 | } 218 | } 219 | for (const node of mutation.addedNodes) { 220 | if (node instanceof Element && node.localName === "hui-root") { 221 | lovelaceRoot = node; 222 | } 223 | } 224 | } 225 | } 226 | }); 227 | let lovelaceRoot = await getLovelaceRoot(document); 228 | if (rootMutationObserver && lovelaceRoot?.parentNode) { 229 | rootMutationObserver.observe(lovelaceRoot.parentNode, { 230 | childList: true, 231 | }); 232 | } 233 | 234 | // popstate will get fired on window.browser_mod?.service("popup", ...) but as this popstate 235 | // is not currently cleared there is no way to distinguish this event properly at this time. 236 | // Hence, setting lovelaceRoot on all popstate which captures, for examople, UI back from History Panel. 237 | ['popstate','location-changed'].forEach(event => 238 | window.addEventListener(event, async (ev) => { 239 | lovelaceRoot = await getLovelaceRoot(document); 240 | }) 241 | ); 242 | 243 | window.addEventListener("hass-more-info", (ev: CustomEvent) => { 244 | if (ev.detail?.ignore_popup_card || !ev.detail?.entityId || !lovelaceRoot) return; 245 | const cardConfig = findPopupCardConfig(lovelaceRoot, ev.detail?.entityId); 246 | if (cardConfig) { 247 | ev.stopPropagation(); 248 | ev.preventDefault(); 249 | let properties = { ...cardConfig } 250 | delete properties.card; 251 | delete properties.entity; 252 | delete properties.type; 253 | window.browser_mod?.service("popup", { 254 | content: cardConfig.card, 255 | ...properties, 256 | }); 257 | setTimeout( 258 | () => 259 | lovelaceRoot.dispatchEvent( 260 | new CustomEvent("hass-more-info", { 261 | bubbles: true, 262 | composed: true, 263 | cancelable: false, 264 | detail: { entityId: "" }, 265 | }) 266 | ), 267 | 10 268 | ); 269 | } 270 | }); 271 | }); 272 | -------------------------------------------------------------------------------- /js/plugin/require-interact.ts: -------------------------------------------------------------------------------- 1 | export const RequireInteractMixin = (SuperClass) => { 2 | return class RequireInteractMixinClass extends SuperClass { 3 | private _interactionResolve; 4 | public firstInteraction = new Promise((resolve) => { 5 | this._interactionResolve = resolve; 6 | }); 7 | 8 | constructor() { 9 | super(); 10 | 11 | this.show_indicator(); 12 | } 13 | 14 | async show_indicator() { 15 | await this.connectionPromise; 16 | 17 | if (!this.registered) return; 18 | 19 | if (this.settings.hideInteractIcon) return; 20 | 21 | const interactSymbol = document.createElement("div"); 22 | document.body.append(interactSymbol); 23 | 24 | interactSymbol.classList.add("browser-mod-require-interaction"); 25 | interactSymbol.attachShadow({ mode: "open" }); 26 | 27 | const styleEl = document.createElement("style"); 28 | interactSymbol.shadowRoot.append(styleEl); 29 | styleEl.innerHTML = ` 30 | :host { 31 | position: fixed; 32 | right: 8px; 33 | bottom: 8px; 34 | color: var(--warning-color, red); 35 | opacity: 0.5; 36 | --mdc-icon-size: 48px; 37 | } 38 | ha-icon::before { 39 | content: "Browser\\00a0Mod"; 40 | font-size: 0.75rem; 41 | position: absolute; 42 | right: 0; 43 | bottom: 90%; 44 | } 45 | video { 46 | display: none; 47 | } 48 | @media all and (max-width: 450px), all and (max-height: 500px) { 49 | ha-icon { 50 | --mdc-icon-size: 30px; 51 | } 52 | ha-icon::before { 53 | content: ""; 54 | } 55 | } 56 | `; 57 | 58 | const icon = document.createElement("ha-icon"); 59 | interactSymbol.shadowRoot.append(icon); 60 | (icon as any).icon = "mdi:gesture-tap"; 61 | 62 | // If we are allowed to play a video, we can assume no interaction is needed 63 | const video = (this._video = document.createElement("video")); 64 | interactSymbol.shadowRoot.append(video); 65 | const vPlay = video.play(); 66 | if (vPlay) { 67 | vPlay 68 | .then(() => { 69 | this._interactionResolve(); 70 | video.pause(); 71 | }) 72 | .catch((e) => { 73 | // if (e.name === "AbortError") { 74 | // this._interactionResolve(); 75 | // } 76 | }); 77 | video.pause(); 78 | } 79 | 80 | window.addEventListener( 81 | "pointerdown", 82 | () => { 83 | this._interactionResolve(); 84 | }, 85 | { once: true } 86 | ); 87 | window.addEventListener( 88 | "touchstart", 89 | () => { 90 | this._interactionResolve(); 91 | }, 92 | { once: true } 93 | ); 94 | 95 | if (this.fully) this._interactionResolve(); 96 | 97 | await this.firstInteraction; 98 | interactSymbol.remove(); 99 | } 100 | }; 101 | }; 102 | -------------------------------------------------------------------------------- /js/plugin/screensaver.ts: -------------------------------------------------------------------------------- 1 | const SCREEN_STATE_ID = "browser_mod-screen_state"; 2 | export const ScreenSaverMixin = (SuperClass) => { 3 | class ScreenSaverMixinClass extends SuperClass { 4 | private _panel; 5 | private _listeners = {}; 6 | private _brightness = 255; 7 | private _screen_state; 8 | 9 | constructor() { 10 | super(); 11 | 12 | const panel = (this._panel = document.createElement("div")); 13 | document.body.append(panel); 14 | panel.classList.add("browser-mod-blackout"); 15 | panel.attachShadow({ mode: "open" }); 16 | 17 | const styleEl = document.createElement("style"); 18 | panel.shadowRoot.append(styleEl); 19 | styleEl.innerHTML = ` 20 | :host { 21 | background: rgba(0,0,0, var(--darkness)); 22 | position: fixed; 23 | left: 0; 24 | top: 0; 25 | bottom: 0; 26 | right: 0; 27 | width: 100%; 28 | height: 100%; 29 | z-index: 10000; 30 | display: block; 31 | pointer-events: none; 32 | } 33 | :host([dark]) { 34 | background: rgba(0,0,0,1); 35 | } 36 | `; 37 | 38 | this.addEventListener("command-screen_off", () => this._screen_off()); 39 | this.addEventListener("command-screen_on", (ev) => this._screen_on(ev)); 40 | 41 | this.addEventListener("fully-update", () => this.send_screen_status()); 42 | this.addEventListener("browser-mod-disconnected", () => this._screen_save_state()); 43 | this.addEventListener("browser-mod-ready", () => this._screen_restore_state()); 44 | } 45 | 46 | send_screen_status() { 47 | this._screen_state = !this._panel.hasAttribute("dark"); 48 | let screen_brightness = this._brightness; 49 | if (this.fully) { 50 | this._screen_state = this.fully_screen; 51 | screen_brightness = this.fully_brightness; 52 | } 53 | 54 | this.sendUpdate({ screen_on: this._screen_state, screen_brightness }); 55 | } 56 | 57 | private _screen_save_state() { 58 | if (this.settings.saveScreenState) { 59 | let storedScreenState = { 60 | screen_on: this._screen_state, 61 | screen_brightness: this._brightness, 62 | }; 63 | localStorage.setItem( 64 | SCREEN_STATE_ID, 65 | JSON.stringify(storedScreenState) 66 | ); 67 | } 68 | } 69 | 70 | private _screen_restore_state() { 71 | if (this.settings.saveScreenState) { 72 | const storedScreenState = localStorage.getItem(SCREEN_STATE_ID); 73 | if (storedScreenState) { 74 | const { screen_on, screen_brightness } = JSON.parse(storedScreenState); 75 | this._screen_state = screen_on; 76 | this._brightness = screen_brightness; 77 | if (this._screen_state) { 78 | this._screen_on({ detail: { brightness: this._brightness } }); 79 | } else { 80 | this._screen_off(); 81 | } 82 | } else { 83 | this._screen_on(); 84 | } 85 | } else { 86 | this._screen_on(); 87 | } 88 | } 89 | 90 | private _screen_off() { 91 | if (this.fully) { 92 | this.fully_screen = false; 93 | } else { 94 | this._panel.setAttribute("dark", ""); 95 | } 96 | this.send_screen_status(); 97 | 98 | const l = () => this._screen_on(); 99 | for (const ev of ["pointerdown", "pointermove", "keydown"]) { 100 | this._listeners[ev] = l; 101 | window.addEventListener(ev, l); 102 | } 103 | } 104 | 105 | private _screen_on(ev = undefined) { 106 | if (this.fully) { 107 | this.fully_screen = true; 108 | if (ev?.detail?.brightness) { 109 | this.fully_brightness = ev.detail.brightness; 110 | } 111 | } else { 112 | if (ev?.detail?.brightness) { 113 | this._brightness = ev.detail.brightness; 114 | this._panel.style.setProperty( 115 | "--darkness", 116 | 1 - ev.detail.brightness / 255 117 | ); 118 | } 119 | this._panel.removeAttribute("dark"); 120 | } 121 | this.send_screen_status(); 122 | 123 | for (const ev of ["pointerdown", "pointermove", "keydown"]) { 124 | if (this._listeners[ev]) { 125 | window.removeEventListener(ev, this._listeners[ev]); 126 | this._listeners[ev] = undefined; 127 | } 128 | } 129 | } 130 | } 131 | return ScreenSaverMixinClass; 132 | }; 133 | -------------------------------------------------------------------------------- /js/plugin/services.ts: -------------------------------------------------------------------------------- 1 | import { getLovelaceRoot, hass_base_el } from "../helpers"; 2 | 3 | export const ServicesMixin = (SuperClass) => { 4 | return class ServicesMixinClass extends SuperClass { 5 | constructor() { 6 | super(); 7 | const cmds = [ 8 | "sequence", 9 | "delay", 10 | "popup", 11 | "more_info", 12 | "close_popup", 13 | "notification", 14 | "navigate", 15 | "refresh", 16 | "set_theme", 17 | "console", 18 | "javascript", 19 | ]; 20 | for (const service of cmds) { 21 | this.addEventListener(`command-${service}`, (ev) => { 22 | this.service(service, ev.detail); 23 | }); 24 | } 25 | 26 | document.body.addEventListener("ll-custom", (ev: CustomEvent) => { 27 | if (ev.detail.browser_mod) { 28 | this._service_action(ev.detail.browser_mod); 29 | } 30 | }); 31 | } 32 | 33 | async service(service, data) { 34 | this._service_action({ service, data, target: {} }); 35 | } 36 | 37 | async _service_action({ service, data, target }) { 38 | if (data === undefined) data = {}; 39 | if (!service) { 40 | console.error( 41 | "Browser Mod: Service parameter not specified in service call." 42 | ); 43 | return; 44 | } 45 | let _service: String = service; 46 | if ( 47 | (!_service.startsWith("browser_mod.") && _service.includes(".")) || 48 | data.browser_id !== undefined || data.user_id !== undefined 49 | ) { 50 | const d = { ...data }; 51 | const t = { ...target }; 52 | if (d.browser_id === "THIS") d.browser_id = this.browserID; 53 | if (d.user_id === "THIS") d.user_id = this.hass?.user.id; 54 | // CALL HOME ASSISTANT SERVICE 55 | const [domain, srv] = _service.split("."); 56 | return this.hass.callService(domain, srv, d, t); 57 | } 58 | 59 | if (_service.startsWith("browser_mod.")) { 60 | _service = _service.substring(12); 61 | } 62 | 63 | switch (_service) { 64 | case "sequence": 65 | for (const a of data.sequence) await this._service_action(a); 66 | break; 67 | case "delay": 68 | await new Promise((resolve) => setTimeout(resolve, data.time)); 69 | break; 70 | 71 | case "more_info": 72 | const { entity, large, ignore_popup_card } = data; 73 | this.showMoreInfo(entity, large, ignore_popup_card); 74 | break; 75 | 76 | case "popup": 77 | const { title, content, ...d } = data; 78 | for (const [k, v] of Object.entries(d)) { 79 | if (k.endsWith("_action")) { 80 | let actions = v; // Force Closure. See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Closures#creating_closures_in_loops_a_common_mistake 81 | let key = k; // If required use key in anonymous function to avoid closure issue as per above comment 82 | d[k] = (ext_data?) => { 83 | if (!Array.isArray(actions)) { 84 | actions = [actions]; 85 | } 86 | actions.forEach((actionItem) => { 87 | var { action, service, target, data } = actionItem as any; 88 | service = (action === undefined || action === "call-service") ? service : action; 89 | this._service_action({ 90 | service, 91 | target, 92 | data: { ...data, ...ext_data }, 93 | }); 94 | }); 95 | }; 96 | } 97 | } 98 | this.showPopup(title, content, d); 99 | break; 100 | 101 | case "notification": 102 | { 103 | data.action_action = data.action; 104 | delete data.action; 105 | var { message, action_text, action_action, duration, dismissable } = 106 | data; 107 | let act = undefined; 108 | act = { 109 | text: action_text, 110 | action: (ext_data?) => { 111 | if (action_action && action_text) { 112 | if (!Array.isArray(action_action)) { 113 | action_action = [action_action]; 114 | } 115 | action_action.forEach((actionItem) => { 116 | var { action, service, target, data } = actionItem; 117 | service = (action === undefined || action === "call-service") ? service : action; 118 | this._service_action({ 119 | service, 120 | target, 121 | data: { ...data, ...ext_data }, 122 | }); 123 | }) 124 | } 125 | } 126 | }; 127 | const base = await hass_base_el(); 128 | base.dispatchEvent( 129 | new CustomEvent("hass-notification", { 130 | detail: { 131 | message, 132 | action: act, 133 | duration, 134 | dismissable, 135 | }, 136 | }) 137 | ); 138 | } 139 | break; 140 | 141 | case "close_popup": 142 | await this.closePopup(); 143 | break; 144 | 145 | case "navigate": 146 | this.browser_navigate(data.path); 147 | break; 148 | 149 | case "refresh": 150 | window.location.href = window.location.href; 151 | break; 152 | 153 | case "set_theme": 154 | { 155 | const detail = { ...data }; 156 | if (detail.theme === "auto") detail.theme = undefined; 157 | if (detail.dark === "auto") detail.dark = undefined; 158 | if (detail.dark === "light") detail.dark = false; 159 | if (detail.dark === "dark") detail.dark = true; 160 | if (detail.primaryColor && Array.isArray(detail.primaryColor)) { 161 | const [r, g, b] = detail.primaryColor; 162 | detail.primaryColor = 163 | "#" + 164 | ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); 165 | } 166 | if (detail.accentColor && Array.isArray(detail.accentColor)) { 167 | const [r, g, b] = detail.accentColor; 168 | detail.accentColor = 169 | "#" + 170 | ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); 171 | } 172 | 173 | const base = await hass_base_el(); 174 | base.dispatchEvent(new CustomEvent("settheme", { detail })); 175 | } 176 | break; 177 | 178 | case "console": 179 | if ( 180 | Object.keys(data).length > 1 || 181 | (data && data.message === undefined) 182 | ) 183 | console.dir(data); 184 | else console.log(data.message); 185 | break; 186 | 187 | case "javascript": 188 | // Reload Lovelace function 189 | const lovelace_reload = async () => { 190 | let root = await getLovelaceRoot(document); 191 | if (root) root.dispatchEvent(new CustomEvent("config-refresh")); 192 | }; 193 | 194 | const log = async (message) => 195 | this.connection.sendMessage({ type: "browser_mod/log", message }); 196 | 197 | const code = ` 198 | "use strict"; 199 | ${data.code} 200 | `; 201 | 202 | const fn = new Function( 203 | "hass", 204 | "data", 205 | "service", 206 | "log", 207 | "lovelace_reload", 208 | code 209 | ); 210 | fn(this.hass, data, window.browser_mod.service, log, lovelace_reload); 211 | break; 212 | } 213 | } 214 | }; 215 | }; 216 | -------------------------------------------------------------------------------- /js/plugin/types.ts: -------------------------------------------------------------------------------- 1 | const a = {}; 2 | 3 | import { BrowserMod } from "./main"; 4 | interface FullyKiosk { 5 | // Types from https://www.fully-kiosk.com/de/#websiteintegration 6 | 7 | // Get device info 8 | getIp4Address: { (): String }; 9 | getIp6Address: { (): String }; 10 | getHostname: { (): String }; 11 | getHostname6: { (): String }; 12 | getMacAddress: { (): String }; 13 | getMacAddressForInterface: { (_interface: String): String }; 14 | getWifiSsid: { (): String }; 15 | getWifiBssid: { (): String }; 16 | getWifiSignalLevel: { (): String }; 17 | getSerialNumber: { (): String }; 18 | getAndroidId: { (): String }; 19 | getDeviceId: { (): String }; 20 | getDeviceName: { (): String }; 21 | getImei: { (): String }; 22 | getSimSerialNumber: { (): String }; 23 | getBatteryLevel: { (): Number }; 24 | getScreenBrightness: { (): Number }; 25 | getScreenOrientation: { (): Number }; 26 | getDisplayWidth: { (): Number }; 27 | getDisplayHeight: { (): Number }; 28 | getScreenOn: { (): Boolean }; 29 | isPlugged: { (): Boolean }; 30 | isKeyboardVisible: { (): Boolean }; 31 | isWifiEnabled: { (): Boolean }; 32 | isWifiConnected: { (): Boolean }; 33 | isNetworkConnected: { (): Boolean }; 34 | isBluetoothEnabled: { (): Boolean }; 35 | isScreenRotationLocked: { (): Boolean }; 36 | getFullyVersion: { (): String }; 37 | getFullyVersionCode: { (): Number }; 38 | getWebviewVersion: { (): String }; 39 | getAndroidVersion: { (): String }; 40 | getAndroidSdk: { (): Number }; 41 | getDeviceModel: { (): String }; 42 | 43 | getInternalStorageTotalSpace: { (): Number }; 44 | getInternalStorageFreeSpace: { (): Number }; 45 | getExternalStorageTotalSpace: { (): Number }; 46 | getExternalStorageFreeSpace: { (): Number }; 47 | 48 | getSensorInfo: { (): String }; 49 | getSensorValue: { (type: Number): Number }; 50 | getSensorValues: { (type: Number): String }; 51 | 52 | getAllRxBytesMobile: { (): Number }; 53 | getAllTxBytesMobile: { (): Number }; 54 | getAllRxBytesWifi: { (): Number }; 55 | getAllTxBytesWifi: { (): Number }; 56 | 57 | // Controll device, show notifications, send network data etc. 58 | turnScreenOn: { () }; 59 | turnScreenOff: { (keepAlive?: Boolean) }; 60 | forceSleep: { () }; 61 | showToast: { (text: String) }; 62 | setScreenBrightness: { (level: Number) }; 63 | enableWifi: { () }; 64 | disableWifi: { () }; 65 | enableBluetooth: { () }; 66 | disableBluetooth: { () }; 67 | showKeyboard: { () }; 68 | hideKeyboard: { () }; 69 | openWifiSettings: { () }; 70 | openBluetoothSettings: { () }; 71 | vibrate: { (millis: Number) }; 72 | sendHexDataToTcpPort: { (hexData: String, host: String, port: Number) }; 73 | showNotification: { 74 | (title: String, text: String, url: String, highPriority: Boolean); 75 | }; 76 | log: { (type: Number, tag: String, message: String) }; 77 | 78 | copyTextToClipboard: { (text: String) }; 79 | getClipboardText: { (): String }; 80 | getClipboardHtmlText: { (): String }; 81 | 82 | // Download and manage files 83 | deleteFile: { (path: String) }; 84 | deleteFolder: { (path: String) }; 85 | emptyFolder: { (path: String) }; 86 | createFolder: { (path: String) }; 87 | getFileList: { (folder: String): String }; 88 | downloadFile: { (url: String, dirName: String) }; 89 | unzipFile: { (fileName: String) }; 90 | downloadAndUnzipFile: { (url: String, dirName: String) }; 91 | 92 | // Use TTS, multimedia and PDF 93 | textToSpeech: { 94 | (text: String, locale?: String, engine?: String, queue?: boolean); 95 | }; 96 | stopTextToSpeech: { () }; 97 | 98 | playVideo: { 99 | ( 100 | url: String, 101 | loop: Boolean, 102 | showControls: Boolean, 103 | exitOnTouc: Boolean, 104 | exitOnCompletion: Boolean 105 | ); 106 | }; 107 | stopVideo: { () }; 108 | 109 | setAudioVolume: { (level: Number, stream: Number) }; 110 | playSound: { (url: String, loop: Boolean, stream?: Number) }; 111 | stopSound: { () }; 112 | showPdf: { (url: String) }; 113 | getAudioVolume: { (stream: String): Number }; 114 | isWiredHeadsetOn: { (): Boolean }; 115 | isMusicActive: { (): Boolean }; 116 | 117 | // Control fully and browsing 118 | loadStartUrl: { () }; 119 | setActionBarTitle: { (text: String) }; 120 | startScreensaver: { () }; 121 | stopScreensaver: { () }; 122 | startDaydream: { () }; 123 | stopDaydream: { () }; 124 | addToHomeScreen: { () }; 125 | print: { () }; 126 | exit: { () }; 127 | restartApp: { () }; 128 | getScreenshotPngBase64: { (): String }; 129 | loadStatsCSV: { (): String }; 130 | clearCache: { () }; 131 | clearFormData: { () }; 132 | clearHistory: { () }; 133 | clearCookies: { () }; 134 | clearCookiesForUrl: { (url: String) }; 135 | focusNextTab: { () }; 136 | focusPrevTab: { () }; 137 | focusTabByIndex: { (index: Number) }; 138 | getCurrentTabIndex: { (): Number }; 139 | shareUrl: { () }; 140 | closeTabByIndex: { (index: Number) }; 141 | closeThisTab: { () }; 142 | getTabList: { (): String }; 143 | loadUrlInTabByIndex: { (index: Number, url: String) }; 144 | loadUrlInNewTab: { (url: String, focus: Boolean) }; 145 | getThisTabIndex: { (): Number }; 146 | focusThisTab: { () }; 147 | 148 | // Barcode Scanner 149 | scanQrCode: { 150 | ( 151 | prompt: String, 152 | resultUrl: String, 153 | cameraId?: Number, 154 | timeout?: Number, 155 | beepEnabled?: Boolean, 156 | showCancelButton?: Boolean, 157 | useFlashlight?: Boolean 158 | ); 159 | }; 160 | 161 | // Bluetooth Interface 162 | btOpenByMac: { (mac: String): Boolean }; 163 | btOpenByUuid: { (uuid: String): Boolean }; 164 | btOpenByName: { (name: String): Boolean }; 165 | 166 | btIsConnected: { (): Boolean }; 167 | btGetDeviceInfoJson: { (): String }; 168 | btClose: { () }; 169 | 170 | btSendStringData: { (stringData: String): Boolean }; 171 | btSendHexData: { (hexData: String): Boolean }; 172 | btSendByteData: { (data: Number[]): Boolean }; 173 | 174 | // Read NFC Tags 175 | nfcScanStart: { (flags?: Number, debounceMs?: Number): Boolean }; 176 | nfcScanStop: { (): Boolean }; 177 | 178 | // Respond to events 179 | bind: { (event: String, action: String) }; 180 | // Events: 181 | // screenOn 182 | // screenOff 183 | // showKeyboard 184 | // hideKeyboard 185 | // networkDisconnect 186 | // networkReconnect 187 | // internetDisconnect 188 | // internetReconnect 189 | // unplugged 190 | // pluggedAC 191 | // pluggedUSB 192 | // pluggedWireless 193 | // onScreensaverStart 194 | // onScreensaverStop 195 | // onDaydreamStart 196 | // onDaydreamStop 197 | // onBatteryLevelChanged 198 | // onVolumeUp 199 | // onVolueDown 200 | // onMotion 201 | // onDarkness 202 | // onMovement 203 | // onIBeacon 204 | // broadcastReceived 205 | 206 | // onQrScanSuccess 207 | // onQrScanCancelled 208 | 209 | // onBtConnectSuccess 210 | // onBtConnectFailure 211 | // onBtDataRead 212 | 213 | // onNdefDiscovered 214 | // onNfcTagDiscovered 215 | // onNfcTagRemoved 216 | 217 | // Manage Apps, Activities, Intents etc. 218 | startApplication: { (packageName: String, action?: String, url?: String) }; 219 | startIntent: { (url: String) }; 220 | broadcastIntent: { (url: String) }; 221 | isInForeground: { (): Boolean }; 222 | bringToForeground: { (millis?: Number) }; 223 | bringToBackground: { () }; 224 | installApkFile: { (url: String) }; 225 | enableMaintenanceMode: { () }; 226 | disableMaintenanceMode: { () }; 227 | setMessageOverlay: { (text: String) }; 228 | registerBroadcastReceiver: { (action: String) }; 229 | unregisterBroadcastReceiver: { (action: String) }; 230 | 231 | // Motion detection 232 | startMotionDetection: { () }; 233 | stopMotionDetection: { () }; 234 | isMotionDetectionRunning: { (): Boolean }; 235 | getCamshotJpgBase64: { (): String }; 236 | triggerMotion: { () }; 237 | 238 | // Manage all Fully settings 239 | getStartUrl: { (): String }; 240 | setStartUrl: { (url: String) }; 241 | 242 | getBooleanSetting: { (key: String): String }; 243 | getStringSetting: { (key: String): String }; 244 | 245 | setBooleanSetting: { (key: String, value: Boolean) }; 246 | setStringSetting: { (key: String, value: String) }; 247 | importSettingsFile: { (url: String) }; 248 | } 249 | 250 | declare global { 251 | interface Window { 252 | browser_mod?: BrowserMod; 253 | browser_mod_log?: any; 254 | fully?: FullyKiosk; 255 | hassConnection?: Promise; 256 | customCards?: [{}?]; 257 | loadCardHelpers?: { () }; 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /js/plugin/version.ts: -------------------------------------------------------------------------------- 1 | import pjson from "../../package.json"; 2 | import { selectTree } from "../helpers"; 3 | 4 | export const VersionMixin = (SuperClass) => { 5 | return class VersionMixinClass extends SuperClass { 6 | _version: string; 7 | _notificationPending: boolean = false; 8 | 9 | constructor() { 10 | super(); 11 | this._version = pjson.version; 12 | this.addEventListener("browser-mod-ready", async () => { 13 | await this._checkVersion(); 14 | }); 15 | this.addEventListener("browser-mod-disconnected", () => { 16 | this._notificationPending = false; 17 | }); 18 | } 19 | 20 | async _checkVersion() { 21 | if (this._data?.version && this._data.version !== this._version) { 22 | if (!this._notificationPending) { 23 | this._notificationPending = true; 24 | await this._loaclNotification( 25 | this._data.version, 26 | this._version 27 | ) 28 | } 29 | } 30 | } 31 | 32 | async _loaclNotification(serverVersion, clientVersion) { 33 | // Wait for any other notifications to expire 34 | let haToast; 35 | do { 36 | await new Promise((resolve) => setTimeout(resolve, 1000)); 37 | haToast = await selectTree( 38 | document.body, 39 | "home-assistant $ notification-manager $ ha-toast", 40 | false, 41 | 1000) 42 | } while (haToast); 43 | const service = "browser_mod.notification"; 44 | const message = `Browser Mod version mismatch! Browser: ${clientVersion}, Home Assistant: ${serverVersion}`; 45 | const data = { 46 | message: message, 47 | duration: -1, // 60 seconds 48 | dismissable: true, 49 | action_text: "Reload", 50 | browser_id: "THIS", 51 | action: { 52 | service: "browser_mod.refresh", 53 | data: { 54 | browser_id: "THIS", 55 | }, 56 | }, 57 | } 58 | await this.service(service, data); 59 | } 60 | }; 61 | }; 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "browser_mod", 3 | "private": true, 4 | "version": "2.4.0", 5 | "description": "", 6 | "scripts": { 7 | "build": "rollup -c", 8 | "watch": "rollup -c --watch" 9 | }, 10 | "keywords": [], 11 | "author": "Thomas Lovén", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@babel/core": "^7.21.4", 15 | "@rollup/plugin-babel": "^5.3.1", 16 | "@rollup/plugin-json": "^4.1.0", 17 | "@rollup/plugin-node-resolve": "^13.3.0", 18 | "lit": "^2.7.2", 19 | "rollup": "^2.79.2", 20 | "rollup-plugin-terser": "^7.0.2", 21 | "rollup-plugin-typescript2": "^0.32.1", 22 | "typescript": "^4.9.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import nodeResolve from "@rollup/plugin-node-resolve"; 2 | import json from "@rollup/plugin-json"; 3 | import typescript from "rollup-plugin-typescript2"; 4 | import { terser } from "rollup-plugin-terser"; 5 | import babel from "@rollup/plugin-babel"; 6 | 7 | const dev = process.env.ROLLUP_WATCH; 8 | 9 | export default [ 10 | { 11 | input: "js/plugin/main.ts", 12 | output: { 13 | file: "custom_components/browser_mod/browser_mod.js", 14 | format: "es", 15 | }, 16 | plugins: [ 17 | nodeResolve(), 18 | json(), 19 | typescript(), 20 | babel.babel({ 21 | exclude: "node_modules/**", 22 | }), 23 | !dev && terser({ format: { comments: false } }), 24 | ], 25 | }, 26 | { 27 | input: "js/config_panel/main.ts", 28 | output: { 29 | file: "custom_components/browser_mod/browser_mod_panel.js", 30 | format: "es", 31 | }, 32 | plugins: [ 33 | nodeResolve(), 34 | json(), 35 | typescript(), 36 | babel.babel({ 37 | exclude: "node_modules/**", 38 | }), 39 | !dev && terser({ format: { comments: false } }), 40 | ], 41 | }, 42 | ]; 43 | -------------------------------------------------------------------------------- /test/automations.yaml: -------------------------------------------------------------------------------- 1 | - id: "1660669793583" 2 | alias: Toggle bed light 3 | description: "" 4 | trigger: 5 | - platform: time_pattern 6 | seconds: /3 7 | condition: [] 8 | action: 9 | - type: toggle 10 | device_id: 98861bdf58b3c79183c03be06da14f27 11 | entity_id: light.bed_light 12 | domain: light 13 | mode: single 14 | 15 | - alias: Popup when kitchen light togggled 16 | trigger: 17 | - platform: state 18 | entity_id: light.kitchen_lights 19 | action: 20 | - service: browser_mod.popup 21 | data: 22 | title: automation 23 | content: 24 | type: markdown 25 | content: "{%raw%}{{states('light.bed_light')}}{%endraw%}" 26 | -------------------------------------------------------------------------------- /test/configuration.yaml: -------------------------------------------------------------------------------- 1 | default_config: 2 | 3 | automation: !include test/automations.yaml 4 | 5 | demo: 6 | 7 | http: 8 | use_x_forwarded_for: true 9 | trusted_proxies: 10 | - 172.17.0.0/24 11 | 12 | logger: 13 | default: warning 14 | logs: 15 | custom_components.browser_mod: info 16 | 17 | # debugpy: 18 | 19 | # browser_mod: 20 | # devices: 21 | # camdevice: 22 | # camera: true 23 | # testdevice: 24 | # alias: test 25 | # fully: 26 | # force_stay_awake: true 27 | # fully2: 28 | # screensaver: true 29 | 30 | lovelace: 31 | mode: storage 32 | dashboards: 33 | lovelace-yaml: 34 | mode: yaml 35 | title: yaml 36 | filename: test/lovelace.yaml 37 | 38 | frontend: 39 | themes: 40 | red: 41 | primary-color: red 42 | test: 43 | card-mod-theme: test 44 | card-mod-more-info-yaml: | 45 | $: | 46 | .mdc-dialog { 47 | backdrop-filter: grayscale(0.7) blur(5px); 48 | } 49 | 50 | tts: 51 | - platform: google_translate 52 | # base_url: !env_var OUT_ADDR 53 | 54 | script: 55 | cm_debug: 56 | sequence: 57 | - service: browser_mod.debug 58 | print_id: 59 | sequence: 60 | - service: system_log.write 61 | data: 62 | message: "Button was clicked in {{browser_id}}." 63 | -------------------------------------------------------------------------------- /test/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.0" 2 | 3 | services: 4 | test: 5 | image: thomasloven/hass-custom-devcontainer 6 | environment: 7 | - HASS_USERNAME 8 | - HASS_PASSWORD 9 | - LOVELACE_LOCAL_FILES 10 | - LOVELACE_PLUGINS 11 | volumes: 12 | - ./configuration.yaml:/config/configuration.yaml:ro 13 | - .:/config/test:ro 14 | - ..:/config/www/workspace 15 | - ../custom_components:/config/custom_components 16 | ports: 17 | - 8125:8123 18 | -------------------------------------------------------------------------------- /test/lovelace.yaml: -------------------------------------------------------------------------------- 1 | name: browser_mod 2 | 3 | views: 4 | - title: Player 5 | cards: 6 | - type: custom:browser-player 7 | 8 | - !include views/various.yaml 9 | - !include views/more-info.yaml 10 | - !include views/popup.yaml 11 | - !include views/popup-card.yaml 12 | - !include views/notification.yaml 13 | 14 | - !include views/frontend-backend.yaml 15 | -------------------------------------------------------------------------------- /test/views/frontend-backend.yaml: -------------------------------------------------------------------------------- 1 | title: frontend vs backend 2 | 3 | cards: 4 | - type: entities 5 | entities: 6 | - light.bed_light 7 | - light.kitchen_lights 8 | 9 | - type: button 10 | name: fire-dom-event 11 | tap_action: 12 | action: fire-dom-event 13 | browser_mod: 14 | service: browser_mod.popup 15 | data: 16 | title: fire-dom-event 17 | content: 18 | type: markdown 19 | content: "{{states('light.bed_light')}}" 20 | 21 | - type: button 22 | name: call-service 23 | tap_action: 24 | action: call-service 25 | service: browser_mod.popup 26 | data: 27 | title: call-service 28 | content: 29 | type: markdown 30 | content: "{{states('light.bed_light')}}" 31 | -------------------------------------------------------------------------------- /test/views/more-info.yaml: -------------------------------------------------------------------------------- 1 | x-anchors: 2 | default: &default 3 | type: button 4 | icon: mdi:star 5 | 6 | title: More-info 7 | cards: 8 | - type: custom:popup-card 9 | entity: light.ceiling_lights 10 | title: Custom popup 11 | card: 12 | type: markdown 13 | content: A custom popup card 14 | 15 | - <<: *default 16 | name: light.bed_light 17 | tap_action: 18 | action: fire-dom-event 19 | browser_mod: 20 | service: more_info 21 | data: 22 | entity: light.bed_light 23 | 24 | - <<: *default 25 | name: Popup card 26 | tap_action: 27 | action: fire-dom-event 28 | browser_mod: 29 | service: more_info 30 | data: 31 | entity: light.ceiling_lights 32 | - <<: *default 33 | name: Ignore popup-card 34 | tap_action: 35 | action: fire-dom-event 36 | browser_mod: 37 | service: more_info 38 | data: 39 | entity: light.ceiling_lights 40 | ignore_popup_card: true 41 | -------------------------------------------------------------------------------- /test/views/notification.yaml: -------------------------------------------------------------------------------- 1 | x-anchors: 2 | default: &default 3 | type: button 4 | icon: mdi:star 5 | 6 | title: Notification 7 | 8 | cards: 9 | - <<: *default 10 | name: Notification 11 | tap_action: 12 | action: fire-dom-event 13 | browser_mod: 14 | service: notification 15 | data: 16 | message: Notification 17 | -------------------------------------------------------------------------------- /test/views/popup-card.yaml: -------------------------------------------------------------------------------- 1 | title: Popup-card 2 | cards: 3 | - type: custom:popup-card 4 | entity: light.bed_light 5 | title: Custom popup 6 | card: 7 | type: markdown 8 | content: A custom popup card 9 | 10 | - type: entities 11 | entities: 12 | - light.bed_light 13 | - light.ceiling_lights 14 | - light.kitchen_lights 15 | -------------------------------------------------------------------------------- /test/views/popup.yaml: -------------------------------------------------------------------------------- 1 | x-anchors: 2 | default: &default 3 | type: button 4 | icon: mdi:star 5 | 6 | desc: &desc 7 | type: markdown 8 | style: | 9 | code { 10 | font-size: 8pt; 11 | line-height: normal; 12 | white-space: pre-wrap; 13 | } 14 | 15 | title: Popup 16 | cards: 17 | - <<: *desc 18 | content: | 19 | ## Common: 20 | ``` 21 | type: button 22 | icon: mdi:star 23 | tap_action: 24 | action: fire-dom-event 25 | browser_mod: 26 | ... 27 | ``` 28 | 29 | # Default 30 | - type: vertical-stack 31 | cards: 32 | - <<: *desc 33 | content: | 34 | ``` 35 | service: popup 36 | data: 37 | title: Default 38 | content: 39 | type: markdown 40 | content: Popup! 41 | ``` 42 | - <<: *default 43 | name: Default 44 | tap_action: 45 | action: fire-dom-event 46 | browser_mod: 47 | service: popup 48 | data: 49 | title: Default 50 | content: 51 | type: markdown 52 | content: Popup! 53 | 54 | # size: wide 55 | - type: vertical-stack 56 | cards: 57 | - <<: *desc 58 | content: | 59 | ``` 60 | service: popup 61 | data: 62 | title: Wide 63 | size: wide 64 | content: 65 | type: markdown 66 | content: Popup! 67 | ``` 68 | - <<: *default 69 | name: Wide 70 | tap_action: 71 | action: fire-dom-event 72 | browser_mod: 73 | service: popup 74 | data: 75 | title: Wide 76 | size: wide 77 | content: 78 | type: markdown 79 | content: Popup! 80 | 81 | # size: fullscreen 82 | - type: vertical-stack 83 | cards: 84 | - <<: *desc 85 | content: | 86 | ``` 87 | service: popup 88 | data: 89 | title: Fullscreen 90 | size: fullscreen 91 | content: 92 | type: markdown 93 | content: Popup! 94 | ``` 95 | - <<: *default 96 | name: Fullscreen 97 | tap_action: 98 | action: fire-dom-event 99 | browser_mod: 100 | service: popup 101 | data: 102 | title: Fullscreen 103 | size: fullscreen 104 | content: 105 | type: markdown 106 | content: Popup! 107 | 108 | # dismissable: false 109 | - type: vertical-stack 110 | cards: 111 | - <<: *desc 112 | content: | 113 | ``` 114 | service: popup 115 | data: 116 | title: Non-dismissable 117 | dismissable: false 118 | content: 119 | type: markdown 120 | content: Popup! 121 | ``` 122 | - <<: *default 123 | name: Non-dismissable 124 | tap_action: 125 | action: fire-dom-event 126 | browser_mod: 127 | service: popup 128 | data: 129 | title: Non-dismissable 130 | dismissable: false 131 | content: 132 | type: markdown 133 | content: Popup! 134 | 135 | # autoclose: true 136 | - type: vertical-stack 137 | cards: 138 | - <<: *desc 139 | content: | 140 | ``` 141 | service: popup 142 | data: 143 | title: Autoclose 144 | autoclose: true 145 | content: 146 | type: markdown 147 | content: Popup! 148 | ``` 149 | - <<: *default 150 | name: Autoclose 151 | tap_action: 152 | action: fire-dom-event 153 | browser_mod: 154 | service: popup 155 | data: 156 | title: Autoclose 157 | autoclose: true 158 | content: 159 | type: markdown 160 | content: Popup! 161 | 162 | # timeout: 5000 163 | - type: vertical-stack 164 | cards: 165 | - <<: *desc 166 | content: | 167 | ``` 168 | service: popup 169 | data: 170 | title: Timeout 171 | timeout: 5000 172 | content: 173 | type: markdown 174 | content: Popup! 175 | ``` 176 | - <<: *default 177 | name: Timeout 178 | tap_action: 179 | action: fire-dom-event 180 | browser_mod: 181 | service: popup 182 | data: 183 | title: Timeout 184 | timeout: 5000 185 | content: 186 | type: markdown 187 | content: Popup! 188 | 189 | - <<: *default 190 | name: Card content 191 | tap_action: 192 | action: fire-dom-event 193 | browser_mod: 194 | service: popup 195 | data: 196 | title: Card 197 | content: 198 | type: entities 199 | entities: 200 | - light.bed_light 201 | - light.ceiling_lights 202 | - light.kitchen_lights 203 | - <<: *default 204 | name: Card footer 205 | tap_action: 206 | action: fire-dom-event 207 | browser_mod: 208 | service: popup 209 | data: 210 | title: Card 211 | right_button: OK 212 | content: 213 | type: entities 214 | entities: 215 | - light.bed_light 216 | - light.ceiling_lights 217 | - light.kitchen_lights 218 | 219 | - <<: *default 220 | name: Form content 221 | tap_action: 222 | action: fire-dom-event 223 | browser_mod: 224 | service: popup 225 | data: 226 | title: Form 227 | content: 228 | - name: Name 229 | label: Label 230 | default: Default value 231 | selector: 232 | text: 233 | - name: Name 234 | label: Label 235 | default: Default value 236 | selector: 237 | text: 238 | - name: Name 239 | label: Label 240 | default: Default value 241 | selector: 242 | text: 243 | - name: Name 244 | label: Label 245 | default: Default value 246 | selector: 247 | text: 248 | - name: Name 249 | label: Label 250 | default: Default value 251 | selector: 252 | text: 253 | - name: Name 254 | label: Label 255 | default: Default value 256 | selector: 257 | text: 258 | - name: Name 259 | label: Label 260 | default: Default value 261 | selector: 262 | text: 263 | - name: Name 264 | label: Label 265 | default: Default value 266 | selector: 267 | text: 268 | - name: Name 269 | label: Label 270 | default: Default value 271 | selector: 272 | text: 273 | - name: Name 274 | label: Label 275 | default: Default value 276 | selector: 277 | text: 278 | - name: Name 279 | label: Label 280 | default: Default value 281 | selector: 282 | text: 283 | - name: Name 284 | label: Label 285 | default: Default value 286 | selector: 287 | text: 288 | -------------------------------------------------------------------------------- /test/views/various.yaml: -------------------------------------------------------------------------------- 1 | default: &default 2 | type: button 3 | icon: mdi:star 4 | 5 | title: services 6 | cards: 7 | - <<: *default 8 | name: Navigate 9 | tap_action: 10 | action: fire-dom-event 11 | browser_mod: 12 | service: navigate 13 | data: 14 | path: /browser-mod/ 15 | 16 | - <<: *default 17 | name: Refresh 18 | tap_action: 19 | action: fire-dom-event 20 | browser_mod: 21 | service: refresh 22 | 23 | - <<: *default 24 | name: Primary color teal 25 | tap_action: 26 | action: fire-dom-event 27 | browser_mod: 28 | service: set_theme 29 | data: 30 | primaryColor: teal 31 | 32 | - <<: *default 33 | name: Reset primary color 34 | tap_action: 35 | action: fire-dom-event 36 | browser_mod: 37 | service: set_theme 38 | data: 39 | primaryColor: ~ 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "moduleResolution": "node", 5 | "resolveJsonModule": true, 6 | "allowSyntheticDefaultImports": true, 7 | "experimentalDecorators": true 8 | } 9 | } 10 | --------------------------------------------------------------------------------