Compare commits

..

No commits in common. "1a3a7b2e953652d9c3b6dfa3f6d07cbe9fe11c00" and "c3f2ebd8d4cec458e03a152420a3622f7381c2f2" have entirely different histories.

69 changed files with 5230 additions and 7867 deletions

View File

@ -1,40 +0,0 @@
{
"name": "hass-browser_mod Dev",
"image": "thomasloven/hass-custom-devcontainer",
"postCreateCommand": "sudo -E container setup-dev && npm add",
"containerEnv": {
"DEVCONTAINER": "1"
},
"forwardPorts": [8123],
"mounts": [
"source=${localWorkspaceFolder},target=/config/www/workspace,type=bind",
"source=${localWorkspaceFolder}/test,target=/config/test,type=bind",
"source=${localWorkspaceFolder}/test/configuration.yaml,target=/config/configuration.yaml,type=bind",
"source=${localWorkspaceFolder}/custom_components,target=/config/custom_components,type=bind"
],
"runArgs": ["--env-file", "${localWorkspaceFolder}/test/.env"],
"extensions": [
"github.vscode-pull-request-github",
"esbenp.prettier-vscode",
"spmeesseman.vscode-taskexplorer",
"ms-python.python"
],
"settings": {
"files.eol": "\n",
"editor.tabSize": 2,
"editor.formatOnPaste": false,
"editor.formatOnSave": true,
"editor.formatOnType": true,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"files.trimTrailingWhitespace": true,
"python.linting.pylintEnabled": false,
"python.linting.flake8Enabled": true,
"python.linting.enabled": true,
"python.formatting.provider": "black"
}
}

View File

@ -1,2 +0,0 @@
[flake8]
max-line-length = 88

2
.gitattributes vendored
View File

@ -1,3 +1 @@
custom_components/browser_mod/browser_mod.js binary custom_components/browser_mod/browser_mod.js binary
custom_components/browser_mod/browser_mod_panel.js binary
package-lock.json binary

View File

@ -1,48 +0,0 @@
---
name: Bug report
about: For reporting bugs or unexpected behavior
title: ''
labels: ''
assignees: ''
---
My Home Assistant version: 0.XX.X
**What I am doing:**
**What I expected to happen:**
**What happened instead:**
**Minimal** steps to reproduce:
```yaml
# The least amount of code or steps possible to reproduce my error
# Put your code/steps here
```
**Error messages from the browser console:**
```
// Select everything from the browser console and copy it
// Paste it here
```
---
**By replacing the space in the checkboxes ([ ]) with an X below, I indicate that I:**
- [ ] Understand that this is a channel for reporting bugs, not a support forum (https://community.home-assistant.io/).
- [ ] Have made sure I am using the latest version of the plugin.
- [ ] Have followed the troubleshooting steps of the "Common Problems" section of https://github.com/thomasloven/hass-config/wiki/Lovelace-Plugins.
- [ ] 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.

View File

@ -1,7 +0,0 @@
---
name: Feature request
about: For suggesting new features
title: ''
labels: 'feature-request'
assignees: ''
---

9
.github/stale.yml vendored
View File

@ -1,9 +0,0 @@
daysUntilStale: 60
daysUntilClose: 7
exemptLabels:
- pinned
- feature-request
staleLabel: stale
markComment: >
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.
closeComment: false

View File

@ -1,14 +0,0 @@
name: Validate with hassfest
on:
push:
pull_request:
schedule:
- cron: '0 0 * * *'
jobs:
validate:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v2"
- uses: home-assistant/actions/hassfest@master

3
.gitignore vendored
View File

@ -1,5 +1,2 @@
node_modules/ node_modules/
**/__pycache__/ **/__pycache__/
.vscode
.env
custom_components/hacs/

475
README.md
View File

@ -1,108 +1,429 @@
# browser_mod 2.0 browser\_mod
============
[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs)
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? A Home Assistant integration to turn your browser into a controllable entity - and also an audio player and a security camera (WIP).
And what if you could use it as an extra security camera? ## Example uses
Or what if you could use it to play music and videos from your Home Assistant media library? - Make the camera feed from your front door pop up on the tablet in your kitchen when someone rings the doorbell.
- Have a message pop up on every screen in the house when it's bedtime.
- Make the browser on your workstation switch to a specific tab when the kitchen light is on after midnight
- Play a TTS message on your work computer when the traffic sensor tells you it's time to go home.
- Display a full screen clock on your screen if no one's touched it for five minutes
What if you could permanently hide that sidebar from your kids and lock them into a single dashboard? For more usage examples, see the [cookbook](https://github.com/thomasloven/hass-browser_mod/wiki/Cookbook).
What if you could change the icon of the Home Assistant tab so it doesn't look the same as the forum?
What if you could change the more-info dialog for some entity to a dashboard card of your own design?
What if you could tap a button and have Home Assistant ask you which rooms you want the roomba to vacuum?
\
 
# Installation instructions # Installation instructions
- **First make sure you have completely removed any installation of Browser Mod 1** - Copy the contents of `custom_components/browser_mod/` to `<your config dir>/custom_components/browser_mod/`.
- Either - Add the following to your `configuration.yaml`:
- Find and install Browser Mod under `integrations`in [HACS](https://hacs.xyz)
- OR copy the contents of `custom_components/browser_mod/` to `<your config dir>/custom_components/browser_mod/`.
- Restart Home Assistant
- 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)
- Restart Home Assistant
> 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']`.
>
> 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.
\
&nbsp;
# Browser Mod Configuration Panel
When you're logged in as an administrator you should see a new panel called _Browser Mod_ in the sidebar. This is where you controll any Browser Mod settings.
### See [Configuration Panel](documentation/configuration-panel.md) for more info
\
&nbsp;
# Browser Mod Services
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.
### See [Services](documentation/services.md) for more info
\
&nbsp;
## Popup card
A popup card can be used to replace the more-info dialog of an entity with something of your choosing.
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).
The card will be visible only while you're in Edit mode.
As long as the popup card is (would be) visible, i.e. you stay on the same view;
whenever the more-info dialog for the entitiy you selected would be opened, the popup card will be shown instead.
Yaml configuration:
```yaml ```yaml
type: custom:popup-card browser_mod:
entity: <entity id>
card:
type: ...etc...
[any parameter from the browser_mod.popup service call except "content"]
``` ```
> *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. - Restart Home Assistant
## Browser Player > Note: If you want to use browser_mod with Home Assistant Cast, you need to also add:
>
> ```yaml
> resources:
> - url: /browser_mod.js
> type: module
> ```
> to your `ui_lovelace.yaml`.
> Don't worry about where to put browser_mod.js, the integration will handle that automatically, and please note that it's **not** `/local/browser_mod.js`.
Browser player is a card that allows you to controll the volume and playback on the current Browsers media player.
Add it to a dashboard via the GUI or through yaml: # Usage
## Devices
The most important concept of `browser_mod` is the *device*.
A *device* is a machine-browser combination identified by a unique `deviceID`. The `deviceID` is randomly generated and may look like `ded3b4dc-abedd098`.
- Chrome on your desktop and Chrome on your laptop are two different *devices*.
- Chrome on your laptop and Safari on your laptop are two different *devices*.
- Two tabs in Firefox on the same computer is one *device*.
- Two windows in Edge on the same computer is one *device*.
In the two latter cases, the last loaded tab/window will be the *active* one.
Note: Incognito mode will generate a new `deviceID` and thus a new *device* every time it's started.
### Aliases
Since the deviceID can be a bit hard to remember for devices you use often, you can specify an alias in `configuration.yaml`
```yaml
browser_mod:
devices:
99980b13-dabc9563:
name: arrakis
d2fc860c-16379d23:
name: dashboard
```
This binds the *aliases* `arrakis` to `99980b13-dabc9563` and `dashboard` to `d2fc860c-16379d23`.
Note: Aliases must be unique.
### Prefix
You can add a custom prefix to all entity ids in `configuration.yaml`:
E.g. to give entities default names like `media_player.browser_99980b13_dabc9563` add:
```yaml
browser_mod:
prefix: "browser_"
```
This does not apply to devices with an alias.
### Disabling entities
`browser_mod` creates a number of entities, which is explained below. In some cases, you may not want to do that. If so, add a list of entity types you do *not* want to add to a `disable` section, either for each device, or globally to ignore for all unknown devices:
E.g. to disable the `light` and `media_player` for the device aliased to `arrakis`, AND disable *all* entities for all devices which *don't* have an alias:
```yaml
browser_mod:
devices:
99980b13-dabc9563:
name: arrakis
disable:
- light
- media_player
disable:
- all
````
## Entities
Once `browser_mod` is installed, loading up your Home Assistant frontend on a new *device* will create three to five new devices.
- `sensor.<device>`
- `media_player.<device>`
- `light.<device>`
- If you've enabled it: `camera.<device>`
- If you're using Fully Kiosk Browser: `binary_sensor.<device>`
`<device>` here will be the `deviceID` of the *device* but with the dash (`-`) replaced by an underscore (`_`). If you've defined an alias, it will be that instead.
E.g:
Connecting your phone with `deviceID: ded3b4dc-abedd098` will create the entities `sensor.ded3b4dc_abedd098`, `media_player.ded3b4dc_abedd098` and `light.ded3b4dc_abedd098`.
Connecting with the computer named `Arrakis` above with `deviceID: 99980b13-dabc9563` will create the entities `sensor.arrakis`, `media_player.arrakis` and `light.arrakis`.
### sensor
The `sensor` will display the number of connected views (tabs/windows) of the device. Note that using multiple view isn't really recommended, and any action targeting a device will happen in the last loaded view.
The sensor also has the following attributes:
| attribute | content |
| --- | --- |
| `type` | `browser_mod` |
| `last_seen` | The time when the *device* was last seen |
| `deviceID` | The deviceID of the *device*. |
| `path` | The currently displayed path on the *device*. |
| `visibility` | Whether the frontend is currently visible on the *device*. |
| `userAgent` | The User Agent of the associated browser. |
| `currentUser` | The user currently logged in on the *device*. |
| `fullyKiosk` | True if the *device* is a Fully Kiosk browser. Undefined otherwise. |
| `width` | The current width of the browser window in pixels. |
| `height` | The current height of the browser window in pixels. |
### media\_player
The `media_player` can be used to play sounds on the *device*.
**NOTE: Because Apple is Apple; on iOS you need to touch the screen once after loading the frontend before any playback will work.**
### light
The `light` can be used to blackout the screen.
For Fully Kiosk Browser, the screen will actually turn off.
For other browsers, the interface will just be covered with black (the screen is still on, will have a visible glow in the dark, and you won't save any battery).
### camera (EXPERIMENTAL)
For security and UX reasons, the camera must be enabled manually on a device by device basis.
Enabling the camera is done by adding `camera: true` to the devices configuration in `configuration.yaml`:
```yaml
browser_mod:
devices:
99980b13-dabc9563:
name: arrakis
camera: true
d2fc860c-16379d23:
name: dashboard
```
After restarting Home Assistant (and [clearing cache](https://github.com/thomasloven/hass-config/wiki/Lovelace-Plugins#clearing-cache)), the next time you load your interface your browser will ask you if you want Home Assistant to be able to access your camera. Some browsers (e.g. mobile Safari) will ask every time you make a hard refresh.
Be aware that keeping the camera on may make your device run hot and drain your battery.
### binary\_sensor
The `binary_sensor` will only be available for Fully Kiosk Browser PRO *devices*.
It's state will be the state of the camera motion detector of the *device* (5 second cooldown).
## Services
`browser_mod` registers a number of services.
All service calls have one parameter in common; `deviceID` which is a list of *devices* to execute the comand on. If `deviceID` is omitted, the command will be executed on **all** currenctly connected *devices*. `deviceID` may also contain aliases.
If a service is called from the frontend (e.g. by using the `call-service` tap action), a value of `this` in the `deviceID` list will be replaced with the ID of the *device* the call was made from.
Alternatively, `deviceID: this` will also work.
All examples below are given in the syntax used for calling them from lovelace via e.g. an entity-button card with `tap_action:` set to `call-service`. If you call the service from a script or an automation, the syntax will be slightly different.
### debug
```
service: browser_mod.debug
```
Display a popup with the deviceID *and* a javascript alert with the deviceID on all connected *devices*.
### set_theme
```
service: browser_mod.set_theme
service_data:
theme: clear_light
```
will set the current theme to `clear_light` on all devices.
### navigate
```
service: browser_mod.navigate
service_data:
navigation_path: /lovelace/1
deviceID:
- ded3b4dc-abedd098
```
will open your second lovelace view on just the *device* `ded3b4dc-abedd098`.
Note: `navigation_path` does not have to be a lovelace path. All paths in Home Assistant works. (E.g. `/states`, `/dev-info`, `/map`)
### more_info
```
service: browser_mod.more_info
service_data:
entity_id: camera.front_door
deviceID:
- ded3b4dc-abedd098
- dashboard
```
will show the more-info dialog of `camera.front_door` on the *devices* `ded3b4dc-abedd098` and `dashboard`.
The optional parameter `large: true` will make the popup wider.
### toast
```
service: browser_mod.toast
service_data:
message: Short message
```
Display a toast notification on all devices.
The optional parameter `duration:` determines the time (in ms) that the toast is shown. Set to 0 for persistent display. Default is 3000.
### popup
```
service: browser_mod.popup
service_data:
title: Popup example
card:
type: entities
entities:
- light.bed_light
- light.kitchen_lights
- light.ceiling_lights
deviceID:
- this
- dashboard
```
will display the specified `entities` card as a popup on the current device and on `dashboard`
![popup-example](https://user-images.githubusercontent.com/1299821/60288984-a7cb6b00-9915-11e9-9322-324323a9ec6e.png)
The optional parameter `large: true` will make the popup wider.
The optional parameter `style:` will apply CSS style options to the popup.
The optional parameter `auto_close: true` will make the popup close automatically when the mouse is moved or a key is pressed on the keyboard. This also removes the header bar.
The optional parameter `time:` (only useable if `auto_close: true` is also set) will turn the popup into a "screensaver". See the `blackout` command below.
Ex:
```yaml
style:
border-radius: 20px
--ha-card-border-radius: 20px
--ha-card-background: red
```
Note: Sometimes this doesn't work if the *device* is not currently displaying a lovelace path. I'm looking into that...
### close_popup
```
service: browser_mod.close_popup
```
will close all more-info dialogs and popups that are open on all connected *devices*.
### blackout
```
service: browser_mod.blackout
service_data:
deviceID: this
```
Will cover the entire window (or screen if in full screen mode) with black on the current device.
Moving the mouse, touching the screen or pressing any key will restore the view.
The optional parameter `time:` will make the blackout turn on automatically after the specified number of seconds. It works kind of like a screensaver and will keep turning on until `blackout` is called again with `time: -1`.
Note: This will *not* turn off your screen backlight. Most screens will still emit light in a dark room.
### no_blackout
```
service: browser_mod.no_blackout
```
Remove a blackout.
The optional parameter `brightness` will set the screen brightness of a device running Fully Kiosk Browser to a value between 0 and 255.
### lovelace_reload
```
service: browser_mod.lovelace_reload
```
Refreshes the lovelace config. Same as clicking "Refresh" in the top right menu in lovelace.
## `browser-player` card
To control the playback in the current *device*, `browser_mod` includes a custom lovelace card. Just add
```yaml ```yaml
type: custom:browser-player type: custom:browser-player
``` ```
anywhere in your lovelace configuration.
The player card also displays the `entityID`. Click it to select, so you can copy it.
![browser-player](https://user-images.githubusercontent.com/1299821/60288980-a4d07a80-9915-11e9-88ba-e078a3aa24f4.png)
# Fully Kiosk Browser
If you are using a device running [Fully Kiosk Browser](https://www.ozerov.de/fully-kiosk-browser/) (PLUS version only) you will have access to a few more functions.
For this to work you need to activate `Settings->Advanced Web Settings->Javascript Interface (PLUS)` and `Settings->Motion Detection (PLUS)->Enable Visual Motion Detection`.
First of all the commands `blackout` and `no-blackout` will control the devices screen directly.
`no-blackout` also has an optional parameter `brightness` that can set the screen brightness between 0 and 255.
Second, there are a few more attributes available
| attribute | content |
| --- | --- |
| `fullyKiosk` | True. |
| `brightness` | The current screen brightness. |
| `battery` | The current charge percentage of the devices battery. |
| `charging` | Whether the battery is currently charging. |
| `motion` | Whether the devices camera has detected any motion in the last five seconds. |
# Replacing more-info dialogs
With browser_mod, you can replace any more-info dialog with any lovelace card you choose yourself. This can be done either per lovelace view, or globally (even outside of lovelace).
The replacement is included in your lovelace or lovelace view configuration, and the syntax is exactly like the `popup` service, except you can't use `auto_close` or `time`.
Ex:
```yaml
views:
- title: Home view
icon: mdi:house
popup_cards:
light.ceiling_light:
title: My popup
card:
type: entities
entities:
- light.ceiling_light_bulb1
- light.ceiling_light_bulb2
- light.ceiling_light_bulb3
- light.ceiling_light_bulb4
```
This would show an entities card with four bulbs any time the more-info dialog for `light.ceiling_light` would normally be shown when you're on the Home view in lovelace.
```yaml
title: My home
resources:
- url: /local/card-mod.js
type: module
popup_cards:
sensor.sensor1:
title: First sensor
card:
type: gauge
entity: sensor.sensor1
sensor.sensor2:
title: Second sensor
card:
type: gauge
entity: sensor.sensor2
```
This would replace the more-info dialogs of `sensor.sensor1` and `sensor.sensor2` anywhere in your interface. Even outside of lovelace - be careful about that.
# Support
[Home Assistant community forum thread](https://community.home-assistant.io/t/browser-mod-turn-your-browser-into-a-controllable-device-and-a-media-player/123806)
# FAQ # FAQ
### **Why doesn't ANYTHING that used to work with Browser Mod 1.0 work with Browser Mod 2.0?** ### Where can I find my deviceID?
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. The easiest way is to go to `developer-tools/service` and call the `browser_mod.debug` service.
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. But you can also find the device id on the `browser-player` card, if you added one to your lovelace config.
### **Why does my Browser ID keep changing?** An alternative way is to type `localStorage["lovelace-player-device-id"]` into your browsers console.
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...
### Does this replace lovelace-player and lovelace-browser-commander
Yes.
Some improvements
- With the backend support `browser_mod` does the same things as both of those, but better.
- Since `browser_mod` uses a service for executing commands rather than events, the commands can be easily triggered by any lovelace element which has a `tap_action` setting.
This actually means it pretty much replaces `popup-card` as well.
- `browser_mod` uses websockets to get immediate feedback from the *device* to the backend and much better tracking of disconnects.
- *Aliases*. 'nuff said.
- `browser_mod` works outside of `/lovelace`.
- This works even if the currently logged in user is not in the admin group.
### Does this replace lovelace-fullykiosk
Yes. You need the paid version, btw.
### Can the deviceID be used to track me across the internet
No\*. The device is stored in your browsers localStorage - a data store which is sandboxed only to Home Assistant. That means only Home Assistant can access it. Furthermore, different Home Assistant installations cannot acces each others localStorage.
Some of [my lovelace plugins](https://github.com/thomasloven/hass-config/wiki/My-Lovelace-Plugins) use the device to do different things for different *devices*.
**\*: There is one exception. If you are using [Fully Kiosk Browser](https://www.ozerov.de/fully-kiosk-browser/), the deviceID is taken from the browser instead of being randomly generated. This deviceID will be the same for each website that asks for it.**
### How do I run commands from /dev-service?
`/dev-service` requires json-formatted service data. There's an explanation on the differences between yaml and json [here](http://thomasloven.com/blog/2018/08/YAML-For-Nonprogrammers/).
### How do I run commands from a script/automation?
Basically, just replace `service_data` with `data` or `data_template`, whichever fits your needs.
--- ---
<a href="https://www.buymeacoffee.com/uqD6KHCdJ" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/white_img.png" alt="Buy Me A Coffee" style="height: auto !important;width: auto !important;" ></a> <a href="https://www.buymeacoffee.com/uqD6KHCdJ" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/white_img.png" alt="Buy Me A Coffee" style="height: auto !important;width: auto !important;" ></a>

View File

@ -1,37 +1,41 @@
import logging import logging
from .store import BrowserModStore from .mod_view import setup_view
from .mod_view import async_setup_view from .connection import setup_connection
from .connection import async_setup_connection from .service import setup_service
from .const import DOMAIN, DATA_BROWSERS, DATA_ADDERS, DATA_STORE from .const import DOMAIN, DATA_DEVICES, DATA_ALIASES, DATA_ADDERS, CONFIG_DEVICES, DATA_CONFIG
from .service import async_setup_services
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
async def async_setup(hass, config): async def async_setup(hass, config):
store = BrowserModStore(hass) setup_view(hass)
await store.load()
aliases = {}
for d in config[DOMAIN].get(CONFIG_DEVICES, {}):
name = config[DOMAIN][CONFIG_DEVICES][d].get("name", None)
if name:
aliases[name] = d.replace('_','-')
hass.data[DOMAIN] = { hass.data[DOMAIN] = {
DATA_BROWSERS: {}, DATA_DEVICES: {},
DATA_ALIASES: aliases,
DATA_ADDERS: {}, DATA_ADDERS: {},
DATA_STORE: store, DATA_CONFIG: config[DOMAIN],
} }
return True await hass.helpers.discovery.async_load_platform("media_player", DOMAIN, {}, config)
await hass.helpers.discovery.async_load_platform("sensor", DOMAIN, {}, config)
await hass.helpers.discovery.async_load_platform("binary_sensor", DOMAIN, {}, config)
await hass.helpers.discovery.async_load_platform("light", DOMAIN, {}, config)
await hass.helpers.discovery.async_load_platform("camera", DOMAIN, {}, config)
await setup_connection(hass, config)
async def async_setup_entry(hass, config_entry): setup_service(hass)
for domain in ["sensor", "binary_sensor", "light", "media_player", "camera"]:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(config_entry, domain)
)
await async_setup_connection(hass)
await async_setup_view(hass)
await async_setup_services(hass)
return True return True

View File

@ -1,47 +1,50 @@
from homeassistant.components.binary_sensor import BinarySensorEntity import logging
from datetime import datetime
from .const import DOMAIN, DATA_ADDERS from homeassistant.const import STATE_UNAVAILABLE, ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, STATE_ON, STATE_OFF
from .entities import BrowserModEntity from homeassistant.components.binary_sensor import DEVICE_CLASS_MOTION
from .helpers import setup_platform, BrowserModEntity
async def async_setup_platform( PLATFORM = 'binary_sensor'
hass, config_entry, async_add_entities, discoveryInfo=None
):
hass.data[DOMAIN][DATA_ADDERS]["binary_sensor"] = async_add_entities
async def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
return setup_platform(hass, config, async_add_devices, PLATFORM, BrowserModSensor)
async def async_setup_entry(hass, config_entry, async_add_entities): class BrowserModSensor(BrowserModEntity):
await async_setup_platform(hass, {}, async_add_entities) domain = PLATFORM
def __init__(self, hass, connection, deviceID, alias=None):
super().__init__(hass, connection, deviceID, alias)
self.last_seen = None
class BrowserBinarySensor(BrowserModEntity, BinarySensorEntity): def updated(self):
def __init__(self, coordinator, browserID, parameter, name, icon=None): self.last_seen = datetime.now()
BrowserModEntity.__init__(self, coordinator, browserID, name, icon) self.schedule_update_ha_state()
BinarySensorEntity.__init__(self)
self.parameter = parameter @property
def state(self):
if not self.connection.connection:
return STATE_UNAVAILABLE
if self.data.get('motion', False):
return STATE_ON
return STATE_OFF
@property @property
def is_on(self): def is_on(self):
return self._data.get("browser", {}).get(self.parameter, None) return not self.data.get('motion', False)
class ActivityBinarySensor(BrowserModEntity, BinarySensorEntity):
def __init__(self, coordinator, browserID):
BrowserModEntity.__init__(self, coordinator, browserID, None)
BinarySensorEntity.__init__(self)
@property
def unique_id(self):
return f"{self.browserID}-activity"
@property
def entity_registry_visible_default(self):
return True
@property @property
def device_class(self): def device_class(self):
return "motion" return DEVICE_CLASS_MOTION
@property @property
def is_on(self): def device_state_attributes(self):
return self._data.get("activity", False) return {
"type": "browser_mod",
"last_seen": self.last_seen,
ATTR_BATTERY_LEVEL: self.data.get('battery', None),
ATTR_BATTERY_CHARGING: self.data.get('charging', None),
**self.data
}

View File

@ -1,223 +0,0 @@
import logging
from homeassistant.components.websocket_api import event_message
from homeassistant.helpers import device_registry, entity_registry
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.core import callback
from .const import DATA_BROWSERS, DOMAIN, DATA_ADDERS
from .sensor import BrowserSensor
from .light import BrowserModLight
from .binary_sensor import BrowserBinarySensor, ActivityBinarySensor
from .media_player import BrowserModPlayer
from .camera import BrowserModCamera
_LOGGER = logging.getLogger(__name__)
class Coordinator(DataUpdateCoordinator):
def __init__(self, hass, browserID):
super().__init__(
hass,
_LOGGER,
name="Browser Mod Coordinator",
)
self.browserID = browserID
class BrowserModBrowser:
"""A Browser_mod browser.
Handles the Home Assistant device corresponding to a registered Browser.
Creates and updates entities based on available data.
"""
def __init__(self, hass, browserID):
self.browserID = browserID
self.coordinator = Coordinator(hass, browserID)
self.entities = {}
self.data = {}
self.settings = {}
self._connections = []
self.update_entities(hass)
def update(self, hass, newData):
"""Update state of all related entities."""
self.data.update(newData)
self.update_entities(hass)
self.coordinator.async_set_updated_data(self.data)
def update_settings(self, hass, settings):
"""Update Browser settings and entities if needed."""
self.settings = settings
self.update_entities(hass)
def update_entities(self, hass):
"""Create all entities associated with the browser."""
coordinator = self.coordinator
browserID = self.browserID
def _assert_browser_sensor(type, name, *properties, **kwarg):
"""Create a browser state sensor if it does not already exist"""
if name in self.entities:
return
adder = hass.data[DOMAIN][DATA_ADDERS][type]
cls = {"sensor": BrowserSensor, "binary_sensor": BrowserBinarySensor}[type]
new = cls(coordinator, browserID, name, *properties, **kwarg)
adder([new])
self.entities[name] = new
_assert_browser_sensor("sensor", "path", "Browser path", icon="mdi:web")
_assert_browser_sensor("sensor", "visibility", "Browser visibility")
_assert_browser_sensor(
"sensor", "userAgent", "Browser userAgent", icon="mdi:account-details"
)
_assert_browser_sensor(
"sensor", "currentUser", "Browser user", icon="mdi:account"
)
_assert_browser_sensor(
"sensor", "width", "Browser width", "px", icon="mdi:arrow-left-right"
)
_assert_browser_sensor(
"sensor", "height", "Browser height", "px", icon="mdi:arrow-up-down"
)
# Don't create battery sensor unless battery level is reported
if self.data.get("browser", {}).get("battery_level", None) is not None:
_assert_browser_sensor(
"sensor", "battery_level", "Browser battery", "%", "battery"
)
_assert_browser_sensor(
"binary_sensor",
"darkMode",
"Browser dark mode",
icon="mdi:theme-light-dark",
)
_assert_browser_sensor(
"binary_sensor", "fullyKiosk", "Browser FullyKiosk", icon="mdi:alpha-f"
)
# Don't create a charging sensor unless charging state is reported
if self.data.get("browser", {}).get("charging", None) is not None:
_assert_browser_sensor(
"binary_sensor", "charging", "Browser charging", icon="mdi:power-plug"
)
if "activity" not in self.entities:
adder = hass.data[DOMAIN][DATA_ADDERS]["binary_sensor"]
new = ActivityBinarySensor(coordinator, browserID)
adder([new])
self.entities["activity"] = new
if "screen" not in self.entities:
adder = hass.data[DOMAIN][DATA_ADDERS]["light"]
new = BrowserModLight(coordinator, browserID, self)
adder([new])
self.entities["screen"] = new
if "player" not in self.entities:
adder = hass.data[DOMAIN][DATA_ADDERS]["media_player"]
new = BrowserModPlayer(coordinator, browserID, self)
adder([new])
self.entities["player"] = new
if "camera" not in self.entities and self.settings.get("camera"):
adder = hass.data[DOMAIN][DATA_ADDERS]["camera"]
new = BrowserModCamera(coordinator, browserID)
adder([new])
self.entities["camera"] = new
if "camera" in self.entities and not self.settings.get("camera"):
er = entity_registry.async_get(hass)
er.async_remove(self.entities["camera"].entity_id)
del self.entities["camera"]
hass.create_task(
self.send(
None, browserEntities={k: v.entity_id for k, v in self.entities.items()}
)
)
@callback
async def send(self, command, **kwargs):
"""Send a command to this browser."""
if self.connection is None:
return
for (connection, cid) in self.connection:
connection.send_message(
event_message(
cid,
{
"command": command,
**kwargs,
},
)
)
def delete(self, hass):
"""Delete the device and associated entities."""
dr = device_registry.async_get(hass)
er = entity_registry.async_get(hass)
for e in self.entities.values():
er.async_remove(e.entity_id)
self.entities = {}
device = dr.async_get_device({(DOMAIN, self.browserID)})
dr.async_remove_device(device.id)
def get_device_id(self, hass):
er = entity_registry.async_get(hass)
entities = list(self.entities.values())
if len(entities):
entity = entities[0]
entry = er.async_get(entity.entity_id)
if entry:
return entry.device_id
return "default"
@property
def connection(self):
"""The current websocket connections for this Browser."""
return self._connections
def open_connection(self, connection, cid):
"""Add a websocket connection."""
self._connections.append((connection, cid))
def close_connection(self, connection):
"""Close a websocket connection."""
self._connections = list(
filter(lambda v: v[0] != connection, self._connections)
)
def getBrowser(hass, browserID, *, create=True):
"""Get or create browser by browserID."""
browsers = hass.data[DOMAIN][DATA_BROWSERS]
if browserID in browsers:
return browsers[browserID]
if not create:
return None
browsers[browserID] = BrowserModBrowser(hass, browserID)
return browsers[browserID]
def deleteBrowser(hass, browserID):
"""Delete a browser by BrowserID."""
browsers = hass.data[DOMAIN][DATA_BROWSERS]
if browserID in browsers:
browsers[browserID].delete(hass)
del browsers[browserID]
def getBrowserByConnection(hass, connection):
"""Get the browser that has a given connection open."""
browsers = hass.data[DOMAIN][DATA_BROWSERS]
for k, v in browsers.items():
if any([c[0] == connection for c in v.connection]):
return v

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,39 +1,36 @@
import logging
from datetime import datetime
import base64 import base64
from homeassistant.const import STATE_UNAVAILABLE, STATE_ON, STATE_OFF, STATE_IDLE
from homeassistant.components.camera import Camera from homeassistant.components.camera import Camera
from .entities import BrowserModEntity from .helpers import setup_platform, BrowserModEntity
from .const import DOMAIN, DATA_ADDERS
import logging PLATFORM = 'camera'
LOGGER = logging.Logger(__name__) async def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
return setup_platform(hass, config, async_add_devices, PLATFORM, BrowserModCamera)
class BrowserModCamera(Camera, BrowserModEntity):
domain = PLATFORM
async def async_setup_platform( def __init__(self, hass, connection, deviceID, alias=None):
hass, config_entry, async_add_entities, discoveryInfo=None
):
hass.data[DOMAIN][DATA_ADDERS]["camera"] = async_add_entities
async def async_setup_entry(hass, config_entry, async_add_entities):
await async_setup_platform(hass, {}, async_add_entities)
class BrowserModCamera(BrowserModEntity, Camera):
def __init__(self, coordinator, browserID):
BrowserModEntity.__init__(self, coordinator, browserID, None)
Camera.__init__(self) Camera.__init__(self)
BrowserModEntity.__init__(self, hass, connection, deviceID, alias)
self.last_seen = None
def updated(self):
self.last_seen = datetime.now()
self.schedule_update_ha_state()
def camera_image(self):
return base64.b64decode(self.data.split(',')[1])
@property @property
def unique_id(self): def device_state_attributes(self):
return f"{self.browserID}-camera" return {
"type": "browser_mod",
@property "deviceID": self.deviceID,
def entity_registry_visible_default(self): "last_seen": self.last_seen,
return True }
def camera_image(self, width=None, height=None):
if "camera" not in self._data:
return None
return base64.b64decode(self._data["camera"].split(",")[-1])

View File

@ -1,18 +0,0 @@
import logging
from homeassistant import config_entries
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@config_entries.HANDLERS.register(DOMAIN)
class BrowserModConfigFlow(config_entries.ConfigFlow):
VERSION = 2
async def async_step_user(self, user_input=None):
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
_LOGGER.error("Running async_create_entry")
return self.async_create_entry(title="Browser Mod", data={})

View File

@ -1,199 +1,115 @@
import logging import logging
import voluptuous as vol import voluptuous as vol
from datetime import datetime, timezone
from homeassistant.components.websocket_api import ( from homeassistant.components.websocket_api import websocket_command, result_message, event_message, async_register_command
event_message, from homeassistant.helpers.entity import Entity, async_generate_entity_id
async_register_command,
)
from homeassistant.components import websocket_api from .const import DOMAIN, WS_CONNECT, WS_UPDATE, WS_CAMERA
from .helpers import get_devices, create_entity, get_config
from homeassistant.core import callback
from .const import (
BROWSER_ID,
DATA_STORE,
WS_CONNECT,
WS_LOG,
WS_RECALL_ID,
WS_REGISTER,
WS_SETTINGS,
WS_UNREGISTER,
WS_UPDATE,
DOMAIN,
)
from .browser import getBrowser, deleteBrowser, getBrowserByConnection
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
async def setup_connection(hass, config):
async def async_setup_connection(hass): @websocket_command({
@websocket_api.websocket_command(
{
vol.Required("type"): WS_CONNECT, vol.Required("type"): WS_CONNECT,
vol.Required("browserID"): str, vol.Required("deviceID"): str,
} })
) def handle_connect(hass, connection, msg):
@websocket_api.async_response deviceID = msg["deviceID"]
async def handle_connect(hass, connection, msg):
"""Connect to Browser Mod and subscribe to settings updates."""
browserID = msg[BROWSER_ID]
store = hass.data[DOMAIN][DATA_STORE]
@callback device = get_devices(hass).get(deviceID, BrowserModConnection(hass, deviceID))
def send_update(data): device.connect(connection, msg["id"])
connection.send_message(event_message(msg["id"], {"result": data})) get_devices(hass)[deviceID] = device
store_listener = store.add_listener(send_update) connection.send_message(result_message(msg["id"]))
def close_connection(): @websocket_command({
store_listener()
dev = getBrowser(hass, browserID, create=False)
if dev:
dev.close_connection(connection)
connection.subscriptions[msg["id"]] = close_connection
connection.send_result(msg["id"])
if store.get_browser(browserID).registered:
dev = getBrowser(hass, browserID)
dev.update_settings(hass, store.get_browser(browserID).asdict())
dev.open_connection(connection, msg["id"])
await store.set_browser(
browserID,
last_seen=datetime.now(tz=timezone.utc).isoformat(),
meta=dev.get_device_id(hass),
)
send_update(store.asdict())
@websocket_api.websocket_command(
{
vol.Required("type"): WS_REGISTER,
vol.Required("browserID"): str,
vol.Optional("data"): dict,
}
)
@websocket_api.async_response
async def handle_register(hass, connection, msg):
"""Register a Browser."""
browserID = msg[BROWSER_ID]
store = hass.data[DOMAIN][DATA_STORE]
browserSettings = {"registered": True}
data = msg.get("data", {})
if "last_seen" in data:
del data["last_seen"]
if BROWSER_ID in data:
# Change ID of registered browser
newBrowserID = data[BROWSER_ID]
del data[BROWSER_ID]
# Copy data from old browser and delete it from store
if oldBrowserSettings := store.get_browser(browserID):
browserSettings = oldBrowserSettings.asdict()
await store.delete_browser(browserID)
# Delete the old Browser device
deleteBrowser(hass, browserID)
# Use the new browserID from now on
browserID = newBrowserID
# Create and/or update Browser device
dev = getBrowser(hass, browserID)
dev.update_settings(hass, data)
# Create or update store data
if data is not None:
browserSettings.update(data)
await store.set_browser(browserID, **browserSettings)
@websocket_api.websocket_command(
{
vol.Required("type"): WS_UNREGISTER,
vol.Required("browserID"): str,
}
)
@websocket_api.async_response
async def handle_unregister(hass, connection, msg):
"""Unregister a Browser."""
browserID = msg[BROWSER_ID]
store = hass.data[DOMAIN][DATA_STORE]
deleteBrowser(hass, browserID)
await store.delete_browser(browserID)
connection.send_result(msg["id"])
@websocket_api.websocket_command(
{
vol.Required("type"): WS_UPDATE, vol.Required("type"): WS_UPDATE,
vol.Required("browserID"): str, vol.Required("deviceID"): str,
vol.Optional("data"): dict, vol.Optional("data"): dict,
} })
) def handle_update( hass, connection, msg):
@websocket_api.async_response devices = get_devices(hass)
async def handle_update(hass, connection, msg): deviceID = msg["deviceID"]
"""Receive state updates from a Browser.""" if deviceID in devices:
browserID = msg[BROWSER_ID] devices[deviceID].update(msg.get("data", None))
store = hass.data[DOMAIN][DATA_STORE]
if store.get_browser(browserID).registered:
dev = getBrowser(hass, browserID)
dev.update(hass, msg.get("data", {}))
@websocket_api.websocket_command(
{
vol.Required("type"): WS_SETTINGS,
vol.Required("key"): str,
vol.Optional("value"): vol.Any(int, str, bool, list, object, None),
vol.Optional("user"): str,
}
)
@websocket_api.async_response
async def handle_settings(hass, connection, msg):
"""Change user or global settings."""
store = hass.data[DOMAIN][DATA_STORE]
if "user" in msg:
# Set user setting
await store.set_user_settings(
msg["user"], **{msg["key"]: msg.get("value", None)}
)
else:
# Set global setting
await store.set_global_settings(**{msg["key"]: msg.get("value", None)})
pass
@websocket_api.websocket_command(
{
vol.Required("type"): WS_RECALL_ID,
}
)
def handle_recall_id(hass, connection, msg):
"""Recall browserID of Browser with the current connection."""
dev = getBrowserByConnection(hass, connection)
if dev:
connection.send_message(
websocket_api.result_message(msg["id"], dev.browserID)
)
connection.send_message(websocket_api.result_message(msg["id"], None))
@websocket_api.websocket_command(
{
vol.Required("type"): WS_LOG,
vol.Required("message"): str,
}
)
def handle_log(hass, connection, msg):
"""Print a debug message."""
_LOGGER.info(f"LOG MESSAGE: {msg['message']}")
async_register_command(hass, handle_connect) async_register_command(hass, handle_connect)
async_register_command(hass, handle_register)
async_register_command(hass, handle_unregister)
async_register_command(hass, handle_update) async_register_command(hass, handle_update)
async_register_command(hass, handle_settings)
async_register_command(hass, handle_recall_id) class BrowserModConnection:
async_register_command(hass, handle_log) def __init__(self, hass, deviceID):
self.hass = hass
self.deviceID = deviceID
self.connection = []
self.media_player = None
self.screen = None
self.sensor = None
self.fully = None
self.camera = None
def connect(self, connection, cid):
self.connection.append((connection, cid))
self.send("update", **get_config(self.hass, self.deviceID))
def disconnect():
self.connection.remove((connection, cid))
connection.subscriptions[cid] = disconnect
def send(self, command, **kwargs):
if self.connection:
connection, cid = self.connection[-1]
connection.send_message(event_message(cid, {
"command": command,
**kwargs,
}))
def update(self, data):
if data.get('browser'):
self.sensor = self.sensor or create_entity(
self.hass,
'sensor',
self.deviceID,
self)
if self.sensor:
self.sensor.data = data.get('browser')
if data.get('player'):
self.media_player = self.media_player or create_entity(
self.hass,
'media_player',
self.deviceID,
self)
if self.media_player:
self.media_player.data = data.get('player')
if data.get('screen'):
self.screen = self.screen or create_entity(
self.hass,
'light',
self.deviceID,
self)
if self.screen:
self.screen.data = data.get('screen')
if data.get('fully'):
self.fully = self.fully or create_entity(
self.hass,
'binary_sensor',
self.deviceID,
self)
if self.fully:
self.fully.data = data.get('fully')
if data.get('camera'):
self.camera = self.camera or create_entity(
self.hass,
'camera',
self.deviceID,
self)
if self.camera:
self.camera.data = data.get('camera')

View File

@ -1,31 +1,33 @@
DOMAIN = "browser_mod" DOMAIN = "browser_mod"
BROWSER_ID = "browserID"
FRONTEND_SCRIPT_URL = "/browser_mod.js" FRONTEND_SCRIPT_URL = "/browser_mod.js"
SETTINGS_PANEL_URL = "/browser_mod_panel.js"
DATA_BROWSERS = "browsers" DATA_EXTRA_MODULE_URL = 'frontend_extra_module_url'
DATA_DEVICES = "devices"
DATA_ALIASES = "aliases"
DATA_ADDERS = "adders" DATA_ADDERS = "adders"
DATA_STORE = "store" DATA_CONFIG = "config"
CONFIG_DEVICES = "devices"
CONFIG_PREFIX = "prefix"
CONFIG_DISABLE = "disable"
CONFIG_DISABLE_ALL = "all"
WS_ROOT = DOMAIN WS_ROOT = DOMAIN
WS_CONNECT = f"{WS_ROOT}/connect" WS_CONNECT = "{}/connect".format(WS_ROOT)
WS_REGISTER = f"{WS_ROOT}/register" WS_UPDATE = "{}/update".format(WS_ROOT)
WS_UNREGISTER = f"{WS_ROOT}/unregister" WS_CAMERA = "{}/camera".format(WS_ROOT)
WS_UPDATE = f"{WS_ROOT}/update"
WS_SETTINGS = f"{WS_ROOT}/settings"
WS_RECALL_ID = f"{WS_ROOT}/recall_id"
WS_LOG = f"{WS_ROOT}/log"
BROWSER_MOD_SERVICES = [ USER_COMMANDS = [
"sequence", "debug",
"delay",
"popup", "popup",
"more_info", "close-popup",
"close_popup",
"navigate", "navigate",
"refresh", "more-info",
"console", "set-theme",
"javascript", "lovelace-reload",
] "blackout",
"no-blackout",
"toast",
]

View File

@ -1,61 +0,0 @@
import logging
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
class BrowserModEntity(CoordinatorEntity):
def __init__(self, coordinator, browserID, name, icon=None):
super().__init__(coordinator)
self.browserID = browserID
self._name = name
self._icon = icon
@property
def _data(self):
return self.coordinator.data or {}
@property
def device_info(self):
config_url = {}
if ip := self._data.get("browser", {}).get("ip_address"):
config_url = {"configuration_url": f"http://{ip}:2323"}
return {
"identifiers": {(DOMAIN, self.browserID)},
"name": self.browserID,
"manufacturer": "Browser Mod",
**config_url,
}
@property
def extra_state_attributes(self):
return {
"type": "browser_mod",
"browserID": self.browserID,
}
@property
def name(self):
return self._name
@property
def has_entity_name(self):
return True
@property
def entity_registry_visible_default(self):
return False
@property
def unique_id(self):
return f"{self.browserID}-{self._name.replace(' ','_')}"
@property
def icon(self):
return self._icon

View File

@ -0,0 +1,68 @@
import logging
from homeassistant.helpers.entity import Entity, async_generate_entity_id
from .const import DOMAIN, DATA_DEVICES, DATA_ALIASES, DATA_ADDERS, CONFIG_DEVICES, DATA_CONFIG, CONFIG_PREFIX, CONFIG_DISABLE, CONFIG_DISABLE_ALL
_LOGGER = logging.getLogger(__name__)
def get_devices(hass):
return hass.data[DOMAIN][DATA_DEVICES]
def get_alias(hass, deviceID):
for k,v in hass.data[DOMAIN][DATA_ALIASES].items():
if v == deviceID:
return k
return None
def get_config(hass, deviceID):
config = hass.data[DOMAIN][DATA_CONFIG].get(CONFIG_DEVICES, {})
return config.get(deviceID, config.get(deviceID.replace('-','_'), {}))
def create_entity(hass, platform, deviceID, connection):
conf = get_config(hass, deviceID)
if conf and (platform in conf.get(CONFIG_DISABLE, [])
or CONFIG_DISABLE_ALL in conf.get(CONFIG_DISABLE, [])):
return None
if not conf and (platform in hass.data[DOMAIN][DATA_CONFIG].get(CONFIG_DISABLE, [])
or CONFIG_DISABLE_ALL in hass.data[DOMAIN][DATA_CONFIG].get(CONFIG_DISABLE, [])):
return None
adder = hass.data[DOMAIN][DATA_ADDERS][platform]
entity = adder(hass, deviceID, connection, get_alias(hass, deviceID))
return entity
def setup_platform(hass, config, async_add_devices, platform, cls):
def adder(hass, deviceID, connection, alias=None):
entity = cls(hass, connection, deviceID, alias)
async_add_devices([entity])
return entity
hass.data[DOMAIN][DATA_ADDERS][platform] = adder
return True
class BrowserModEntity(Entity):
def __init__(self, hass, connection, deviceID, alias=None):
self.hass = hass
self.connection = connection
self.deviceID = deviceID
self._data = {}
prefix = hass.data[DOMAIN][DATA_CONFIG].get(CONFIG_PREFIX, '')
self.entity_id = async_generate_entity_id(self.domain+".{}", alias or f"{prefix}{deviceID}", hass=hass)
def updated(self):
pass
@property
def data(self):
return self._data
@data.setter
def data(self, data):
self._data = data
self.updated()
@property
def device_id(self):
return self.deviceID
def send(self, command, **kwargs):
self.connection.send(command, **kwargs)

View File

@ -1,47 +1,55 @@
from homeassistant.components.light import LightEntity, ColorMode import logging
from datetime import datetime
from .entities import BrowserModEntity from homeassistant.const import STATE_UNAVAILABLE, STATE_ON, STATE_OFF
from .const import DOMAIN, DATA_ADDERS from homeassistant.components.light import Light, SUPPORT_BRIGHTNESS
from .helpers import setup_platform, BrowserModEntity
async def async_setup_platform( PLATFORM = 'light'
hass, config_entry, async_add_entities, discoveryInfo=None
):
hass.data[DOMAIN][DATA_ADDERS]["light"] = async_add_entities
async def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
return setup_platform(hass, config, async_add_devices, PLATFORM, BrowserModLight)
async def async_setup_entry(hass, config_entry, async_add_entities): class BrowserModLight(Light, BrowserModEntity):
await async_setup_platform(hass, {}, async_add_entities) domain = PLATFORM
def __init__(self, hass, connection, deviceID, alias=None):
super().__init__(hass, connection, deviceID, alias)
self.last_seen = None
class BrowserModLight(BrowserModEntity, LightEntity): def updated(self):
def __init__(self, coordinator, browserID, browser): self.last_seen = datetime.now()
BrowserModEntity.__init__(self, coordinator, browserID, "Screen") self.schedule_update_ha_state()
LightEntity.__init__(self)
self.browser = browser
@property @property
def entity_registry_visible_default(self): def state(self):
return True if not self.connection.connection:
return STATE_UNAVAILABLE
if self.data.get('blackout', False):
return STATE_OFF
return STATE_ON
@property @property
def is_on(self): def is_on(self):
return self._data.get("screen_on", None) return not self.data.get('blackout', False)
@property @property
def supported_color_modes(self): def device_state_attributes(self):
return {ColorMode.BRIGHTNESS} return {
"type": "browser_mod",
"deviceID": self.deviceID,
"last_seen": self.last_seen,
}
@property @property
def color_mode(self): def supported_features(self):
return ColorMode.BRIGHTNESS if self.data.get('brightness', False):
return SUPPORT_BRIGHTNESS
return 0
@property def turn_on(self, **kwargs):
def brightness(self): self.connection.send("no-blackout", **kwargs)
return self._data.get("screen_brightness", 1)
async def async_turn_on(self, **kwargs): def turn_off(self, **kwargs):
await self.browser.send("screen_on", **kwargs) self.connection.send("blackout")
async def async_turn_off(self, **kwargs):
await self.browser.send("screen_off")

View File

@ -1,11 +1,8 @@
{ {
"domain": "browser_mod", "domain": "browser_mod",
"name": "Browser mod", "name": "Browser mod",
"documentation": "https://github.com/thomasloven/hass-browser_mod/blob/master/README.md", "documentation": "",
"dependencies": ["panel_custom", "websocket_api", "http", "frontend", "lovelace"], "dependencies": ["websocket_api", "http"],
"codeowners": [], "codeowners": [],
"requirements": [], "requirements": []
"version": "2.0.0",
"iot_class": "local_push",
"config_flow": true
} }

View File

@ -1,155 +1,82 @@
from homeassistant.components import media_source import logging
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
SUPPORT_PLAY, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA,
SUPPORT_PLAY_MEDIA, SUPPORT_PAUSE, SUPPORT_STOP,
SUPPORT_PAUSE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_MUTE,
SUPPORT_STOP, MediaPlayerDevice,
SUPPORT_VOLUME_SET, )
SUPPORT_VOLUME_MUTE,
MediaPlayerEntity,
)
from homeassistant.components.media_player.browse_media import (
async_process_play_media_url,
)
from homeassistant.components.media_player.const import (
MEDIA_TYPE_MUSIC,
MEDIA_TYPE_URL,
SUPPORT_BROWSE_MEDIA,
SUPPORT_SEEK,
SUPPORT_TURN_OFF,
SUPPORT_TURN_ON,
)
from homeassistant.const import ( from homeassistant.const import (
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
STATE_PAUSED, STATE_PAUSED,
STATE_PLAYING, STATE_PLAYING,
STATE_IDLE, STATE_IDLE,
STATE_UNKNOWN, STATE_UNKNOWN,
STATE_ON, )
STATE_OFF,
)
from homeassistant.util import dt from .helpers import setup_platform, BrowserModEntity
from .entities import BrowserModEntity _LOGGER = logging.getLogger(__name__)
from .const import DOMAIN, DATA_ADDERS
PLATFORM = 'media_player'
async def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
return setup_platform(hass, config, async_add_devices, PLATFORM, BrowserModPlayer)
async def async_setup_platform( class BrowserModPlayer(MediaPlayerDevice, BrowserModEntity):
hass, config_entry, async_add_entities, discoveryInfo=None domain = PLATFORM
):
hass.data[DOMAIN][DATA_ADDERS]["media_player"] = async_add_entities
def __init__(self, hass, connection, deviceID, alias=None):
super().__init__(hass, connection, deviceID, alias)
self.last_seen = None
async def async_setup_entry(hass, config_entry, async_add_entities): def updated(self):
await async_setup_platform(hass, {}, async_add_entities) self.schedule_update_ha_state()
class BrowserModPlayer(BrowserModEntity, MediaPlayerEntity):
def __init__(self, coordinator, browserID, browser):
BrowserModEntity.__init__(self, coordinator, browserID, None)
MediaPlayerEntity.__init__(self)
self.browser = browser
@property @property
def unique_id(self): def device_state_attributes(self):
return f"{self.browserID}-player" return {
"type": "browser_mod",
@property "deviceID": self.deviceID,
def entity_registry_visible_default(self): }
return True
@property @property
def state(self): def state(self):
state = self._data.get("player", {}).get("state") if not self.connection.connection:
return STATE_UNAVAILABLE
state = self.data.get("state", "unknown")
return { return {
"playing": STATE_PLAYING, "playing": STATE_PLAYING,
"paused": STATE_PAUSED, "paused": STATE_PAUSED,
"stopped": STATE_IDLE, "stopped": STATE_IDLE,
"unavailable": STATE_UNAVAILABLE,
"on": STATE_ON,
"off": STATE_OFF,
}.get(state, STATE_UNKNOWN) }.get(state, STATE_UNKNOWN)
@property @property
def supported_features(self): def supported_features(self):
return ( return (
SUPPORT_PLAY SUPPORT_PLAY | SUPPORT_PLAY_MEDIA |
| SUPPORT_PLAY_MEDIA SUPPORT_PAUSE | SUPPORT_STOP |
| SUPPORT_PAUSE SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE
| SUPPORT_STOP
| SUPPORT_VOLUME_SET
| SUPPORT_VOLUME_MUTE
| SUPPORT_BROWSE_MEDIA
| SUPPORT_SEEK
| SUPPORT_TURN_OFF
| SUPPORT_TURN_ON
) )
@property @property
def volume_level(self): def volume_level(self):
return self._data.get("player", {}).get("volume", 0) return self.data.get("volume", 0)
@property @property
def is_volume_muted(self): def is_volume_muted(self):
return self._data.get("player", {}).get("muted", False) return self.data.get("muted", False)
@property @property
def media_duration(self): def media_content_id(self):
duration = self._data.get("player", {}).get("media_duration", None) return self.data.get("src", "")
return float(duration) if duration is not None else None
@property def set_volume_level(self, volume):
def media_position(self): self.connection.send("set_volume", volume_level=volume)
position = self._data.get("player", {}).get("media_position", None) def mute_volume(self, mute):
return float(position) if position is not None else None self.connection.send("mute", mute=mute)
@property def play_media(self, media_type, media_id, **kwargs):
def media_position_updated_at(self): self.connection.send("play", media_content_id=media_id)
return dt.utcnow() def media_play(self):
self.connection.send("play")
async def async_set_volume_level(self, volume): def media_pause(self):
await self.browser.send("player-set-volume", volume_level=volume) self.connection.send("pause")
def media_stop(self):
async def async_mute_volume(self, mute): self.connection.send("stop")
await self.browser.send("player-mute", mute=mute)
async def async_play_media(self, media_type, media_id, **kwargs):
if media_source.is_media_source_id(media_id):
play_item = await media_source.async_resolve_media(
self.hass, media_id, self.entity_id
)
media_type = play_item.mime_type
media_id = play_item.url
media_id = async_process_play_media_url(self.hass, media_id)
if media_type in (MEDIA_TYPE_URL, MEDIA_TYPE_MUSIC):
media_id = async_process_play_media_url(self.hass, media_id)
await self.browser.send(
"player-play", media_content_id=media_id, media_type=media_type, **kwargs
)
async def async_browse_media(self, media_content_type=None, media_content_id=None):
"""Implement the websocket media browsing helper."""
return await media_source.async_browse_media(
self.hass,
media_content_id,
# content_filter=lambda item: item.media_content_type.startswith("audio/"),
)
async def async_media_play(self):
await self.browser.send("player-play")
async def async_media_pause(self):
await self.browser.send("player-pause")
async def async_media_stop(self):
await self.browser.send("player-stop")
async def async_media_seek(self, position):
await self.browser.send("player-seek", position=position)
async def async_turn_off(self):
await self.browser.send("player-turn-off")
async def async_turn_on(self, **kwargs):
await self.browser.send("player-turn-on", **kwargs)

View File

@ -1,71 +1,36 @@
from homeassistant.components.frontend import add_extra_js_url from aiohttp import web
from homeassistant.components.http import HomeAssistantView
from .const import FRONTEND_SCRIPT_URL, SETTINGS_PANEL_URL from .const import FRONTEND_SCRIPT_URL, DATA_EXTRA_MODULE_URL
import logging
_LOGGER = logging.getLogger(__name__)
async def async_setup_view(hass): def setup_view(hass):
if DATA_EXTRA_MODULE_URL not in hass.data:
hass.data[DATA_EXTRA_MODULE_URL] = set()
url_set = hass.data[DATA_EXTRA_MODULE_URL]
url_set.add(FRONTEND_SCRIPT_URL)
# Serve the Browser Mod controller and add it as extra_module_url hass.http.register_view(ModView(hass, FRONTEND_SCRIPT_URL))
hass.http.register_static_path(
FRONTEND_SCRIPT_URL,
hass.config.path("custom_components/browser_mod/browser_mod.js"),
)
add_extra_js_url(hass, FRONTEND_SCRIPT_URL)
# Serve the Browser Mod Settings panel and register it as a panel class ModView(HomeAssistantView):
hass.http.register_static_path(
SETTINGS_PANEL_URL,
hass.config.path("custom_components/browser_mod/browser_mod_panel.js"),
)
hass.components.frontend.async_register_built_in_panel(
component_name="custom",
sidebar_title="Browser Mod",
sidebar_icon="mdi:server",
frontend_url_path="browser-mod",
require_admin=True,
config={
"_panel_custom": {
"name": "browser-mod-panel",
"js_url": SETTINGS_PANEL_URL,
}
},
)
# Also load Browser Mod as a lovelace resource so it's accessible to Cast name = "browser_mod_script"
resources = hass.data["lovelace"]["resources"] requires_auth = False
if resources:
if not resources.loaded:
await resources.async_load()
resources.loaded = True
frontend_added = False def __init__(self, hass, url):
for r in resources.async_items(): self.url = url
if r["url"].startswith(FRONTEND_SCRIPT_URL): self.config_dir = hass.config.path()
frontend_added = True
continue
# While going through the resources, also preload card-mod if it is found async def get(self, request):
if "card-mod.js" in r["url"]: path = "{}/custom_components/browser_mod/browser_mod.js".format(self.config_dir)
add_extra_js_url(hass, r["url"])
if not frontend_added: filecontent = ""
if getattr(resources, "async_create_item", None):
await resources.async_create_item( try:
{ with open(path, mode="r", encoding="utf-8", errors="ignore") as localfile:
"res_type": "module", filecontent = localfile.read()
"url": FRONTEND_SCRIPT_URL + "?automatically-added", localfile.close()
} except Exception as exception:
) pass
elif getattr(resources, "data", None) and getattr(
resources.data, "append", None return web.Response(body=filecontent, content_type="text/javascript", charset="utf-8")
):
resources.data.append(
{
"type": "module",
"url": FRONTEND_SCRIPT_URL + "?automatically-added",
}
)

View File

@ -1,64 +1,37 @@
from homeassistant.components.sensor import SensorEntity import logging
from datetime import datetime
from .const import DOMAIN, DATA_ADDERS from homeassistant.const import STATE_UNAVAILABLE
from .entities import BrowserModEntity
from .helpers import setup_platform, BrowserModEntity
async def async_setup_platform( PLATFORM = 'sensor'
hass, config_entry, async_add_entities, discoveryInfo=None
):
hass.data[DOMAIN][DATA_ADDERS]["sensor"] = async_add_entities
async def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
return setup_platform(hass, config, async_add_devices, PLATFORM, BrowserModSensor)
async def async_setup_entry(hass, config_entry, async_add_entities): class BrowserModSensor(BrowserModEntity):
await async_setup_platform(hass, {}, async_add_entities) domain = PLATFORM
def __init__(self, hass, connection, deviceID, alias=None):
super().__init__(hass, connection, deviceID, alias)
self.last_seen = None
class BrowserSensor(BrowserModEntity, SensorEntity): def updated(self):
def __init__( self.last_seen = datetime.now()
self, self.schedule_update_ha_state()
coordinator,
browserID,
parameter,
name,
unit_of_measurement=None,
device_class=None,
icon=None,
):
BrowserModEntity.__init__(self, coordinator, browserID, name, icon)
SensorEntity.__init__(self)
self.parameter = parameter
self._device_class = device_class
self._unit_of_measurement = unit_of_measurement
@property @property
def native_value(self): def state(self):
val = self._data.get("browser", {}).get(self.parameter, None) if not self.connection.connection:
if len(str(val)) > 255: return STATE_UNAVAILABLE
val = str(val)[:250] + "..." return len(self.connection.connection)
return val
@property @property
def device_class(self): def device_state_attributes(self):
return self._device_class return {
"type": "browser_mod",
@property "last_seen": self.last_seen,
def native_unit_of_measurement(self): "deviceID": self.deviceID,
return self._unit_of_measurement **self.data
}
@property
def extra_state_attributes(self):
retval = super().extra_state_attributes
if self.parameter == "currentUser":
retval["userData"] = self._data.get("browser", {}).get("userData")
if self.parameter == "path":
retval["pathSegments"] = (
self._data.get("browser", {}).get("path", "").split("/")
)
if self.parameter == "userAgent":
retval["userAgent"] = self._data.get("browser", {}).get("userAgent")
return retval

View File

@ -1,64 +1,37 @@
import logging import logging
from .const import DOMAIN, DATA_DEVICES, DATA_ALIASES, USER_COMMANDS
from homeassistant.helpers import device_registry
from .const import (
BROWSER_MOD_SERVICES,
DOMAIN,
DATA_BROWSERS,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def setup_service(hass):
async def async_setup_services(hass): def handle_command(call):
def call_service(service, targets, data): command = call.data.get("command", None)
if not command:
browsers = hass.data[DOMAIN][DATA_BROWSERS] return
targets = call.data.get("deviceID", None)
if isinstance(targets, str): if isinstance(targets, str):
targets = [targets] targets = [targets]
devices = hass.data[DOMAIN][DATA_DEVICES]
aliases = hass.data[DOMAIN][DATA_ALIASES]
if not targets:
targets = devices.keys()
targets = [aliases.get(t, t) for t in targets]
# If no targets were specified, send to all browsers data = dict(call.data)
if len(targets) == 0: del data["command"]
targets = browsers.keys()
for target in targets: for t in targets:
if target not in browsers: if t in devices:
continue devices[t].send(command, **data)
browser = browsers[target]
hass.create_task(browser.send(service, **data))
def handle_service(call): def command_wrapper(call):
service = call.service command = call.service.replace('_','-')
data = {**call.data} call.data = dict(call.data)
call.data['command'] = command
handle_command(call)
browsers = data.pop("browser_id", []) hass.services.async_register(DOMAIN, 'command', handle_command)
if isinstance(browsers, str): for cmd in USER_COMMANDS:
browsers = [browsers] hass.services.async_register(DOMAIN, cmd.replace('-','_'), command_wrapper)
browsers = set(browsers)
device_ids = set(data.pop("device_id", []))
area_ids = set(data.pop("area_id", []))
dr = device_registry.async_get(hass)
for device in device_ids:
dev = dr.async_get(device)
if not dev:
continue
browserID = list(dev.identifiers)[0][1]
if browserID is None:
continue
browsers.add(browserID)
for area in area_ids:
for dev in device_registry.async_entries_for_area(dr, area):
browserID = list(dev.identifiers)[0][1]
if browserID is None:
continue
browsers.add(browserID)
call_service(service, browsers, data)
for service in BROWSER_MOD_SERVICES:
hass.services.async_register(DOMAIN, service, handle_service)

View File

@ -1,238 +1,9 @@
sequence: command:
description: "Run a sequence of services" description: Send a command to a browser
target:
device:
integration: "browser_mod"
multiple: true
entity:
integration: "browser_mod_none"
area:
device:
integration: "browser_mod"
multiple: true
fields: fields:
sequence: command:
name: Actions description: Command to send
description: List of services to run example: 'navigate'
selector: deviceID:
object: description: List of receiving browsers
example: '["99980b13-dabc9563", "office_computer"]'
delay:
description: "Wait for a time"
target:
device:
integration: "browser_mod"
multiple: true
entity:
integration: "browser_mod_none"
area:
device:
integration: "browser_mod"
multiple: true
fields:
time:
name: Time
description: Time to wait (ms)
selector:
number:
mode: box
popup:
description: "Display a popup"
target:
device:
integration: "browser_mod"
multiple: true
entity:
integration: "browser_mod_none"
area:
device:
integration: "browser_mod"
multiple: true
fields:
title:
name: Title
description: "Popup title"
selector:
text:
content:
name: Content
required: true
description: "Popup content (Test or lovelace card configuration)"
selector:
object:
size:
name: Size
selector:
select:
mode: dropdown
options:
- normal
- wide
- fullscreen
right_button:
name: Right button
description: Text of the right button
selector:
text:
right_button_action:
name: Right button action
description: Action to perform when the right button is pressed
selector:
object:
left_button:
name: Left button
description: Text of the left button
selector:
text:
left_button_action:
name: Left button action
description: Action to perform when left button is pressed
selector:
object:
dismissable:
name: User dismissable
description: Whether the popup can be closed by the user without action
default: true
selector:
boolean:
dismiss_action:
name: Dismiss action
description: Action to perform when popup is dismissed
selector:
object:
autoclose:
name: Auto close
description: Close the popup automatically on mouse, pointer or keyboard activity
default: false
selector:
boolean:
timeout:
name: Auto close timeout
description: Time before closing (ms)
selector:
number:
mode: box
timeout_action:
name: Timeout action
description: Action to perform when popup is closed by timeout
selector:
object:
style:
name: Styles
description: CSS code to apply to the popup window
selector:
text:
multiline: true
more_info:
description: "Show more-info dialog"
target:
device:
integration: "browser_mod"
multiple: true
entity:
integration: "browser_mod_none"
area:
device:
integration: "browser_mod"
multiple: true
fields:
entity:
name: Entity ID
required: true
selector:
text:
large:
name: Large size
default: false
selector:
boolean:
ignore_popup_card:
name: Ignore any active popup-card overrides
default: false
selector:
boolean:
close_popup:
description: "Close a popup"
target:
device:
integration: "browser_mod"
multiple: true
entity:
integration: "browser_mod_none"
area:
device:
integration: "browser_mod"
multiple: true
navigate:
description: "Navigate browser to a different page"
target:
device:
integration: "browser_mod"
multiple: true
entity:
integration: "browser_mod_none"
area:
device:
integration: "browser_mod"
multiple: true
fields:
path:
name: Path
description: "Target path"
selector:
text:
refresh:
description: "Refresh page"
target:
device:
integration: "browser_mod"
multiple: true
entity:
integration: "browser_mod_none"
area:
device:
integration: "browser_mod"
multiple: true
console:
description: "Print text to browser console"
target:
device:
integration: "browser_mod"
multiple: true
entity:
integration: "browser_mod_none"
area:
device:
integration: "browser_mod"
multiple: true
fields:
message:
name: Message
description: "Text to print"
selector:
text:
javascript:
description: "Run arbitrary JavaScript code"
target:
device:
integration: "browser_mod"
multiple: true
entity:
integration: "browser_mod_none"
area:
device:
integration: "browser_mod"
multiple: true
fields:
code:
name: Code
description: "JavaScript code to run"
selector:
object:

View File

@ -1,153 +0,0 @@
import logging
import attr
STORAGE_VERSION = 1
STORAGE_KEY = "browser_mod.storage"
LISTENER_STORAGE_KEY = "browser_mod.config_listeners"
_LOGGER = logging.getLogger(__name__)
@attr.s
class SettingsStoreData:
hideSidebar = attr.ib(type=bool, default=None)
hideHeader = attr.ib(type=bool, default=None)
defaultPanel = attr.ib(type=str, default=None)
sidebarPanelOrder = attr.ib(type=list, default=None)
sidebarHiddenPanels = attr.ib(type=list, default=None)
sidebarTitle = attr.ib(type=str, default=None)
faviconTemplate = attr.ib(type=str, default=None)
titleTemplate = attr.ib(type=str, default=None)
@classmethod
def from_dict(cls, data):
return cls(**data)
def asdict(self):
return attr.asdict(self)
@attr.s
class BrowserStoreData:
last_seen = attr.ib(type=int, default=0)
registered = attr.ib(type=bool, default=False)
camera = attr.ib(type=bool, default=False)
settings = attr.ib(type=SettingsStoreData, factory=SettingsStoreData)
meta = attr.ib(type=str, default="default")
@classmethod
def from_dict(cls, data):
settings = SettingsStoreData.from_dict(data.get("settings", {}))
return cls(
**(
data
| {
"settings": settings,
}
)
)
def asdict(self):
return attr.asdict(self)
@attr.s
class ConfigStoreData:
browsers = attr.ib(type=dict[str:BrowserStoreData], factory=dict)
version = attr.ib(type=str, default="2.0")
settings = attr.ib(type=SettingsStoreData, factory=SettingsStoreData)
user_settings = attr.ib(type=dict[str:SettingsStoreData], factory=dict)
@classmethod
def from_dict(cls, data={}):
browsers = {
k: BrowserStoreData.from_dict(v)
for k, v in data.get("browsers", {}).items()
}
user_settings = {
k: SettingsStoreData.from_dict(v)
for k, v in data.get("user_settings", {}).items()
}
settings = SettingsStoreData.from_dict(data.get("settings", {}))
return cls(
**(
data
| {
"browsers": browsers,
"settings": settings,
"user_settings": user_settings,
}
)
)
def asdict(self):
return attr.asdict(self)
class BrowserModStore:
def __init__(self, hass):
self.store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
self.listeners = []
self.data = None
self.dirty = False
async def save(self):
if self.dirty:
await self.store.async_save(attr.asdict(self.data))
self.dirty = False
async def load(self):
stored = await self.store.async_load()
if stored:
self.data = ConfigStoreData.from_dict(stored)
if self.data is None:
self.data = ConfigStoreData()
await self.save()
self.dirty = False
async def updated(self):
self.dirty = True
for listener in self.listeners:
listener(attr.asdict(self.data))
await self.save()
def asdict(self):
return self.data.asdict()
def add_listener(self, callback):
self.listeners.append(callback)
def remove_listener():
self.listeners.remove(callback)
return remove_listener
def get_browser(self, browserID):
return self.data.browsers.get(browserID, BrowserStoreData())
async def set_browser(self, browserID, **data):
browser = self.data.browsers.get(browserID, BrowserStoreData())
browser.__dict__.update(data)
self.data.browsers[browserID] = browser
await self.updated()
async def delete_browser(self, browserID):
del self.data.browsers[browserID]
await self.updated()
def get_user_settings(self, name):
return self.data.user_settings.get(name, SettingsStoreData())
async def set_user_settings(self, name, **data):
settings = self.data.user_settings.get(name, SettingsStoreData())
settings.__dict__.update(data)
self.data.user_settings[name] = settings
await self.updated()
def get_global_settings(self):
return self.data.settings
async def set_global_settings(self, **data):
self.data.settings.__dict__.update(data)
await self.updated()

View File

@ -1,109 +0,0 @@
# The Browser Mod Configuration Panel
## This browser
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).
Browser Mod will initially assigning a random `BrowserID` to each _Browser_ that connects, but you can change this if you want.
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.
### Register
Registering a _Browser_ as a device will create a Home Assistant Device associated with that browser. The device has the following entities:
- A `media_player` entitiy which will play sound and video through the browser.
- 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.
- 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.
- A number of `sensor` and `binary_sensor` entities providing different bits of information about the Browser which you may or may not find useful.
Registering a browser also enables it to act as a target for Browser Mod _services_.
### Browser ID
This box lets you set the `BrowserID` for the current _Browser_.
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.
There may be benefits to using the same `BrowserID` in some cases, so you'll have to experiment with what works for you.
Browser Mod is trying hard to keep the Browser ID constant
### Enable camera
If your device has a camera, this will allow it to be forwarded as a `camera` entity to Home Assistant.
## Registered Browsers
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.
### Register CAST browser
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.
## Frontend Settings
This section is for settings that change the default behavior of the Home Assistant frontend.
For each option the first applicable value will be applied.
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".
![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)
### Title template
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/).
> Ex:
>
> ```jinja
> {{ states.persistent_notification | list | count}} - Home Assistant
> ```
### Favicon template
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 `<config>/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 `<config>/www/` replaced by `/local/` (see [Hosting files](https://www.home-assistant.io/integrations/http/#hosting-files)).
> Ex:
>
> ```jinja
> {% if is_state("light.bed_light", "on") %}
> /local/icons/green.png
> {% else %}
> /local/icons/red.png
> {% endif %}
> ```
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.
### Hide Sidebar
This will hide the sidebar wit the navigation links. You can still access all the pages via normal links.
> Tip: add `/browser-mod` to the end of your home assistant URL when you need to turn this off again...
### Hide header
This will hide the header bar. Completely. It does not care if there are useful navigation links there or not. It's gone.
> 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.
### Default dashboard
Set the default dashboard that is shown when you access `https://<your home assistant url>/` with nothing after the `/`.
> *Note:* 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`.
### Sidebar order
Set the order and hidden items of the sidebar. To change this setting:
- Click the "EDIT" button
- Change the sidebar to how you want it
- DO NOT click "DONE"
- Either add a new User or Browser setting or click one of the pencil icons to overwrite an old layout
- Click the "RESTORE" button
### Sidebar title
This changes the "Home Assistant" text that is displayed at the top of the sidebar.
Accepts Jinja [templates](https://www.home-assistant.io/docs/configuration/templating/).

View File

@ -1,161 +0,0 @@
## Anatomy of a popup
```yaml
service: browser_mod.popup
data:
title: The title
content: The content
right_button: Right button
left_button: Left button
```
![Screenshot illustrating the title, content and button placements of a popup](https://user-images.githubusercontent.com/1299821/182708739-f89e3b2b-199f-43e0-bf04-e1dfc7075b2a.png)
## Size
The `size` parameter can be set to `normal`, `wide` and `fullscreen` with results as below (background blur has been exagerated for clarity):
![Screenshot of a normal size popup](https://user-images.githubusercontent.com/1299821/182709146-439814f1-d479-4fc7-aab1-e28f5c9a13c7.png)
![Screenshot of a wide size popup](https://user-images.githubusercontent.com/1299821/182709172-c98a9c23-5e58-4564-bcb7-1d187842948f.png)
![Screenshot of a fullscreen size popup](https://user-images.githubusercontent.com/1299821/182709224-fb2e7b92-8a23-4422-95a0-f0f2835909e0.png)
## HTML content
```yaml
service: browser_mod.popup
data:
title: HTML content
content: |
An <b>HTML</b> string.
<p> Pretty much any HTML works: <ha-icon icon="mdi:lamp" style="color: red;"></ha-icon>
```
![Screenshot of a popup rendering the HTML code above](https://user-images.githubusercontent.com/1299821/182710044-6fea3ba3-5262-4361-a131-691770340518.png)
## Dashboard card content
```yaml
service: browser_mod.popup
data:
title: HTML content
content:
type: entities
entities:
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
```
![Screenshot of a popup rendering the entities card described above](https://user-images.githubusercontent.com/1299821/182710445-f09b74b8-dd53-4d65-8eba-0945fc1d418e.png)
## Form content
`content` can be a list of ha-form schemas and the popup will then contain a form for user input:
```
<ha-form schema>:
name: <string>
[label: <string>]
[default: <any>]
selector: <Home Assistant Selector>
```
| | |
|-|-|
| `name` | A unique parameter name |
| `label` | A description of the parameter |
| `default` | The default value for the parameter |
| `selector` | A [Home Assistant selector](https://www.home-assistant.io/docs/blueprint/selectors) |
The data from the form will be forwarded as data for any `right_button_action` or `left_button_action` of the popup.
```yaml
service: browser_mod.popup
data:
title: Form content
content:
- name: parameter_name
label: Descriptive name
selector:
text: null
- name: another_parameter
label: A number
default: 5
selector:
number:
min: 0
max: 10
slider: true
```
![Screenshot of a popup rendering the form described above](https://user-images.githubusercontent.com/1299821/182712670-f3b4fdb7-84a9-49d1-a26f-2cdaa450fa0e.png)
## Actionable popups
Example of a popup with actions opening more popups or calling Home Assistant services:
```yaml
service: browser_mod.popup
data:
content: Do you want to turn the light on?
right_button: "Yes"
left_button: "No"
right_button_action:
service: light.turn_on
data:
entity_id: light.bed_light
left_button_action:
service: browser_mod.popup
data:
title: Really?
content: Are you sure?
right_button: "Yes"
left_button: "No"
right_button_action:
service: browser_mod.popup
data:
content: Fine, live in darkness.
dismissable: false
title: Ok
timeout: 3000
left_button_action:
service: light.turn_on
data:
entity_id: light.bed_light
```
![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)
## Forward form data
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:
```yaml
service: browser_mod.popup
data:
title: Where to vacuum?
right_button: Go!
right_button_action:
service: vacuum.send_command
data:
entity_id: vacuum.xiaomi
command: app_segment_clean
content:
- name: params
label: Rooms to clean
selector:
select:
multiple: true
options:
- label: Kitchen
value: 11
- label: Living room
value: 13
- label: Bedroom
value: 12
```
![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)

View File

@ -1,288 +0,0 @@
## Reading guide
Service parameters are described using the following conventions:
- `<type>` in brackets describe the type of a parameter, e.g.
- `<string>` is a piece of text
- `<number>` is a number
- `<TRUE/false>` means the value must be either `true` or `false` with `true` being the default
- `<service call>` means a full service call specification. Note that this can be any service, not just Browser Mod services
- `<Browser IDs>` is a list of BrowserIDs
- Square brackets `[ ]` indicate that a parameter is optional and can be omitted.
### `<service call>`
A service call is a combination of a service and it's data:
Ex, a `<service call>` for `browser_mod.more_info` with `light.bed_light` as entity:
```yaml
service: browser_mod.more_info
data:
entity: light.bed_light
```
If `data` contains `browser_id: THIS` then `THIS` will be replaced with the current browser ID.
## A note about targets
Browser Mod services can be called in two different ways which behave slightly differently.
The first way is as a *server* call. This is when the service is called from a script or automation, from the dev-services panel or from a dashboard `call-service` action.
The second way is as a *browser* call. This is when the service is called from a dashboard `fire-dom-event` action, as a part of a `browser_mod.sequence` call or as a `browser_mod.popup` `_action`.
The notable difference between the two is when no target (`browser_id`) is specified, in which case:
- A *server* call will perform the service on ALL REGISTERED BROWSERS.
- A *browser* call will perform the service on THE CURRENT BROWSER, i.e. the browser it was called from.
---
Finally, in *browser* calls, a parameter `browser_id` with the value `THIS` will be replaced with the current Browsers browser ID.
Ex:
```yaml
tap_action:
action: fire-dom-event
browser_mod:
service: script.print_clicking_browser
data:
browser_id: THIS
```
with the script:
```yaml
script:
print_clicking_browser:
sequence:
- service: system_log.write
data:
message: "Button was clicked in {{browser_id}}"
```
Will print `"Button was clicked in 79be65e8-f06c78f" to the Home Assistant log.
# Calling services
Services can be called from the backend using the normal service call procedures. Registered Browsers can be selected as targets through their device:
![A picture exemplifying setting up a browser_mod.more_info service call in the GUI editor](https://user-images.githubusercontent.com/1299821/180668350-1cbe751d-615d-4102-b939-e49e9cd2ca74.png)
In yaml, the BrowserID can be used for targeting a specific browser:
```yaml
service: browser_mod.more_info
data:
entity: light.bed_light
browser_id:
- 79be65e8-f06c78f
```
If no target or `browser_id` is specified, the service will target all registerd Browsers.
To call a service from a dashboard use the call-service [action](https://www.home-assistant.io/dashboards/actions/) or the special action `fire-dom-event`:
```yaml
tap_action:
action: fire-dom-event
browser_mod:
service: browser_mod.more_info
data:
entity: light.bed_light
```
Services called via `fire-dom-event` or called as a part of a different service call will (by default) _only_ target the current Browser (even if it's not registered).
# Browser Mod Services
> Note: Since `browser_id` is common for all services it is not explained further.
## `browser_mod.navigate`
Point the browser to the given Home Assistant path.
```yaml
service: browser_mod.navigate
data:
path: <string>
[browser_id: <Browser IDs>]
```
| | |
|---|---|
|`path` | A Home Assistant path. <br/>E.x. `/lovelace/`, `/my-dashboard/bedroom`, `/browser_mod/`, `/config/devices/device/20911cc5a63b1caafa2089618545eb8a`...|
## `browser_mod.refresh`
Reload the current page.
```yaml
service: browser_mod.refresh
data:
[browser_id: <Browser IDs>]
```
## `browser_mod.more_info`
Show a more-info dialog.
```yaml
service: browser_mod.more_info
data:
entity: <string>
[large: <true/FALSE>]
[ignore_popup_card: <true/FALSE>]
[browser_id: <Browser IDs>]
```
| | |
|---|---|
|`entity`| The entity whose more-info dialog to display. |
|`large`| If true, the dialog will be displayed wider, as if you had clicked the title of the dialog. |
| `ignore_popup_card` | If true the more-info dialog will be shown even if there's currently a popup-card which would override it. |
## `browser_mod.popup`
Display a popup dialog
```yaml
service: browser_mod.popup
data:
[title: <string>]
content: <string / Dashboard card configuration / ha-form schema>
[size: <NORMAL/wide/fullscreen>]
[right_button: <string>]
[right_button_action: <service call>]
[left_button: <string>]
[left_button_action: <service call>]
[dismissable: <TRUE/false>]
[dismiss_action: <service call>]
[autoclose: <true/FALSE>]
[timeout: <number>]
[timeout_action: <service call>]
[style: <string>]
[browser_id: <Browser IDs>]
```
| | |
|---|---|
|`title` | The title of the popup window.|
|`content`| HTML, a dashboard card configuration or ha-form schema to display.|
| `size` | `wide` will make the popup window wider. `fullscreen` will make it cover the entire screen. |
| `right_button`| The text of the right action button.|
| `right_button_action`| Action to perform when the right action button is pressed. |
| `left_button`| The text of the left action button.|
| `left_button_action`| Action to perform when the left action button is pressed. |
| `dismissable`| If false the dialog cannot be closed by the user without clicking an action button. |
| `dismiss_action` | An action to perform if the dialog is closed by the user without clicking an action button. |
| `autoclose` | If true the dialog will close automatically when the mouse, screen or keyboard is touched. This will perform the `dismiss_action`. |
| `timeout` | If set will close the dialog after `timeout` milliseconds. |
| `timeout_action` | An action to perform if the dialog is closed by timeout. |
| `style` | CSS styles to apply to the dialog. |
The default value for `style` is as follows:
```yaml
style:
--popup-min-width: 400px;
--popup-max-width: 600px;
--popup-border-width: var(--ha-card-border-width, 2px);
--popup-border-color: var(--ha-card-border-color, var(--divider-color, #eee));
--popup-border-radius: 8px;
--popup-background-color: var(--ha-card-background, var(--card-background-color, white));
--popup-header-background-color: var(--popup-background-color, var(--sidebar-background-color));
```
Note that any Browser Mod services performed as `_action`s here will be performed only on the same Browser as initiated the action unless `browser_id` is given.
If a ha-form schema is used for `content` the resulting data will be inserted into the `data` for any `_action`.
See [popups.md](popups.md) for more information and usage examples.
## `browser_mod.close_popup`
Close any currently open popup or more-info dialog.
```yaml
service: browser_mod.close_popup
data:
[browser_id: <Browser IDs>]
```
## `browser_mod.sequence`
Perform several services sequentially.
```yaml
service: browser_mod.sequence
data:
sequence:
- <service call>
- <service call>
- ...
[browser_id: <Browser IDs>]
```
| | |
|---|---|
|`sequence` | List of actions to perform. |
Note that if `browser_id` is omitted in the service calls listed in `sequence` the services will be performed on the Browser that's targeted as a whole rather than all browsers.
## `browser_mod.delay`
Wait for a specified time.
```yaml
service: browser_mod.delay
data:
time: <number>
[browser_id: <Browser IDs>]
```
| | |
|---|---|
|`time` | Number of milliseconds to wait.|
This is probably most useful as part of a `browser_mod.sequence` call.
## `browsermod.console`
Print a text to the browsers javascript console.
```yaml
service: browser_mod.console
data:
message: <string>
[browser_id: <Browser IDs>]
```
| | |
|---|---|
|`message` | Text to print. |
## `browsermod.javascript`
Run arbitrary javascript code in the browser.
```yaml
service: browser_mod.console
data:
code: <string>
[browser_id: <Browser IDs>]
```
| | |
|---|---|
|`code` | Code to run. |
Only use this one if you know what you're doing.
The `hass` frontend object is available as global variable `hass`.

View File

@ -1,4 +0,0 @@
{
"name": "browser_mod",
"homeassistant": "2022.3.0"
}

108
js/browser-player.js Normal file
View File

@ -0,0 +1,108 @@
import { LitElement, html, css } from "card-tools/src/lit-element";
import { deviceID } from "card-tools/src/deviceId"
import { moreInfo } from "card-tools/src/more-info"
class BrowserPlayer extends LitElement {
static get properties() {
return {
hass: {},
};
}
setConfig(config) {
this._config = config;
}
handleMute(ev) {
window.browser_mod.mute({});
}
handleVolumeChange(ev) {
const vol = parseFloat(ev.target.value);
window.browser_mod.set_volume({volume_level: vol});
}
handleMoreInfo(ev) {
moreInfo("media_player."+window.browser_mod.entity_id);
}
handlePlayPause(ev) {
if (window.browser_mod.player.paused)
window.browser_mod.play({});
else
window.browser_mod.pause({});
}
render() {
const player = window.browser_mod.player;
return html`
<ha-card>
<div class="card-content">
<paper-icon-button
.icon=${player.muted
? "mdi:volume-off"
: "mdi:volume-high"
}
@click=${this.handleMute}
></paper-icon-button>
<ha-paper-slider
min=0
max=1
step=0.01
?disabled=${player.muted}
value=${player.volume}
@change=${this.handleVolumeChange}
></ha-paper-slider>
${window.browser_mod.player_state === "stopped"
? html`<div class="placeholder"></div>`
: html`
<paper-icon-button
.icon=${player.paused
? "mdi:play"
: "mdi:pause"
}
@click=${this.handlePlayPause}
highlight
></paper-icon-button>
`}
<paper-icon-button
.icon=${"mdi:settings"}
@click=${this.handleMoreInfo}
></paper-icon-button>
</div>
<div class="device-id">
${deviceID}
</div>
</ha-card>
`;
}
static get styles() {
return css`
paper-icon-button[highlight] {
color: var(--accent-color);
}
.card-content {
display: flex;
justify-content: center;
}
.placeholder {
width: 24px;
padding: 8px;
}
.device-id {
opacity: 0.7;
font-size: xx-small;
margin-top: -10px;
user-select: all;
-webkit-user-select: all;
-moz-user-select: all;
-ms-user-select: all;
}
`
}
}
if(!customElements.get("browser-player"))
customElements.define("browser-player", BrowserPlayer);

View File

@ -1,294 +0,0 @@
import { LitElement, html, css } from "lit";
import { property } from "lit/decorators.js";
import { selectTree } from "../helpers";
class BrowserModSettingsTable extends LitElement {
@property() settingKey;
@property() settingSelector = {
template: {},
};
@property() hass;
@property() default;
@property() tableData = [];
_users = undefined;
firstUpdated() {
window.browser_mod.addEventListener("browser-mod-config-update", () =>
this.updateTable()
);
}
updated(changedProperties) {
if (changedProperties.has("settingKey")) this.updateTable();
if (
changedProperties.has("hass") &&
changedProperties.get("hass") === undefined
)
this.updateTable();
}
async fetchUsers(): Promise<any[]> {
if (this._users === undefined)
this._users = await this.hass.callWS({ type: "config/auth/list" });
return this._users;
}
clearSetting(type, target) {
const clearSettingCallback = async () => {
if (this.settingKey === "sidebarPanelOrder") {
const sideBar: any = await selectTree(
document,
"home-assistant $ home-assistant-main $ app-drawer-layout app-drawer ha-sidebar"
);
window.browser_mod.setSetting(type, target, {
sidebarHiddenPanels: "[]",
sidebarPanelOrder: "[]",
});
window.browser_mod.setSetting(type, target, {
sidebarHiddenPanels: undefined,
sidebarPanelOrder: undefined,
});
return;
}
if (this.default)
window.browser_mod.setSetting(type, target, {
[this.settingKey]: this.default,
});
window.browser_mod.setSetting(type, target, {
[this.settingKey]: undefined,
});
};
window.browser_mod?.showPopup(
"Are you sure",
"Do you wish to clear this setting?",
{
right_button: "Yes",
right_button_action: clearSettingCallback,
left_button: "No",
}
);
}
changeSetting(type, target) {
const changeSettingCallback = async (newValue) => {
if (this.settingKey === "sidebarPanelOrder") {
const sideBar: any = await selectTree(
document,
"home-assistant $ home-assistant-main $ app-drawer-layout app-drawer ha-sidebar"
);
window.browser_mod.setSetting(type, target, {
sidebarHiddenPanels: JSON.stringify(sideBar._hiddenPanels),
sidebarPanelOrder: JSON.stringify(sideBar._panelOrder),
});
console.log(sideBar._hiddenPanels, sideBar._panelOrder);
return;
}
let value = newValue.value;
window.browser_mod.setSetting(type, target, { [this.settingKey]: value });
};
const settings = window.browser_mod?.getSetting?.(this.settingKey);
const def =
(type === "global" ? settings.global : settings[type][target]) ??
this.default;
window.browser_mod?.showPopup(
"Change value",
(this.settingSelector as any).plaintext ?? [
{
name: "value",
label: (this.settingSelector as any).label ?? "",
default: def,
selector: this.settingSelector,
},
],
{
right_button: "OK",
right_button_action: changeSettingCallback,
left_button: "Cancel",
}
);
}
addBrowserSetting() {
const settings = window.browser_mod?.getSetting?.(this.settingKey);
const allBrowsers = window.browser_mod._data.browsers;
const browsers = [];
for (const target of Object.keys(allBrowsers)) {
if (settings.browser[target] == null) browsers.push(target);
}
if (browsers.length === 0) {
window.browser_mod.showPopup(
"No browsers to configure",
"All registered browsers have already been configured.",
{ right_button: "OK" }
);
return;
}
window.browser_mod.showPopup(
"Select browser to configure",
[
{
name: "browser",
label: "",
selector: {
select: { options: browsers },
},
},
],
{
right_button: "Next",
right_button_action: (value) =>
this.changeSetting("browser", value.browser),
left_button: "Cancel",
}
);
}
async addUserSetting() {
const settings = window.browser_mod?.getSetting?.(this.settingKey);
const allUsers = await this.fetchUsers();
const users = [];
for (const target of allUsers) {
if (target.username && settings.user[target.id] == null)
users.push({ label: target.name, value: target.id });
}
if (users.length === 0) {
window.browser_mod.showPopup(
"No users to configure",
"All users have already been configured.",
{ right_button: "OK" }
);
return;
}
window.browser_mod.showPopup(
"Select user to configure",
[
{
name: "user",
label: "",
selector: {
select: { options: users },
},
},
],
{
right_button: "Next",
right_button_action: (value) => this.changeSetting("user", value.user),
left_button: "Cancel",
}
);
}
async updateTable() {
if (this.hass === undefined) return;
const users = await this.fetchUsers();
const settings = window.browser_mod?.getSetting?.(this.settingKey);
const data = [];
for (const [k, v] of Object.entries(settings.user)) {
const user = users.find((usr) => usr.id === k);
data.push({
name: `User: ${user.name}`,
value: String(v),
controls: html`
<ha-icon-button @click=${() => this.changeSetting("user", k)}>
<ha-icon .icon=${"mdi:pencil"} style="display:flex;"></ha-icon>
</ha-icon-button>
<ha-icon-button @click=${() => this.clearSetting("user", k)}>
<ha-icon .icon=${"mdi:delete"} style="display:flex;"></ha-icon>
</ha-icon-button>
`,
});
}
data.push({
name: "",
value: html`
<mwc-button @click=${() => this.addUserSetting()}>
<ha-icon .icon=${"mdi:plus"}></ha-icon>
Add user setting
</mwc-button>
`,
});
for (const [k, v] of Object.entries(settings.browser)) {
data.push({
name: `Browser: ${k}`,
value: String(v),
controls: html`
<ha-icon-button @click=${() => this.changeSetting("browser", k)}>
<ha-icon .icon=${"mdi:pencil"} style="display:flex;"></ha-icon>
</ha-icon-button>
<ha-icon-button @click=${() => this.clearSetting("browser", k)}>
<ha-icon .icon=${"mdi:delete"} style="display:flex;"></ha-icon>
</ha-icon-button>
`,
});
}
data.push({
name: "",
value: html`
<mwc-button @click=${() => this.addBrowserSetting()}>
<ha-icon .icon=${"mdi:plus"}></ha-icon>
Add browser setting
</mwc-button>
`,
});
data.push({
name: "GLOBAL",
value:
settings.global != null
? String(settings.global)
: html`<span style="color: var(--warning-color);">DEFAULT</span>`,
controls: html`
<ha-icon-button @click=${() => this.changeSetting("global", null)}>
<ha-icon .icon=${"mdi:pencil"} style="display:flex;"></ha-icon>
</ha-icon-button>
<ha-icon-button @click=${() => this.clearSetting("global", null)}>
<ha-icon .icon=${"mdi:delete"} style="display:flex;"></ha-icon>
</ha-icon-button>
`,
});
this.tableData = data;
}
render() {
const global = window.browser_mod?.global_settings?.[this.settingKey];
const columns = {
name: {
title: "Name",
grows: true,
},
value: {
title: "Value",
grows: true,
},
controls: {},
};
return html`
<ha-data-table .columns=${columns} .data=${this.tableData} auto-height>
</ha-data-table>
`;
}
static get styles() {
return css`
:host {
display: block;
}
`;
}
}
customElements.define("browser-mod-settings-table", BrowserModSettingsTable);

View File

@ -1,261 +0,0 @@
import { LitElement, html, css } from "lit";
import { property, state } from "lit/decorators.js";
class BrowserModRegisteredBrowsersCard extends LitElement {
@property() hass;
@property() dirty = false;
toggleRegister() {
if (!window.browser_mod?.connected) return;
window.browser_mod.registered = !window.browser_mod.registered;
this.dirty = true;
}
changeBrowserID(ev) {
window.browser_mod.browserID = ev.target.value;
this.dirty = true;
}
toggleCameraEnabled() {
window.browser_mod.cameraEnabled = !window.browser_mod.cameraEnabled;
this.dirty = true;
}
firstUpdated() {
window.browser_mod.addEventListener("browser-mod-config-update", () =>
this.requestUpdate()
);
}
render() {
return html`
<ha-card outlined>
<h1 class="card-header">
<div class="name">This Browser</div>
${window.browser_mod?.connected
? html`
<ha-icon
class="icon"
.icon=${"mdi:check-circle-outline"}
style="color: var(--success-color, green);"
></ha-icon>
`
: html`
<ha-icon
class="icon"
.icon=${"mdi:circle-outline"}
style="color: var(--error-color, red);"
></ha-icon>
`}
</h1>
<div class="card-content">
${this.dirty
? html`
<ha-alert alert-type="warning">
It is strongly recommended to refresh your browser window
after changing any of the settings in this box.
</ha-alert>
`
: ""}
</div>
<div class="card-content">
<ha-settings-row>
<span slot="heading">Register</span>
<span slot="description"
>Enable this browser as a Device in Home Assistant</span
>
<ha-switch
.checked=${window.browser_mod?.registered}
@change=${this.toggleRegister}
></ha-switch>
</ha-settings-row>
<ha-settings-row>
<span slot="heading">Browser ID</span>
<span slot="description"
>A unique identifier for this browser-device combination.</span
>
<ha-textfield
.value=${window.browser_mod?.browserID}
@change=${this.changeBrowserID}
></ha-textfield>
</ha-settings-row>
${window.browser_mod?.registered
? html`
${this._renderSuspensionAlert()}
<ha-settings-row>
<span slot="heading">Enable camera</span>
<span slot="description"
>Get camera input from this browser (hardware
dependent)</span
>
<ha-switch
.checked=${window.browser_mod?.cameraEnabled}
@change=${this.toggleCameraEnabled}
></ha-switch>
</ha-settings-row>
${window.browser_mod?.cameraError
? html`
<ha-alert alert-type="error">
Setting up the device camera failed. Make sure you have
allowed use of the camera in your browser.
</ha-alert>
`
: ""}
${this._renderInteractionAlert()}
${this._renderFKBSettingsInfo()}
`
: ""}
</div>
</ha-card>
`;
}
private _renderSuspensionAlert() {
if (!this.hass.suspendWhenHidden) return html``;
return html`
<ha-alert alert-type="warning" title="Auto closing connection">
Home Assistant will close the websocket connection to the server
automatically after 5 minutes of inactivity.<br /><br />
While decreasing network trafic and memory usage, this may cause
problems for browser_mod operation.
<br /><br />
If you find that some things stop working for this Browser after a time,
try going to your
<a
href="/profile"
style="text-decoration: underline; color: var(--primary-color);"
>Profile Settings</a
>
and disabling the option
"${this.hass.localize("ui.panel.profile.suspend.header") ||
"Automatically close connection"}".
</ha-alert>
`;
}
private _renderInteractionAlert() {
return html`
<ha-alert title="Interaction requirement">
For privacy reasons many browsers require the user to interact with a
webpage before allowing audio playback or video capture. This may affect
the
<code>media_player</code> and <code>camera</code> components of Browser
Mod. <br /><br />
If you ever see a
<ha-icon icon="mdi:gesture-tap"></ha-icon> symbol at the bottom right
corner of the screen, please tap or click anywhere on the page. This
should allow Browser Mod to work again.
</ha-alert>
`;
}
private _renderFKBSettingsInfo() {
if (!window.browser_mod?.fully || !this.getFullySettings()) return html``;
return html`
${window.browser_mod?.fully && this.getFullySettings()
? html` <ha-alert title="FullyKiosk Browser">
You are using FullyKiosk Browser. It is recommended to enable the
following settings:
<ul>
${this.getFullySettings()}
</ul>
</ha-alert>`
: ""}
`;
}
private getFullySettings() {
if (!window.browser_mod.fully) return null;
const retval = [];
const wcs = [];
// Web Content Settings
// Autoplay Videos
if (window.fully.getBooleanSetting("autoplayVideos") !== "true")
wcs.push(html`<li>Autoplay Videos</li>`);
// Autoplay Audio
if (window.fully.getBooleanSetting("autoplayAudio") !== "true")
wcs.push(html`<li>Autoplay Audio</li>`);
// Enable Webcam Access (PLUS)
if (window.fully.getBooleanSetting("webcamAccess") !== "true")
wcs.push(html`<li>Enable Webcam Access (PLUS)</li>`);
if (wcs.length !== 0) {
retval.push(html`<li>Web Content Settings</li>
<ul>
${wcs}
</ul>`);
}
// Advanced Web Settings
// Enable JavaScript Interface (PLUS)
if (window.fully.getBooleanSetting("websiteIntegration") !== "true")
retval.push(html`<li>Advanced Web Settings</li>
<ul>
<li>Enable JavaScript Interface (PLUS)</li>
</ul>`);
// Device Management
// Keep Screen On
if (window.fully.getBooleanSetting("keepScreenOn") !== "true")
retval.push(html`<li>Device Management</li>
<ul>
<li>Keep Screen On</li>
</ul>`);
// Power Settings
// Prevent from Sleep while Screen Off
if (window.fully.getBooleanSetting("preventSleepWhileScreenOff") !== "true")
retval.push(html`<li>Power Settings</li>
<ul>
<li>Prevent from Sleep while Screen Off</li>
</ul>`);
const md = [];
// Motion Detection (PLUS)
// Enable Visual Motion Detection
if (window.fully.getBooleanSetting("motionDetection") !== "true")
md.push(html`<li>Enable Visual Motion Detection</li>`);
// Turn Screen On on Motion
if (window.fully.getBooleanSetting("screenOnOnMotion") !== "true")
md.push(html`<li>Turn Screen On on Motion</li>`);
// Exit Screensaver on Motion
if (window.fully.getBooleanSetting("stopScreensaverOnMotion") !== "true")
md.push(html`<li>Exit Screensaver on Motion</li>`);
if (md.length !== 0) {
retval.push(html`<li>Motion Detection (PLUS)</li>
<ul>
${md}
</ul>`);
}
// Remote Administration (PLUS)
// Enable Remote Administration
if (window.fully.getBooleanSetting("remoteAdmin") !== "true")
retval.push(html`<li>Remote Administration (PLUS)</li>
<ul>
<li>Enable Remote Administration</li>
</ul>`);
return retval.length ? retval : null;
}
static get styles() {
return css`
.card-header {
display: flex;
justify-content: space-between;
}
ha-textfield {
width: 250px;
display: block;
margin-top: 8px;
}
`;
}
}
customElements.define(
"browser-mod-browser-settings-card",
BrowserModRegisteredBrowsersCard
);

View File

@ -1,221 +0,0 @@
import { LitElement, html, css } from "lit";
import { property, state } from "lit/decorators.js";
import { loadDeveloperToolsTemplate, selectTree } from "../helpers";
import "./browser-mod-settings-table";
loadDeveloperToolsTemplate();
class BrowserModFrontendSettingsCard extends LitElement {
@property() hass;
@state() _dashboards = [];
@state() _editSidebar = false;
_savedSidebar = { panelOrder: [], hiddenPanels: [] };
firstUpdated() {
window.browser_mod.addEventListener("browser-mod-config-update", () =>
this.requestUpdate()
);
}
updated(changedProperties) {
if (
changedProperties.has("hass") &&
changedProperties.get("hass") === undefined
) {
(async () =>
(this._dashboards = await this.hass.callWS({
type: "lovelace/dashboards/list",
})))();
}
}
async toggleEditSidebar() {
const sideBar: any = await selectTree(
document,
"home-assistant $ home-assistant-main $ app-drawer-layout app-drawer ha-sidebar"
);
sideBar.editMode = !sideBar.editMode;
this._editSidebar = sideBar.editMode;
if (this._editSidebar) {
this._savedSidebar = {
panelOrder: sideBar._panelOrder,
hiddenPanels: sideBar._hiddenPanels,
};
} else {
sideBar._panelOrder = this._savedSidebar.panelOrder ?? [];
sideBar._hiddenPanels = this._savedSidebar.hiddenPanels ?? [];
this._savedSidebar = { panelOrder: [], hiddenPanels: [] };
}
}
render() {
const db = this._dashboards.map((d) => {
return { value: d.url_path, label: d.title };
});
const dashboardSelector = {
select: {
options: [{ value: "lovelace", label: "lovelace (default)" }, ...db],
custom_value: true,
},
};
return html`
<ha-card header="Frontend Settings" outlined>
<div class="card-content">
<ha-alert alert-type="warning" title="Please note:">
The settings in this section severely change the way the Home
Assistant frontend works and looks. It is very easy to forget that
you made a setting here when you switch devices or user.
<p>
Do not report any issues to Home Assistant before clearing
<b>EVERY</b> setting here and thouroghly clearing all your browser
caches. Failure to do so means you risk wasting a lot of peoples
time, and you will be severly and rightfully ridiculed.
</p>
</ha-alert>
<p>
Settings below are applied by first match. I.e. if a matching User
setting exists, it will be applied. Otherwise any matching Browser
setting and otherwise the GLOBAL setting if that differs from
DEFAULT.
</p>
<div class="separator"></div>
<ha-settings-row>
<span slot="heading">Title template</span>
<span slot="description">
Jinja template for the browser window/tab title
</span>
</ha-settings-row>
<browser-mod-settings-table
.hass=${this.hass}
.settingKey=${"titleTemplate"}
></browser-mod-settings-table>
<div class="separator"></div>
<ha-settings-row>
<span slot="heading">Favicon template</span>
<span slot="description">
Jinja template for the browser favicon
</span>
</ha-settings-row>
<browser-mod-settings-table
.hass=${this.hass}
.settingKey=${"faviconTemplate"}
></browser-mod-settings-table>
<div class="separator"></div>
<ha-settings-row>
<span slot="heading">Hide sidebar</span>
<span slot="description">
Completely remove the sidebar from all panels
</span>
</ha-settings-row>
<browser-mod-settings-table
.hass=${this.hass}
.settingKey=${"hideSidebar"}
.settingSelector=${{ boolean: {}, label: "Hide sidebar" }}
></browser-mod-settings-table>
<div class="separator"></div>
<ha-settings-row>
<span slot="heading">Hide header</span>
<span slot="description">
Completely remove the header from all panels
</span>
</ha-settings-row>
<browser-mod-settings-table
.hass=${this.hass}
.settingKey=${"hideHeader"}
.settingSelector=${{ boolean: {}, label: "Hide header" }}
></browser-mod-settings-table>
<div class="separator"></div>
<ha-settings-row>
<span slot="heading">Default dashboard</span>
<span slot="description">
The dashboard that is showed when navigating to
${location.origin}/
</span>
</ha-settings-row>
<browser-mod-settings-table
.hass=${this.hass}
.settingKey=${"defaultPanel"}
.settingSelector=${dashboardSelector}
.default=${"lovelace"}
></browser-mod-settings-table>
<div class="separator"></div>
<ha-settings-row>
<span slot="heading">Sidebar order</span>
<span slot="description">
Order and visibility of sidebar items. <br />Click EDIT and set
the sidebar up as you want. Then save the settings and finally
click RESTORE.
</span>
<mwc-button @click=${() => this.toggleEditSidebar()}>
${this._editSidebar ? "Restore" : "Edit"}
</mwc-button>
</ha-settings-row>
<browser-mod-settings-table
.hass=${this.hass}
.settingKey=${"sidebarPanelOrder"}
.settingSelector=${{
plaintext: "Press OK to store the current sidebar order",
}}
.default=${"lovelace"}
></browser-mod-settings-table>
<div class="separator"></div>
<ha-settings-row>
<span slot="heading">Sidebar title</span>
<span slot="description">
The title at the top of the sidebar
</span>
</ha-settings-row>
<browser-mod-settings-table
.hass=${this.hass}
.settingKey=${"sidebarTitle"}
.settingSelector=${{ text: {} }}
></browser-mod-settings-table>
</div>
</ha-card>
`;
}
static get styles() {
return css`
.box {
border: 1px solid var(--divider-color);
padding: 8px;
}
.separator {
border-bottom: 1px solid var(--divider-color);
margin: 16px -16px 0px;
}
img.favicon {
width: 64px;
height: 64px;
margin-left: 16px;
}
mwc-tab-bar ha-icon {
display: flex;
align-items: center;
}
`;
}
}
customElements.define(
"browser-mod-frontend-settings-card",
BrowserModFrontendSettingsCard
);

View File

@ -1,73 +0,0 @@
import { LitElement, html, css } from "lit";
import { property } from "lit/decorators.js";
import { loadConfigDashboard } from "../helpers";
import "./browser-settings-card";
import "./registered-browsers-card";
import "./frontend-settings-card";
const bmWindow = window as any;
loadConfigDashboard().then(() => {
class BrowserModPanel extends LitElement {
@property() hass;
@property() narrow;
@property() connection;
firstUpdated() {
window.addEventListener("browser-mod-config-update", () =>
this.requestUpdate()
);
}
render() {
if (!window.browser_mod) return html``;
return html`
<ha-app-layout>
<app-header slot="header" fixed>
<app-toolbar>
<ha-menu-button
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
<div main-title>Browser Mod Settings</div>
</app-toolbar>
</app-header>
<ha-config-section .narrow=${this.narrow} full-width>
<browser-mod-browser-settings-card
.hass=${this.hass}
></browser-mod-browser-settings-card>
<browser-mod-registered-browsers-card
.hass=${this.hass}
></browser-mod-registered-browsers-card>
<browser-mod-frontend-settings-card
.hass=${this.hass}
></browser-mod-frontend-settings-card>
</ha-config-section>
</ha-app-layout>
`;
}
static get styles() {
return [
...((customElements.get("ha-config-dashboard") as any)?.styles ?? []),
css`
:host {
--app-header-background-color: var(--sidebar-background-color);
--app-header-text-color: var(--sidebar-text-color);
--app-header-border-bottom: 1px solid var(--divider-color);
--ha-card-border-radius: var(--ha-config-card-border-radius, 8px);
}
ha-config-section {
padding: 16px 0;
}
`,
];
}
}
customElements.define("browser-mod-panel", BrowserModPanel);
});

View File

@ -1,100 +0,0 @@
import { LitElement, html, css } from "lit";
import { property, state } from "lit/decorators.js";
class BrowserModRegisteredBrowsersCard extends LitElement {
@property() hass;
firstUpdated() {
window.browser_mod.addEventListener("browser-mod-config-update", () =>
this.requestUpdate()
);
}
unregister_browser(ev) {
const browserID = ev.currentTarget.browserID;
const unregisterCallback = () => {
if (browserID === window.browser_mod.browserID) {
window.browser_mod.registered = false;
} else {
window.browser_mod.connection.sendMessage({
type: "browser_mod/unregister",
browserID,
});
}
};
window.browser_mod.showPopup(
"Unregister browser",
`Are you sure you want to unregister Browser ${browserID}?`,
{
right_button: "Yes",
right_button_action: unregisterCallback,
left_button: "No",
}
);
}
register_cast() {
window.browser_mod.connection.sendMessage({
type: "browser_mod/register",
browserID: "CAST",
});
}
render() {
return html`
<ha-card header="Registered Browsers" outlined>
<div class="card-content">
${Object.keys(window.browser_mod.browsers).map((d) => {
const browser = window.browser_mod.browsers[d];
return html` <ha-settings-row>
<span slot="heading"> ${d} </span>
<span slot="description">
Last connected:
<ha-relative-time
.hass=${this.hass}
.datetime=${browser.last_seen}
></ha-relative-time>
</span>
${browser.meta && browser.meta !== "default"
? html`
<a href="config/devices/device/${browser.meta}">
<ha-icon-button>
<ha-icon .icon=${"mdi:devices"}></ha-icon>
</ha-icon-button>
</a>
`
: ""}
<ha-icon-button .browserID=${d} @click=${this.unregister_browser}>
<ha-icon .icon=${"mdi:delete"}></ha-icon>
</ha-icon-button>
</ha-settings-row>`;
})}
</div>
${window.browser_mod.browsers["CAST"] === undefined
? html`
<div class="card-actions">
<mwc-button @click=${this.register_cast}>
Register CAST Browser
</mwc-button>
</div>
`
: ""}
</ha-card>
`;
}
static get styles() {
return css`
ha-icon-button > * {
display: flex;
color: var(--primary-text-color);
}
`;
}
}
customElements.define(
"browser-mod-registered-browsers-card",
BrowserModRegisteredBrowsersCard
);

View File

@ -1,172 +0,0 @@
const TIMEOUT_ERROR = "SELECTTREE-TIMEOUT";
export async function await_element(el, hard = false) {
if (el.localName?.includes("-"))
await customElements.whenDefined(el.localName);
if (el.updateComplete) await el.updateComplete;
if (hard) {
if (el.pageRendered) await el.pageRendered;
if (el._panelState) {
let rounds = 0;
while (el._panelState !== "loaded" && rounds++ < 5)
await new Promise((r) => setTimeout(r, 100));
}
}
}
async function _selectTree(root, path, all = false) {
let el = [root];
if (typeof path === "string") {
path = path.split(/(\$| )/);
}
while (path[path.length - 1] === "") path.pop();
for (const [i, p] of path.entries()) {
const e = el[0];
if (!e) return null;
if (!p.trim().length) continue;
await_element(e);
el = p === "$" ? [e.shadowRoot] : e.querySelectorAll(p);
}
return all ? el : el[0];
}
export async function selectTree(root, path, all = false, timeout = 10000) {
return Promise.race([
_selectTree(root, path, all),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(TIMEOUT_ERROR)), timeout)
),
]).catch((err) => {
if (!err.message || err.message !== TIMEOUT_ERROR) throw err;
return null;
});
}
export async function hass_base_el() {
await Promise.race([
customElements.whenDefined("home-assistant"),
customElements.whenDefined("hc-main"),
]);
const element = customElements.get("home-assistant")
? "home-assistant"
: "hc-main";
while (!document.querySelector(element))
await new Promise((r) => window.setTimeout(r, 100));
return document.querySelector(element);
}
export async function hass() {
const base: any = await hass_base_el();
while (!base.hass) await new Promise((r) => window.setTimeout(r, 100));
return base.hass;
}
export async function provideHass(el) {
const base: any = await hass_base_el();
base.provideHass(el);
}
export const loadLoadCardHelpers = async () => {
if (window.loadCardHelpers !== undefined) return;
await customElements.whenDefined("partial-panel-resolver");
const ppResolver = document.createElement("partial-panel-resolver");
const routes = (ppResolver as any).getRoutes([
{
component_name: "lovelace",
url_path: "a",
},
]);
await routes?.routes?.a?.load?.();
};
export const loadHaForm = async () => {
if (customElements.get("ha-form")) return;
await loadLoadCardHelpers();
const helpers = await window.loadCardHelpers();
if (!helpers) return;
const card = await helpers.createCardElement({ type: "button" });
if (!card) return;
await card.constructor.getConfigElement();
};
// Loads in ha-config-dashboard which is used to copy styling
// Also provides ha-settings-row
export const loadConfigDashboard = async () => {
await customElements.whenDefined("partial-panel-resolver");
const ppResolver = document.createElement("partial-panel-resolver");
const routes = (ppResolver as any).getRoutes([
{
component_name: "config",
url_path: "a",
},
]);
await routes?.routes?.a?.load?.();
await customElements.whenDefined("ha-panel-config");
const configRouter: any = document.createElement("ha-panel-config");
await configRouter?.routerOptions?.routes?.dashboard?.load?.(); // Load ha-config-dashboard
await configRouter?.routerOptions?.routes?.cloud?.load?.(); // Load ha-settings-row
await configRouter?.routerOptions?.routes?.entities?.load?.(); // Load ha-data-table
await customElements.whenDefined("ha-config-dashboard");
};
export const loadDeveloperToolsTemplate = async () => {
await customElements.whenDefined("partial-panel-resolver");
await customElements.whenDefined("partial-panel-resolver");
const ppResolver = document.createElement("partial-panel-resolver");
const routes = (ppResolver as any).getRoutes([
{
component_name: "developer-tools",
url_path: "a",
},
]);
await routes?.routes?.a?.load?.();
const dtRouter: any = document.createElement("developer-tools-router");
await dtRouter?.routerOptions?.routes?.template?.load?.();
await customElements.whenDefined("developer-tools-template");
};
export function throttle(timeout) {
return function (target, propertyKey, descriptor) {
const fn = descriptor.value;
let cooldown = undefined;
descriptor.value = function (...rest) {
if (cooldown) return;
cooldown = setTimeout(() => (cooldown = undefined), timeout);
return fn.bind(this)(...rest);
};
};
}
export function runOnce(restart = false) {
return function (target, propertyKey, descriptor) {
const fn = descriptor.value;
let running = undefined;
const newfn = function (...rest) {
if (restart && running === false) running = true;
if (running !== undefined) return;
running = false;
const retval = fn.bind(this)(...rest);
if (running) {
running = undefined;
return newfn.bind(this)(...rest);
} else {
running = undefined;
return retval;
}
};
descriptor.value = newfn;
};
}
export async function waitRepeat(fn, times, delay) {
while (times--) {
fn();
await new Promise((r) => setTimeout(r, delay));
}
}

435
js/main.js Normal file
View File

@ -0,0 +1,435 @@
import { deviceID } from "card-tools/src/deviceId";
import { lovelace_view, provideHass, load_lovelace, lovelace, hass } from "card-tools/src/hass";
import { popUp, closePopUp } from "card-tools/src/popup";
import { fireEvent } from "card-tools/src/event";
import { moreInfo } from "card-tools/src/more-info.js";
import "./browser-player";
class BrowserMod {
set hass(hass) {
if(!hass) return;
this._hass = hass;
if(this.hassPatched) return;
const callService = hass.callService;
const newCallService = (domain, service, serviceData) => {
if(serviceData && serviceData.deviceID) {
if(Array.isArray(serviceData.deviceID)) {
const index = serviceData.deviceID.indexOf('this');
if(index !== -1)
serviceData.deviceID[index] = deviceID;
} else if(serviceData.deviceID === "this") {
serviceData.deviceID = deviceID;
}
}
return callService(domain, service, serviceData);
};
hass.callService = newCallService;
this.hassPatched = true;
if(document.querySelector("hc-main"))
document.querySelector("hc-main").hassChanged(hass,hass);
else
document.querySelector("home-assistant").hassChanged(hass, hass);
}
playOnce(ev) {
if(this._video) this._video.play();
if(window.browser_mod.playedOnce) return;
window.browser_mod.player.play();
window.browser_mod.playedOnce = true;
}
_load_lovelace() {
if(!load_lovelace()) {
let timer = window.setTimeout(this._load_lovelace.bind(this), 100);
}
}
constructor() {
this.entity_id = deviceID.replace("-","_");
this.cast = document.querySelector("hc-main") !== null;
if(!this.cast) {
window.setTimeout(this._load_lovelace.bind(this), 500);
window.hassConnection.then((conn) => this.connect(conn.conn));
document.querySelector("home-assistant").addEventListener("hass-more-info", this.popup_card.bind(this));
} else {
this.connect(hass().connection);
}
this.player = new Audio();
this.playedOnce = false;
this.autoclose_popup_active = false;
const updater = this.update.bind(this);
this.player.addEventListener("ended", updater);
this.player.addEventListener("play", updater);
this.player.addEventListener("pause", updater);
this.player.addEventListener("volumechange", updater);
document.addEventListener("visibilitychange", updater);
window.addEventListener("location-changed", updater);
window.addEventListener("click", this.playOnce);
window.addEventListener("mousemove", this.no_blackout.bind(this));
window.addEventListener("mousedown", this.no_blackout.bind(this));
window.addEventListener("keydown", this.no_blackout.bind(this));
window.addEventListener("touchstart", this.no_blackout.bind(this));
provideHass(this);
if(window.fully)
{
this._fullyMotion = false;
this._motionTimeout = undefined;
fully.bind('screenOn', 'browser_mod.update();');
fully.bind('screenOff', 'browser_mod.update();');
fully.bind('pluggedAC', 'browser_mod.update();');
fully.bind('pluggedUSB', 'browser_mod.update();');
fully.bind('onBatteryLevelChanged', 'browser_mod.update();');
fully.bind('unplugged', 'browser_mod.update();');
fully.bind('networkReconnect', 'browser_mod.update();');
fully.bind('onMotion', 'browser_mod.fullyMotion();');
}
this._screenSaver = undefined;
this._screenSaverTimer = undefined;
this._screenSaverTime = 0;
this._blackout = document.createElement("div");
this._blackout.style.cssText = `
position: fixed;
left: 0;
top: 0;
padding: 0;
margin: 0;
width: 100%;
height: 100%;
background: black;
visibility: hidden;
`;
document.body.appendChild(this._blackout);
}
connect(conn) {
this.conn = conn
conn.subscribeMessage((msg) => this.callback(msg), {
type: 'browser_mod/connect',
deviceID: deviceID,
});
}
callback(msg) {
switch (msg.command) {
case "update":
this.update(msg);
break;
case "debug":
this.debug(msg);
break;
case "play":
this.play(msg);
break;
case "pause":
this.pause(msg);
break;
case "stop":
this.stop(msg);
break;
case "set_volume":
this.set_volume(msg);
break;
case "mute":
this.mute(msg);
break;
case "toast":
this.toast(msg);
break;
case "popup":
this.popup(msg);
break;
case "close-popup":
this.close_popup(msg);
break;
case "navigate":
this.navigate(msg);
break;
case "more-info":
this.more_info(msg);
break;
case "set-theme":
this.set_theme(msg);
break;
case "lovelace-reload":
this.lovelace_reload(msg);
break;
case "blackout":
this.blackout(msg);
break;
case "no-blackout":
this.no_blackout(msg);
break;
}
}
get player_state() {
if (!this.player.src) return "stopped";
if (this.player.ended) return "stopped";
if (this.player.paused) return "paused";
return "playing";
}
popup_card(ev) {
const moreInfoEl = document.querySelector("home-assistant")._moreInfoEl;
if(moreInfoEl && !moreInfoEl.getAttribute('aria-hidden')) return;
if(!lovelace()) return;
const ll = lovelace();
const data = {
...ll.config.popup_cards,
...ll.config.views[ll.current_view].popup_cards,
};
if(!ev.detail || !ev.detail.entityId) return;
const d = data[ev.detail.entityId];
if(!d) return;
popUp(d.title, d.card, d.large || false, d.style);
}
debug(msg) {
popUp(`deviceID`, {type: "markdown", content: `# ${deviceID}`})
alert(deviceID);
}
_set_screensaver(fn, time) {
clearTimeout(this._screenSaverTimer);
if(!fn) {
if(this._screenSaverTime)
this._screenSaverTimer = setTimeout(this._screenSaver, this._screenSaverTime)
} else {
time = parseInt(time)
if(time == -1) {
clearTimeout(this._screenSaverTimer);
this._screenSaverTime = 0;
return;
}
this._screenSaverTime = time * 1000;
this._screenSaver = fn;
this._screenSaverTimer = setTimeout(this._screenSaver, this._screenSaverTime)
}
}
play(msg) {
const src = msg.media_content_id;
if(src)
this.player.src = src;
this.player.play();
}
pause(msg) {
this.player.pause();
}
stop(msg) {
this.player.pause();
this.player.src = null;
}
set_volume(msg) {
if (msg.volume_level === undefined) return;
this.player.volume = msg.volume_level;
}
mute(msg) {
if (msg.mute === undefined)
msg.mute = !this.player.muted;
this.player.muted = Boolean(msg.mute)
}
toast(msg) {
if(!msg.message) return;
fireEvent("hass-notification", {
message: msg.message,
duration: msg.duration !== undefined ? parseInt(msg.duration) : undefined
}, document.querySelector("home-assistant"));
}
popup(msg){
if(!msg.title && !msg.auto_close) return;
if(!msg.card) return;
const fn = () => {
popUp(msg.title, msg.card, msg.large, msg.style, msg.auto_close);
if(msg.auto_close)
this.autoclose_popup_active = true;
};
if(msg.auto_close && msg.time) {
this._set_screensaver(fn, msg.time);
} else {
// closePopUp();
fn();
}
}
close_popup(msg){
this._set_screensaver();
this.autoclose_popup_active = false;
closePopUp();
}
navigate(msg){
if(!msg.navigation_path) return;
history.pushState(null, "", msg.navigation_path);
fireEvent("location-changed", {}, document.querySelector("home-assistant"));
}
more_info(msg){
if(!msg.entity_id) return;
moreInfo(msg.entity_id, msg.large);
}
set_theme(msg){
if(!msg.theme) msg.theme = "default";
fireEvent("settheme", msg.theme, document.querySelector("home-assistant"));
}
lovelace_reload(msg) {
const ll = lovelace_view();
if (ll)
fireEvent("config-refresh", {}, ll);
}
blackout(msg){
const fn = () => {
if (window.fully)
{
fully.turnScreenOff();
} else {
this._blackout.style.visibility = "visible";
}
this.update();
};
if(msg.time) {
this._set_screensaver(fn, msg.time)
} else {
fn();
}
}
no_blackout(msg){
this._set_screensaver();
if(this.autoclose_popup_active)
return this.close_popup();
if (window.fully)
{
if (!fully.getScreenOn())
fully.turnScreenOn();
if (msg.brightness)
fully.setScreenBrightness(msg.brightness);
this.update();
} else {
if(this._blackout.style.visibility !== "hidden") {
this._blackout.style.visibility = "hidden";
this.update();
}
}
}
is_blackout(){
if (window.fully)
return !fully.getScreenOn();
return Boolean(this._blackout.style.visibility === "visible")
}
fullyMotion() {
this._fullyMotion = true;
clearTimeout(this._motionTimeout);
this._motionTimeout = setTimeout(() => {
this._fullyMotion = false;
this.update();
}, 5000);
this.update();
}
start_camera() {
if(this._video) return;
this._video = document.createElement("video");
this._video.autoplay = true;
this._video.playsInline = true;
this._video.style.cssText = `
visibility: hidden;
width: 0;
height: 0;
`;
this._canvas = document.createElement("canvas");
this._canvas.style.cssText = `
visibility: hidden;
width: 0;
height: 0;
`;
document.body.appendChild(this._canvas);
document.body.appendChild(this._video);
navigator.mediaDevices.getUserMedia({video: true, audio: false}).then((stream) => {
this._video.srcObject = stream;
this._video.play();
this.send_cam();
});
}
send_cam(data) {
const context = this._canvas.getContext('2d');
context.drawImage(this._video, 0, 0, this._canvas.width, this._canvas.height);
this.conn.sendMessage({
type: 'browser_mod/update',
deviceID: deviceID,
data: {
camera: this._canvas.toDataURL('image/png'),
},
});
setTimeout(this.send_cam.bind(this), 5000);
}
update(msg=null) {
if(!this.conn) return;
if(msg) {
if(msg.name) {
this.entity_id = msg.name.toLowerCase();
}
if(msg.camera) {
this.start_camera();
}
}
this.conn.sendMessage({
type: 'browser_mod/update',
deviceID: deviceID,
data: {
browser: {
path: window.location.pathname,
visibility: document.visibilityState,
userAgent: navigator.userAgent,
currentUser: this._hass && this._hass.user && this._hass.user.name,
fullyKiosk: window.fully ? true : undefined,
width: window.innerWidth,
height: window.innerHeight,
},
player: {
volume: this.player.volume,
muted: this.player.muted,
src: this.player.src,
state: this.player_state,
},
screen: {
blackout: this.is_blackout(),
brightness: window.fully ? fully.getScreenBrightness() : undefined,
},
fully: window.fully ? {
battery: window.fully ? fully.getBatteryLevel() : undefined,
charging: window.fully ? fully.isPlugged(): undefined,
motion: window.fully ? this._fullyMotion : undefined,
} : undefined,
},
});
}
}
window.browser_mod = window.browser_mod || new BrowserMod();

View File

@ -1,43 +0,0 @@
export const ActivityMixin = (SuperClass) => {
return class ActivityMixinClass extends SuperClass {
activityTriggered = false;
_activityCooldown = 15000;
_activityTimeout;
constructor() {
super();
for (const ev of ["pointerdown", "pointermove", "keydown"]) {
window.addEventListener(ev, () => this.activityTrigger(true));
}
this.addEventListener("fully-update", () => {
this.activityTrigger();
});
}
activityTrigger(touched = false) {
if (!this.activityTriggered) {
this.sendUpdate({
activity: true,
});
}
this.activityTriggered = true;
if (touched) {
this.fireEvent("browser-mod-activity");
}
clearTimeout(this._activityTimeout);
this._activityTimeout = setTimeout(
() => this.activityReset(),
this._activityCooldown
);
}
activityReset() {
clearTimeout(this._activityTimeout);
if (this.activityTriggered) {
this.sendUpdate({
activity: false,
});
}
this.activityTriggered = false;
}
};
};

View File

@ -1,25 +0,0 @@
import { LitElement, html } from "lit";
class BrowserPlayerEditor extends LitElement {
setConfig(config) {}
render() {
return html` <div>Nothing to configure.</div> `;
}
}
// (async () => {
// while (!window.browser_mod) {
// await new Promise((resolve) => setTimeout(resolve, 1000));
// }
// await window.browser_mod.connectionPromise;
if (!customElements.get("browser-player-editor")) {
customElements.define("browser-player-editor", BrowserPlayerEditor);
window.customCards = window.customCards || [];
window.customCards.push({
type: "browser-player",
name: "Browser Player",
preview: true,
});
}
// })();

View File

@ -1,179 +0,0 @@
import { LitElement, html, css } from "lit";
import { property } from "lit/decorators.js";
import "./browser-player-editor.ts";
import "./types";
class BrowserPlayer extends LitElement {
@property() hass;
@property({ attribute: "edit-mode", reflect: true }) editMode;
static getConfigElement() {
return document.createElement("browser-player-editor");
}
static getStubConfig() {
return {};
}
async connectedCallback() {
super.connectedCallback();
if (!window.browser_mod?.registered) {
if (this.parentElement.localName === "hui-card-preview") {
this.removeAttribute("hidden");
} else {
this.setAttribute("hidden", "");
}
}
}
async setConfig(config) {
while (!window.browser_mod) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
for (const event of [
"play",
"pause",
"ended",
"volumechange",
"canplay",
"loadeddata",
])
window.browser_mod?._audio_player?.addEventListener(event, () =>
this.requestUpdate()
);
window.browser_mod?._video_player?.addEventListener(event, () =>
this.requestUpdate()
);
}
handleMute(ev) {
window.browser_mod.player.muted = !window.browser_mod.player.muted;
}
handleVolumeChange(ev) {
const volume_level = parseFloat(ev.target.value);
window.browser_mod.player.volume = volume_level;
}
handleMoreInfo(ev) {
this.dispatchEvent(
new CustomEvent("hass-more-info", {
bubbles: true,
composed: true,
cancelable: false,
detail: {
entityId: window.browser_mod.browserEntities?.player,
},
})
);
}
handlePlayPause(ev) {
if (
!window.browser_mod.player.src ||
window.browser_mod.player.paused ||
window.browser_mod.player.ended
) {
window.browser_mod.player.play();
window.browser_mod._show_video_player();
} else {
window.browser_mod.player.pause();
}
}
render() {
if (!window.browser_mod) {
window.setTimeout(() => this.requestUpdate(), 100);
return html``;
}
if (!window.browser_mod?.registered) {
return html`
<ha-card>
<ha-alert> This browser is not registered to Browser Mod. </ha-alert>
</ha-card>
`;
}
return html`
<ha-card>
<div class="card-content">
<ha-icon-button @click=${this.handleMute}>
<ha-icon
.icon=${window.browser_mod.player.muted
? "mdi:volume-off"
: "mdi:volume-high"}
></ha-icon>
</ha-icon-button>
<ha-slider
min="0"
max="1"
step="0.01"
?disabled=${window.browser_mod.player.muted}
value=${window.browser_mod.player.volume}
@change=${this.handleVolumeChange}
></ha-slider>
${window.browser_mod.player_state === "stopped"
? html`<div class="placeholder"></div>`
: html`
<ha-icon-button @click=${this.handlePlayPause} highlight>
<ha-icon
.icon=${!window.browser_mod.player.src ||
window.browser_mod.player.ended ||
window.browser_mod.player.paused
? "mdi:play"
: "mdi:pause"}
></ha-icon>
</ha-icon-button>
`}
<ha-icon-button @click=${this.handleMoreInfo}>
<ha-icon .icon=${"mdi:cog"}></ha-icon>
</ha-icon-button>
</div>
<div class="browser-id">${window.browser_mod.browserID}</div>
</ha-card>
`;
}
static get styles() {
return css`
:host(["hidden"]) {
display: none;
}
:host([edit-mode="true"]) {
display: block !important;
}
paper-icon-button[highlight] {
color: var(--accent-color);
}
.card-content {
display: flex;
justify-content: center;
}
.placeholder {
width: 24px;
padding: 8px;
}
.browser-id {
opacity: 0.7;
font-size: xx-small;
margin-top: -10px;
user-select: all;
-webkit-user-select: all;
-moz-user-select: all;
-ms-user-select: all;
}
ha-icon-button ha-icon {
display: flex;
}
`;
}
}
// (async () => {
// while (!window.browser_mod) {
// await new Promise((resolve) => setTimeout(resolve, 1000));
// }
// await window.browser_mod.connectionPromise;
if (!customElements.get("browser-player"))
customElements.define("browser-player", BrowserPlayer);
// })();

View File

@ -1,49 +0,0 @@
import { hass_base_el } from "../helpers";
export const BrowserStateMixin = (SuperClass) => {
return class BrowserStateMixinClass extends SuperClass {
constructor() {
super();
document.addEventListener("visibilitychange", () =>
this._browser_state_update()
);
window.addEventListener("location-changed", () =>
this._browser_state_update()
);
this.addEventListener("fully-update", () => this._browser_state_update());
this.connectionPromise.then(() => this._browser_state_update());
}
_browser_state_update() {
const update = async () => {
const battery = (<any>navigator).getBattery?.();
this.sendUpdate({
browser: {
path: window.location.pathname,
visibility: document.visibilityState,
userAgent: navigator.userAgent,
currentUser: this.hass?.user?.name,
fullyKiosk: this.fully || false,
width: window.innerWidth,
height: window.innerHeight,
battery_level:
window.fully?.getBatteryLevel() ?? battery?.level * 100,
charging: window.fully?.isPlugged() ?? battery?.charging,
darkMode: this.hass?.themes?.darkMode,
userData: this.hass?.user,
ip_address: window.fully?.getIp4Address(),
},
});
};
update();
}
async browser_navigate(path) {
if (!path) return;
history.pushState(null, "", path);
window.dispatchEvent(new CustomEvent("location-changed"));
}
};
};

View File

@ -1,61 +0,0 @@
const ID_STORAGE_KEY = "browser_mod-browser-id";
export const BrowserIDMixin = (SuperClass) => {
return class BrowserIDMixinClass extends SuperClass {
constructor() {
super();
if (Storage) {
if (!Storage.prototype.browser_mod_patched) {
const _clear = Storage.prototype.clear;
Storage.prototype.clear = function () {
const browserId = this.getItem(ID_STORAGE_KEY);
const suspendWhenHidden = this.getItem("suspendWhenHidden");
_clear.apply(this);
this.setItem(ID_STORAGE_KEY, browserId);
this.setItem("suspendWhenHidden", suspendWhenHidden);
};
Storage.prototype.browser_mod_patched = true;
}
}
}
async recall_id() {
// If the connection is still open, but the BrowserID has disappeared - recall it from the backend
// This happens e.g. when the frontend cache is reset in the Compainon app
if (!this.connection) return;
const recalledID = await this.connection.sendMessagePromise({
type: "browser_mod/recall_id",
});
if (recalledID) {
localStorage[ID_STORAGE_KEY] = recalledID;
}
}
get browserID() {
if (document.querySelector("hc-main")) return "CAST";
if (localStorage[ID_STORAGE_KEY]) return localStorage[ID_STORAGE_KEY];
this.browserID = "";
this.recall_id();
return this.browserID;
}
set browserID(id) {
function _createBrowserID() {
const s4 = () => {
return Math.floor((1 + Math.random()) * 100000)
.toString(16)
.substring(1);
};
return window.fully?.getDeviceId() ?? `${s4()}${s4()}-${s4()}${s4()}`;
}
if (id === "") id = _createBrowserID();
const oldID = localStorage[ID_STORAGE_KEY];
localStorage[ID_STORAGE_KEY] = id;
this.browserIDChanged(oldID, id);
}
protected browserIDChanged(oldID, newID) {}
};
};

View File

@ -1,99 +0,0 @@
export const CameraMixin = (SuperClass) => {
return class CameraMixinClass extends SuperClass {
private _video;
private _canvas;
private _framerate;
public cameraError;
// TODO: Enable WebRTC?
// https://levelup.gitconnected.com/establishing-the-webrtc-connection-videochat-with-javascript-step-3-48d4ae0e9ea4
constructor() {
super();
this._framerate = 2;
this.cameraError = false;
this._setup_camera();
}
async _setup_camera() {
if (this._video) return;
await this.connectionPromise;
await this.firstInteraction;
if (!this.cameraEnabled) return;
if (this.fully) return this.update_camera();
const div = document.createElement("div");
document.body.append(div);
div.classList.add("browser-mod-camera");
div.attachShadow({ mode: "open" });
const styleEl = document.createElement("style");
div.shadowRoot.append(styleEl);
styleEl.innerHTML = `
:host {
display: none;
}`;
const video = (this._video = document.createElement("video"));
div.shadowRoot.append(video);
video.autoplay = true;
video.playsInline = true;
video.style.display = "none";
const canvas = (this._canvas = document.createElement("canvas"));
div.shadowRoot.append(canvas);
canvas.style.display = "none";
if (!navigator.mediaDevices) return;
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false,
});
video.srcObject = stream;
video.play();
this.update_camera();
} catch (e) {
if (e.name !== "NotAllowedError") throw e;
else {
this.cameraError = true;
this.fireEvent("browser-mod-config-update");
}
}
}
async update_camera() {
if (!this.cameraEnabled) {
const stream = this._video?.srcObject;
if (stream) {
stream.getTracks().forEach((t) => t.stop());
this._video.scrObject = undefined;
}
return;
}
if (this.fully) {
this.sendUpdate({
camera: this.fully_camera,
});
} else {
const video = this._video;
const width = video.videoWidth;
const height = video.videoHeight;
this._canvas.width = width;
this._canvas.height = height;
const context = this._canvas.getContext("2d");
context.drawImage(video, 0, 0, width, height);
this.sendUpdate({
camera: this._canvas.toDataURL("image/jpeg"),
});
}
const interval = Math.round(1000 / this._framerate);
setTimeout(() => this.update_camera(), interval);
}
};
};

View File

@ -1,232 +0,0 @@
import { hass, provideHass } from "../helpers";
export const ConnectionMixin = (SuperClass) => {
class BrowserModConnection extends SuperClass {
public hass;
public connection;
private _data;
public connected = false;
private _connectionResolve;
public connectionPromise = new Promise((resolve) => {
this._connectionResolve = resolve;
});
public browserEntities = {};
LOG(...args) {
if (window.browser_mod_log === undefined) return;
const dt = new Date();
console.log(`${dt.toLocaleTimeString()}`, ...args);
this.connection.sendMessage({
type: "browser_mod/log",
message: args[0],
});
}
private fireEvent(event, detail = undefined) {
this.dispatchEvent(new CustomEvent(event, { detail, bubbles: true }));
}
private incoming_message(msg) {
if (msg.command) {
this.LOG("Command:", msg);
this.fireEvent(`command-${msg.command}`, msg);
} else if (msg.browserEntities) {
this.browserEntities = msg.browserEntities;
} else if (msg.result) {
this.update_config(msg.result);
}
this._connectionResolve?.();
this._connectionResolve = undefined;
}
private update_config(cfg) {
this.LOG("Receive:", cfg);
let update = false;
if (!this.registered && cfg.browsers?.[this.browserID]) {
update = true;
}
this._data = cfg;
if (!this.connected) {
this.connected = true;
this.fireEvent("browser-mod-connected");
}
this.fireEvent("browser-mod-config-update");
if (update) this.sendUpdate({});
}
async connect() {
const conn = (await hass()).connection;
this.connection = conn;
// Subscribe to configuration updates
conn.subscribeMessage((msg) => this.incoming_message(msg), {
type: "browser_mod/connect",
browserID: this.browserID,
});
// Keep connection status up to date
conn.addEventListener("disconnected", () => {
this.connected = false;
this.fireEvent("browser-mod-disconnected");
});
conn.addEventListener("ready", () => {
this.connected = true;
this.fireEvent("browser-mod-connected");
this.sendUpdate({});
});
provideHass(this);
}
get config() {
return this._data?.config ?? {};
}
get browsers() {
return this._data?.browsers ?? [];
}
get registered() {
return this.browsers?.[this.browserID] !== undefined;
}
set registered(reg) {
(async () => {
if (reg) {
if (this.registered) return;
await this.connection.sendMessage({
type: "browser_mod/register",
browserID: this.browserID,
});
} else {
if (!this.registered) return;
await this.connection.sendMessage({
type: "browser_mod/unregister",
browserID: this.browserID,
});
}
})();
}
private async _reregister(newData = {}) {
await this.connection.sendMessage({
type: "browser_mod/register",
browserID: this.browserID,
data: {
...this.browsers[this.browserID],
...newData,
},
});
}
get global_settings() {
const settings = {};
const global = this._data?.settings ?? {};
for (const [k, v] of Object.entries(global)) {
if (v !== null) settings[k] = v;
}
return settings;
}
get user_settings() {
const settings = {};
const user = this._data?.user_settings?.[this.hass?.user?.id] ?? {};
for (const [k, v] of Object.entries(user)) {
if (v !== null) settings[k] = v;
}
return settings;
}
get browser_settings() {
const settings = {};
const browser = this.browsers?.[this.browserID]?.settings ?? {};
for (const [k, v] of Object.entries(browser)) {
if (v !== null) settings[k] = v;
}
return settings;
}
get settings() {
return {
...this.global_settings,
...this.browser_settings,
...this.user_settings,
};
}
set_setting(key, value, level) {
switch (level) {
case "global": {
this.connection.sendMessage({
type: "browser_mod/settings",
key,
value,
});
break;
}
case "user": {
const user = this.hass.user.id;
this.connection.sendMessage({
type: "browser_mod/settings",
user,
key,
value,
});
break;
}
case "browser": {
const settings = this.browsers[this.browserID]?.settings;
settings[key] = value;
this._reregister({ settings });
break;
}
}
}
get cameraEnabled() {
if (!this.registered) return null;
return this.browsers[this.browserID].camera;
}
set cameraEnabled(value) {
this._reregister({ camera: value });
}
sendUpdate(data) {
if (!this.connected || !this.registered) return;
const dt = new Date();
this.LOG("Send:", data);
this.connection.sendMessage({
type: "browser_mod/update",
browserID: this.browserID,
data,
});
}
browserIDChanged(oldID, newID) {
this.fireEvent("browser-mod-config-update");
if (
this.browsers?.[oldID] !== undefined &&
this.browsers?.[this.browserID] === undefined
) {
(async () => {
await this.connection.sendMessage({
type: "browser_mod/register",
browserID: oldID,
data: {
...this.browsers[oldID],
browserID: this.browserID,
},
});
})();
}
}
}
return BrowserModConnection;
};

View File

@ -1,224 +0,0 @@
import { await_element, waitRepeat, runOnce, selectTree } from "../helpers";
export const AutoSettingsMixin = (SuperClass) => {
class AutoSettingsMixinClass extends SuperClass {
_faviconTemplateSubscription;
_titleTemplateSubscription;
_sidebarTitleSubscription;
__currentTitle = undefined;
@runOnce()
async runHideHeader() {
while (!(await this._hideHeader()))
await new Promise((r) => setTimeout(r, 500));
}
@runOnce(true)
async runUpdateTitle() {
await waitRepeat(() => this._updateTitle(), 3, 500);
}
constructor() {
super();
const runUpdates = async () => {
this.runUpdateTitle();
this.runHideHeader();
};
this._auto_settings_setup();
this.addEventListener("browser-mod-config-update", () => {
this._auto_settings_setup();
runUpdates();
});
window.addEventListener("location-changed", runUpdates);
}
async _auto_settings_setup() {
await this.connectionPromise;
const settings = this.settings;
// Sidebar panel order and hiding
if (settings.sidebarPanelOrder) {
localStorage.setItem("sidebarPanelOrder", settings.sidebarPanelOrder);
}
if (settings.sidebarHiddenPanels) {
localStorage.setItem(
"sidebarHiddenPanels",
settings.sidebarHiddenPanels
);
}
// Default panel
if (settings.defaultPanel) {
localStorage.setItem("defaultPanel", `"${settings.defaultPanel}"`);
}
// Hide sidebar
if (settings.hideSidebar === true) {
selectTree(
document.body,
"home-assistant$home-assistant-main$app-drawer-layout"
).then((el) => el?.style?.setProperty("--app-drawer-width", "0px"));
selectTree(
document.body,
"home-assistant$home-assistant-main$app-drawer-layout app-drawer"
).then((el) => el?.remove?.());
}
// Sidebar title
if (settings.sidebarTitle) {
(async () => {
if (this._sidebarTitleSubscription) {
this._sidebarTitleSubscription();
}
this._sidebarTitleSubscription = undefined;
this._sidebarTitleSubscription =
await this.connection.subscribeMessage(this._updateSidebarTitle, {
type: "render_template",
template: settings.sidebarTitle,
variables: {},
});
})();
}
// Hide header
// Favicon template
if (settings.faviconTemplate !== undefined) {
(async () => {
if (this._faviconTemplateSubscription) {
this._faviconTemplateSubscription();
}
this._faviconTemplateSubscription = undefined;
this._faviconTemplateSubscription =
await this.connection.subscribeMessage(this._updateFavicon, {
type: "render_template",
template: settings.faviconTemplate,
variables: {},
});
})();
}
// Title template
if (settings.titleTemplate !== undefined) {
(async () => {
if (this._titleTemplateSubscription) {
this._titleTemplateSubscription();
}
this._titleTemplateSubscription = undefined;
this._titleTemplateSubscription =
await this.connection.subscribeMessage(
this._updateTitle.bind(this),
{
type: "render_template",
template: settings.titleTemplate,
variables: {},
}
);
})();
}
}
_updateSidebarTitle({ result }) {
selectTree(
document,
"home-assistant $ home-assistant-main $ app-drawer-layout app-drawer ha-sidebar $ .title"
).then((el) => {
if (el) (el as HTMLElement).innerHTML = result;
});
}
get _currentFavicon() {
const link: any = document.head.querySelector("link[rel~='icon']");
return link?.href;
}
_updateFavicon({ result }) {
const link: any = document.head.querySelector("link[rel~='icon']");
link.href = result;
}
get _currentTitle() {
return this.__currentTitle;
}
_updateTitle(data = undefined) {
if (data) this.__currentTitle = data.result;
if (this.__currentTitle) document.title = this.__currentTitle;
}
async _hideHeader() {
if (this.settings.hideHeader !== true) return true;
let el = await selectTree(
document,
"home-assistant $ home-assistant-main $ app-drawer-layout partial-panel-resolver"
);
if (!el) return false;
let steps = 0;
while (el && el.localName !== "ha-app-layout" && steps++ < 5) {
await await_element(el, true);
const next =
el.querySelector("ha-app-layout") ??
el.firstElementChild ??
el.shadowRoot;
el = next;
}
if (el?.localName !== "ha-app-layout") return false;
if (el.header) {
el.header.style.setProperty("display", "none");
setTimeout(() => el._updateLayoutStates(), 0);
return true;
}
return false;
}
getSetting(key) {
const retval = { global: undefined, browser: {}, user: {} };
retval.global = this._data.settings?.[key];
for (const [k, v] of Object.entries(this._data.browsers ?? {})) {
if ((v as any).settings?.[key] != null)
retval.browser[k] = (v as any).settings[key];
}
for (const [k, v] of Object.entries(this._data.user_settings ?? {})) {
if (v[key] != null) retval.user[k] = v[key];
}
return retval;
}
setSetting(type, target, settings) {
if (type === "global") {
for (const [key, value] of Object.entries(settings))
this.connection.sendMessage({
type: "browser_mod/settings",
key,
value,
});
} else if (type === "browser") {
const browser = this._data.browsers[target];
const newsettings = { ...browser.settings, ...settings };
console.log(newsettings);
this.connection.sendMessage({
type: "browser_mod/register",
browserID: target,
data: {
...browser,
settings: newsettings,
},
});
} else if (type === "user") {
const user = target;
for (const [key, value] of Object.entries(settings))
this.connection.sendMessage({
type: "browser_mod/settings",
user,
key,
value,
});
}
}
}
return AutoSettingsMixinClass;
};

View File

@ -1,68 +0,0 @@
export const FullyMixin = (C) => {
return class FullyMixinClass extends C {
private _fully_screensaver = false;
get fully() {
return window.fully !== undefined;
}
constructor() {
super();
if (!this.fully) return;
for (const ev of [
"screenOn",
"screenOff",
"pluggedAC",
"pluggedUSB",
"onBatteryLevelChanged",
"unplugged",
"networkReconnect",
"onMotion",
"onDaydreamStart",
"onDaydreamStop",
]) {
window.fully.bind(ev, `window.browser_mod.fullyEvent("${ev}");`);
}
window.fully.bind(
"onScreensaverStart",
`window.browser_mod._fully_screensaver = true; window.browser_mod.fullyEvent();`
);
window.fully.bind(
"onScreensaverStop",
`window.browser_mod._fully_screensaver = false; window.browser_mod.fullyEvent();`
);
return;
}
get fully_screen() {
return this._fully_screensaver === false && window.fully?.getScreenOn();
}
set fully_screen(state) {
if (state) {
window.fully?.turnScreenOn();
window.fully?.stopScreensaver();
} else {
window.fully?.turnScreenOff();
}
}
get fully_brightness() {
return window.fully?.getScreenBrightness();
}
set fully_brightness(br) {
window.fully?.setScreenBrightness(br);
}
get fully_camera() {
return window.fully?.getCamshotJpgBase64();
}
fullyEvent(event = undefined) {
this.fireEvent("fully-update", { event });
}
};
};

View File

@ -1,112 +0,0 @@
import "./browser-player";
import { ConnectionMixin } from "./connection";
import { ScreenSaverMixin } from "./screensaver";
import { MediaPlayerMixin } from "./mediaPlayer";
import { CameraMixin } from "./camera";
import { RequireInteractMixin } from "./require-interact";
import { FullyMixin } from "./fullyKiosk";
import { BrowserStateMixin } from "./browser";
import { ServicesMixin } from "./services";
import { ActivityMixin } from "./activity";
import "./popups";
import { PopupMixin } from "./popups";
import pjson from "../../package.json";
import "./popup-card";
import { AutoSettingsMixin } from "./frontend-settings";
import { BrowserIDMixin } from "./browserID";
/*
TODO:
- More pictures for documentation
x Fix nomenclature
x Command -> Service
x Device -> Browser
- Popups
X Basic popups
x Styling
X Timeout
X Fullscreen
x Popup-card
x Auto-close
x Forms that are forwarded to service calls
x Motion/occupancy tracker
x Information about interaction requirement
x Information about fullykiosk
- Commands
x Change targets from the frontend
x Send browser ID to the backend in service calls?
x Rename browser_mod commands to browser_mod services
x Framework
x ll-custom handling
- Commands
x popup
x close_popup
x more-info
x navigate
o lovelace-reload?
o Not needed
x window-reload
o screensaver ?
o Refer to automations instead
x sequence
x delay
x javascript eval
o toast?
o Replaced with popups with timeout
x Redesign services to target devices
x frontend editor for popup cards
o also screensavers
x Saved frontend settings
X Framework
x Save sidebar
x Kiosk mode
x Default dashboard
o Screensaver?
x Favicon templates
x Title templates
- Tweaks
- Quickbar tweaks (ctrl+enter)?
x Card-mod preload
x Video player
x Media_seek
o Screensavers
x IMPORTANT: FIX DEFAULT HIDING OF ENTITIES
o NOFIX. Home Assistant bug
X Check functionality with CAST - may need to add frontend part as a lovelace resource
*/
export class BrowserMod extends ServicesMixin(
PopupMixin(
ActivityMixin(
BrowserStateMixin(
CameraMixin(
MediaPlayerMixin(
ScreenSaverMixin(
AutoSettingsMixin(
FullyMixin(
RequireInteractMixin(
ConnectionMixin(BrowserIDMixin(EventTarget))
)
)
)
)
)
)
)
)
)
) {
constructor() {
super();
this.connect();
console.info(
`%cBROWSER_MOD ${pjson.version} IS INSTALLED
%cBrowserID: ${this.browserID}`,
"color: green; font-weight: bold",
""
);
}
}
if (!window.browser_mod) window.browser_mod = new BrowserMod();

View File

@ -1,130 +0,0 @@
import { selectTree, throttle } from "../helpers";
export const MediaPlayerMixin = (SuperClass) => {
class MediaPlayerMixinClass extends SuperClass {
public player;
private _audio_player;
private _video_player;
private _player_enabled;
constructor() {
super();
this._audio_player = new Audio();
this._video_player = document.createElement("video");
this._video_player.controls = true;
this._video_player.style.setProperty("width", "100%");
this.player = this._audio_player;
this._player_enabled = false;
for (const ev of ["play", "pause", "ended", "volumechange"]) {
this._audio_player.addEventListener(ev, () => this._player_update());
this._video_player.addEventListener(ev, () => this._player_update());
}
for (const ev of ["timeupdate"]) {
this._audio_player.addEventListener(ev, () =>
this._player_update_throttled()
);
this._video_player.addEventListener(ev, () =>
this._player_update_throttled()
);
}
this.firstInteraction.then(() => {
this._player_enabled = true;
if (!this.player.ended) this.player.play();
});
this.addEventListener("command-player-play", (ev) => {
if (this.player.src) this.player.pause();
if (ev.detail?.media_type)
if (ev.detail?.media_type.startsWith("video"))
this.player = this._video_player;
else this.player = this._audio_player;
if (ev.detail?.media_content_id)
this.player.src = ev.detail.media_content_id;
this.player.play();
this._show_video_player();
});
this.addEventListener("command-player-pause", (ev) =>
this.player.pause()
);
this.addEventListener("command-player-stop", (ev) => {
this.player.src = null;
this.player.pause();
});
this.addEventListener("command-player-set-volume", (ev) => {
if (ev.detail?.volume_level === undefined) return;
this.player.volume = ev.detail.volume_level;
});
this.addEventListener("command-player-mute", (ev) => {
if (ev.detail?.mute !== undefined)
this.player.muted = Boolean(ev.detail.mute);
else this.player.muted = !this.player.muted;
});
this.addEventListener("command-player-seek", (ev) => {
this.player.currentTime = ev.detail.position;
setTimeout(() => this._player_update(), 10);
});
this.addEventListener("command-player-turn-off", (ev) => {
if (
this.player === this._video_player &&
this._video_player.isConnected
)
this.closePopup();
else if (this.player.src) this.player.pause();
this.player.src = "";
this._player_update();
});
this.connectionPromise.then(() => this._player_update());
}
private _show_video_player() {
if (this.player === this._video_player && this.player.src) {
selectTree(
document,
"home-assistant $ dialog-media-player-browse"
).then((el) => el?.closeDialog());
this.showPopup(undefined, this._video_player, {
dismiss_action: () => this._video_player.pause(),
size: "wide",
});
} else if (
this.player !== this._video_player &&
this._video_player.isConnected
) {
this.closePopup();
}
}
@throttle(3000)
_player_update_throttled() {
this._player_update();
}
private _player_update() {
const state = this._player_enabled
? !this.player.src || this.player.src === window.location.href
? "off"
: this.player.ended
? "stopped"
: this.player.paused
? "paused"
: "playing"
: "unavailable";
this.sendUpdate({
player: {
volume: this.player.volume,
muted: this.player.muted,
src: this.player.src,
state,
media_duration: this.player.duration,
media_position: this.player.currentTime,
},
});
}
}
return MediaPlayerMixinClass;
};

View File

@ -1,270 +0,0 @@
import { LitElement, html, css } from "lit";
import { property, query, state } from "lit/decorators.js";
import { loadHaForm } from "../helpers";
const configSchema = [
{
name: "entity",
label: "Entity",
selector: { entity: {} },
},
{
name: "title",
label: "Title",
selector: { text: {} },
},
{
name: "size",
selector: {
select: { mode: "dropdown", options: ["normal", "wide", "fullscreen"] },
},
},
{
type: "grid",
schema: [
{
name: "right_button",
label: "Right button",
selector: { text: {} },
},
{
name: "left_button",
label: "Left button",
selector: { text: {} },
},
],
},
{
type: "grid",
schema: [
{
name: "right_button_action",
label: "Right button action",
selector: { object: {} },
},
{
name: "left_button_action",
label: "Left button action",
selector: { object: {} },
},
],
},
{
type: "grid",
schema: [
{
name: "dismissable",
label: "User dismissable",
selector: { boolean: {} },
},
{
name: "timeout",
label: "Auto close timeout (ms)",
selector: { number: { mode: "box" } },
},
],
},
{
type: "grid",
schema: [
{
name: "dismiss_action",
label: "Dismiss action",
selector: { object: {} },
},
{
name: "timeout_action",
label: "Timeout action",
selector: { object: {} },
},
],
},
{
name: "style",
label: "CSS style",
selector: { text: { multiline: true } },
},
];
class PopupCardEditor extends LitElement {
@state() _config;
@property() lovelace;
@property() hass;
@state() _selectedTab = 0;
@state() _cardGUIMode = true;
@state() _cardGUIModeAvailable = true;
@query("hui-card-element-editor") private _cardEditorEl?;
setConfig(config) {
this._config = config;
}
connectedCallback() {
super.connectedCallback();
loadHaForm();
}
_handleSwitchTab(ev: CustomEvent) {
this._selectedTab = parseInt(ev.detail.index, 10);
}
_configChanged(ev: CustomEvent) {
ev.stopPropagation();
if (!this._config) return;
this._config = { ...ev.detail.value };
this.dispatchEvent(
new CustomEvent("config-changed", { detail: { config: this._config } })
);
}
_cardConfigChanged(ev: CustomEvent) {
ev.stopPropagation();
if (!this._config) return;
const card = { ...ev.detail.config };
this._config = { ...this._config, card };
this._cardGUIModeAvailable = ev.detail.guiModeAvailable;
this.dispatchEvent(
new CustomEvent("config-changed", { detail: { config: this._config } })
);
}
_toggleCardMode(ev) {
this._cardEditorEl?.toggleMode();
}
_deleteCard(ev) {
if (!this._config) return;
this._config = { ...this._config };
delete this._config.card;
this.dispatchEvent(
new CustomEvent("config-changed", { detail: { config: this._config } })
);
}
_cardGUIModeChanged(ev: CustomEvent) {
ev.stopPropagation();
this._cardGUIMode = ev.detail.guiMode;
this._cardGUIModeAvailable = ev.detail.guiModeAvailable;
}
render() {
if (!this.hass || !this._config) {
return html``;
}
return html`
<div class="card-config">
<div class="toolbar">
<mwc-tab-bar
.activeIndex=${this._selectedTab}
@MDCTabBar:activated=${this._handleSwitchTab}
>
<mwc-tab .label=${"Settings"}></mwc-tab>
<mwc-tab .label=${"Card"}></mwc-tab>
</mwc-tab-bar>
</div>
<div id="editor">
${[this._renderSettingsEditor, this._renderCardEditor][
this._selectedTab
].bind(this)()}
</div>
</div>
`;
}
_renderSettingsEditor() {
return html`<div class="box">
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${configSchema}
.computeLabel=${(s) => s.label ?? s.name}
@value-changed=${this._configChanged}
></ha-form>
</div>`;
}
_renderCardEditor() {
return html`
<div class="box cards">
${this._config.card
? html`
<div class="toolbar">
<mwc-button
@click=${this._toggleCardMode}
.disabled=${!this._cardGUIModeAvailable}
class="gui-mode-button"
>
${!this._cardEditorEl || this._cardGUIMode
? "Show code editor"
: "Show visual editor"}
</mwc-button>
<mwc-button
.title=${"Change card type"}
@click=${this._deleteCard}
>
Change card type
</mwc-button>
</div>
<hui-card-element-editor
.hass=${this.hass}
.lovelace=${this.lovelace}
.value=${this._config.card}
@config-changed=${this._cardConfigChanged}
@GUImode-changed=${this._cardGUIModeChanged}
></hui-card-element-editor>
`
: html`
<hui-card-picker
.hass=${this.hass}
.lovelace=${this.lovelace}
@config-changed=${this._cardConfigChanged}
></hui-card-picker>
`}
</div>
`;
}
static get styles() {
return css`
mwc-tab-bar {
border-bottom: 1px solid var(--divider-color);
}
.box {
margin-top: 8px;
border: 1px solid var(--divider-color);
padding: 12px;
}
.box .toolbar {
display: flex;
justify-content: flex-end;
width: 100%;
gap: 8px;
}
.gui-mode-button {
margin-right: auto;
}
`;
}
}
(async () => {
while (!window.browser_mod) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
await window.browser_mod.connectionPromise;
if (!customElements.get("popup-card-editor")) {
customElements.define("popup-card-editor", PopupCardEditor);
(window as any).customCards = (window as any).customCards || [];
(window as any).customCards.push({
type: "popup-card",
name: "Popup card",
preview: false,
description:
"Replace the more-info dialog for a given entity in the view that includes this card. (Browser Mod)",
});
}
})();

View File

@ -1,200 +0,0 @@
import { LitElement, html, css } from "lit";
import { property, state } from "lit/decorators.js";
import "./popup-card-editor";
class PopupCard extends LitElement {
@property() hass;
@state() _config;
@property({ attribute: "edit-mode", reflect: true }) editMode;
@state() _element;
static getConfigElement() {
return document.createElement("popup-card-editor");
}
static getStubConfig(hass, entities) {
const entity = entities[0];
return {
entity,
title: "Custom popup",
dismissable: true,
card: { type: "markdown", content: "This replaces the more-info dialog" },
};
}
constructor() {
super();
this.popup = this.popup.bind(this);
}
setConfig(config) {
this._config = config;
(async () => {
const ch = await window.loadCardHelpers();
this._element = await ch.createCardElement(config.card);
this._element.hass = this.hass;
})();
}
async connectedCallback() {
super.connectedCallback();
window.addEventListener("hass-more-info", this.popup);
if (this.parentElement.localName === "hui-card-preview") {
this.editMode = true;
this.removeAttribute("hidden");
} else {
this.setAttribute("hidden", "");
}
}
async disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("hass-more-info", this.popup);
}
popup(ev: CustomEvent) {
if (
ev.detail?.entityId === this._config.entity &&
!ev.detail?.ignore_popup_card
) {
ev.stopPropagation();
ev.preventDefault();
const config = { ...this._config };
delete config.card;
window.browser_mod?.service("popup", {
content: this._config.card,
...this._config,
});
setTimeout(
() =>
this.dispatchEvent(
new CustomEvent("hass-more-info", {
bubbles: true,
composed: true,
cancelable: false,
detail: { entityId: "." },
})
),
50
);
}
}
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has("hass")) {
if (this._element) this._element.hass = this.hass;
}
}
render() {
if (!this.editMode) return html``;
return html` <ha-card>
<div class="app-toolbar">
${this._config.dismissable
? html`
<ha-icon-button>
<ha-icon .icon=${"mdi:close"}></ha-icon>
</ha-icon-button>
`
: ""}
<div class="main-title">${this._config.title}</div>
</div>
${this._element}
<style>
:host {
${this._config.style}
}
</style>
${this._config.right_button !== undefined ||
this._config.left_button !== undefined
? html`
<footer class="mdc-dialog__actions">
<span>
${this._config.left_button !== undefined
? html`
<mwc-button
.label=${this._config.left_button}
></mwc-button>
`
: ""}
</span>
<span>
${this._config.right_button !== undefined
? html`
<mwc-button
.label=${this._config.right_button}
></mwc-button>
`
: ""}
</span>
</footer>
`
: ""}
</ha-card>`;
}
static get styles() {
return css`
:host {
display: none !important;
}
:host([edit-mode="true"]) {
display: block !important;
border: 1px solid var(--primary-color);
}
ha-card {
background-color: var(
--popup-background-color,
var(--ha-card-background, var(--card-background-color, white))
);
}
.app-toolbar {
color: var(--primary-text-color);
background-color: var(
--popup-header-background-color,
var(--popup-background-color, --sidebar-background-color)
);
display: var(--layout-horizontal_-_display);
flex-direction: var(--layout-horizontal_-_flex-direction);
align-items: var(--layout-center_-_align-items);
height: 64px;
padding: 0 16px;
font-size: var(--app-toolbar-font-size, 20px);
}
ha-icon-button > * {
display: flex;
}
.main-title {
margin-left: 16px;
line-height: 1.3em;
max-height: 2.6em;
overflow: hidden;
text-overflow: ellipsis;
}
.mdc-dialog__actions {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 52px;
margin: 0px;
padding: 8px;
border-top: 1px solid transparent;
}
`;
}
}
(async () => {
while (!window.browser_mod) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
await window.browser_mod.connectionPromise;
if (!customElements.get("popup-card"))
customElements.define("popup-card", PopupCard);
})();

View File

@ -1,413 +0,0 @@
import { LitElement, html, css } from "lit";
import { property, query } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { provideHass, loadLoadCardHelpers, hass_base_el } from "../helpers";
import { loadHaForm } from "../helpers";
class BrowserModPopup extends LitElement {
@property() open;
@property() content;
@property() title;
@property({ reflect: true }) actions;
@property({ reflect: true }) card;
@property() right_button;
@property() left_button;
@property() dismissable;
@property({ reflect: true }) wide;
@property({ reflect: true }) fullscreen;
@property() _style;
@query("ha-dialog") dialog: any;
_autoclose;
_autocloseListener;
_actions;
timeout;
_timeoutStart;
_timeoutTimer;
_resolveClosed;
_formdata;
async closeDialog() {
this.open = false;
this.card = undefined;
clearInterval(this._timeoutTimer);
if (this._autocloseListener) {
window.browser_mod.removeEventListener(
"browser-mod-activity",
this._autocloseListener
);
this._autocloseListener = undefined;
}
}
openDialog() {
this.open = true;
this.dialog?.show();
if (this.timeout) {
this._timeoutStart = new Date().getTime();
this._timeoutTimer = setInterval(() => {
const ellapsed = new Date().getTime() - this._timeoutStart;
const progress = (ellapsed / this.timeout) * 100;
this.style.setProperty("--progress", `${progress}%`);
if (ellapsed >= this.timeout) this._timeout();
}, 10);
}
this._autocloseListener = undefined;
if (this._autoclose) {
this._autocloseListener = () => this.dialog.close();
window.browser_mod.addEventListener(
"browser-mod-activity",
this._autocloseListener,
{ once: true }
);
}
}
async setupDialog(
title,
content,
{
right_button = undefined,
right_button_action = undefined,
left_button = undefined,
left_button_action = undefined,
dismissable = true,
dismiss_action = undefined,
timeout = undefined,
timeout_action = undefined,
size = undefined,
style = undefined,
autoclose = false,
} = {}
) {
this._formdata = undefined;
this.title = title;
this.card = undefined;
if (content && content instanceof HTMLElement) {
this.content = content;
} else if (content && Array.isArray(content)) {
loadHaForm();
const form: any = document.createElement("ha-form");
form.schema = content;
form.computeLabel = (s) => s.label ?? s.name;
form.hass = window.browser_mod.hass;
this._formdata = {};
for (const i of content) {
if (i.name && i.default !== undefined) {
this._formdata[i.name] = i.default;
}
}
form.data = this._formdata;
provideHass(form);
form.addEventListener("value-changed", (ev) => {
this._formdata = { ...ev.detail.value };
form.data = this._formdata;
});
this.content = form;
} else if (content && typeof content === "object") {
// Create a card from config in content
this.card = true;
const helpers = await window.loadCardHelpers();
const card = await helpers.createCardElement(content);
card.hass = window.browser_mod.hass;
provideHass(card);
this.content = card;
} else {
// Basic HTML content
this.content = unsafeHTML(content);
}
this.right_button = right_button;
this.left_button = left_button;
this.actions = right_button === undefined ? undefined : "";
this.dismissable = dismissable;
this.timeout = timeout;
this._actions = {
right_button_action,
left_button_action,
dismiss_action,
timeout_action,
};
this.wide = size === "wide" ? "" : undefined;
this.fullscreen = size === "fullscreen" ? "" : undefined;
this._style = style;
this._autoclose = autoclose;
}
async _primary() {
if (this._actions?.dismiss_action) this._actions.dismiss_action = undefined;
this.dialog?.close();
this._actions?.right_button_action?.(this._formdata);
}
async _secondary() {
if (this._actions?.dismiss_action) this._actions.dismiss_action = undefined;
this.dialog?.close();
this._actions?.left_button_action?.(this._formdata);
}
async _dismiss(ev?) {
this.dialog?.close();
this._actions?.dismiss_action?.();
}
async _timeout() {
if (this._actions?.dismiss_action) this._actions.dismiss_action = undefined;
this.dialog?.close();
this._actions?.timeout_action?.();
}
render() {
if (!this.open) return html``;
return html`
<ha-dialog
open
@closed=${this.closeDialog}
@closing=${this._dismiss}
.heading=${this.title !== undefined}
?hideActions=${this.actions === undefined}
.scrimClickAction=${this.dismissable ? "close" : ""}
.escapeKeyAction=${this.dismissable ? "close" : ""}
>
${this.timeout
? html` <div slot="heading" class="progress"></div> `
: ""}
${this.title
? html`
<div slot="heading">
<ha-header-bar>
${this.dismissable
? html`
<ha-icon-button
dialogAction="cancel"
slot="navigationIcon"
>
<ha-icon .icon=${"mdi:close"}></ha-icon>
</ha-icon-button>
`
: ""}
<div slot="title" class="main-title">${this.title}</div>
</ha-header-bar>
</div>
`
: html``}
<div class="content">${this.content}</div>
${this.right_button !== undefined
? html`
<mwc-button
slot="primaryAction"
.label=${this.right_button}
@click=${this._primary}
class="action-button"
></mwc-button>
`
: ""}
${this.left_button !== undefined
? html`
<mwc-button
slot="secondaryAction"
.label=${this.left_button}
@click=${this._secondary}
class="action-button"
></mwc-button>
`
: ""}
<style>
:host {
${this._style}
}
</style>
</ha-dialog>
`;
}
static get styles() {
return css`
ha-dialog {
z-index: 10;
--mdc-dialog-min-width: var(--popup-min-width, 400px);
--mdc-dialog-max-width: var(--popup-max-width, 600px);
--mdc-dialog-heading-ink-color: var(--primary-text-color);
--mdc-dialog-content-ink-color: var(--primary-text-color);
--justify-action-buttons: space-between;
--dialog-box-shadow: 0px 0px 0px
var(--popup-border-width, var(--ha-card-border-width, 2px))
var(
--popup-border-color,
var(--ha-card-border-color, var(--divider-color, #e0e0e0))
);
--mdc-theme-surface: var(
--popup-background-color,
var(--ha-card-background, var(--card-background-color, white))
);
}
:host([wide]) ha-dialog {
--mdc-dialog-max-width: 90vw;
}
:host([fullscreen]) ha-dialog {
--mdc-dialog-min-width: 100vw;
--mdc-dialog-max-width: 100vw;
--mdc-dialog-min-height: 100%;
--mdc-dialog-max-height: 100%;
--mdc-shape-medium: 0px;
--vertial-align-dialog: flex-end;
--ha-dialog-border-radius: 0px;
}
.progress::before {
content: "";
position: absolute;
left: 0;
width: calc(100% - var(--progress, 60%));
top: 0;
height: 2px;
background: var(--primary-color);
z-index: 10;
}
ha-header-bar {
--mdc-theme-on-primary: var(--primary-text-color);
--mdc-theme-primary: var(--mdc-theme-surface);
flex-shrink: 0;
display: block;
}
ha-icon-button > * {
display: flex;
}
.main-title {
overflow: hidden;
text-overflow: ellipsis;
cursor: default;
}
.content {
--padding-x: 24px;
--padding-y: 20px;
margin: -20px -24px;
padding: var(--padding-y) var(--padding-y);
--header-height: 64px;
--footer-height: 0px;
}
.content:first-child {
--header-height: 0px;
}
:host([card]) .content {
--padding-x: 0px;
--padding-y: 0px;
--ha-card-box-shadow: none;
}
:host([actions]) .content {
xborder-bottom: 2px solid
var(--popup-border-color, var(--divider-color));
--footer-height: 54px;
}
:host([wide]) .content {
width: calc(90vw - 2 * var(--padding-x));
}
:host([fullscreen]) .content {
height: calc(
100vh - var(--header-height) - var(--footer-height) - 2 *
var(--padding-y) - 16px
);
}
.action-button {
margin-bottom: -24px;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-dialog {
--mdc-dialog-min-width: 100vw;
--mdc-dialog-max-width: 100vw;
--mdc-shape-medium: 0px;
}
}
`;
}
}
if (!customElements.get("browser-mod-popup"))
customElements.define("browser-mod-popup", BrowserModPopup);
export const PopupMixin = (SuperClass) => {
return class PopupMixinClass extends SuperClass {
private _popupEl: any;
constructor() {
super();
loadLoadCardHelpers();
this._popupEl = document.createElement("browser-mod-popup");
document.body.append(this._popupEl);
this._popupEl.addEventListener("hass-more-info", async (ev) => {
const base = await hass_base_el();
console.log("More info", ev, base);
this._popupEl.closeDialog();
base.dispatchEvent(ev);
});
// const historyListener = async (ev) => {
// const popupState = ev.state?.browserModPopup;
// if (popupState) {
// if (popupState.open) {
// this._popupEl.setupDialog(...popupState.args);
// this._popupEl.openDialog();
// } else {
// this._popupEl.closeDialog();
// }
// }
// };
// window.addEventListener("popstate", historyListener);
}
showPopup(...args) {
// if (history.state?.browserModPopup === undefined) {
// history.replaceState(
// {
// browserModPopup: {
// open: false,
// },
// },
// ""
// );
// }
// history.pushState(
// {
// browserModPopup: {
// open: true,
// args,
// },
// },
// ""
// );
this._popupEl.setupDialog(...args).then(() => this._popupEl.openDialog());
}
closePopup(...args) {
this._popupEl.closeDialog();
this.showMoreInfo("");
}
async showMoreInfo(entityId, large = false, ignore_popup_card = undefined) {
const base = await hass_base_el();
base.dispatchEvent(
new CustomEvent("hass-more-info", {
bubbles: true,
composed: true,
cancelable: false,
detail: { entityId, ignore_popup_card },
})
);
if (large) {
await new Promise((resolve) => setTimeout(resolve, 50));
const dialog: any = base.shadowRoot.querySelector(
"ha-more-info-dialog"
);
if (dialog) dialog.large = true;
}
}
};
};

View File

@ -1,84 +0,0 @@
export const RequireInteractMixin = (SuperClass) => {
return class RequireInteractMixinClass extends SuperClass {
private _interactionResolve;
public firstInteraction = new Promise((resolve) => {
this._interactionResolve = resolve;
});
constructor() {
super();
this.show_indicator();
}
async show_indicator() {
await this.connectionPromise;
if (!this.registered) return;
const interactSymbol = document.createElement("div");
document.body.append(interactSymbol);
interactSymbol.classList.add("browser-mod-require-interaction");
interactSymbol.attachShadow({ mode: "open" });
const styleEl = document.createElement("style");
interactSymbol.shadowRoot.append(styleEl);
styleEl.innerHTML = `
:host {
position: fixed;
right: 8px;
bottom: 8px;
color: var(--warning-color, red);
opacity: 0.5;
--mdc-icon-size: 48px;
}
ha-icon::before {
content: "Browser\\00a0Mod";
font-size: 0.75rem;
position: absolute;
right: 0;
bottom: 90%;
}
video {
display: none;
}
`;
const icon = document.createElement("ha-icon");
interactSymbol.shadowRoot.append(icon);
(icon as any).icon = "mdi:gesture-tap";
// If we are allowed to play a video, we can assume no interaction is needed
const video = (this._video = document.createElement("video"));
interactSymbol.shadowRoot.append(video);
const vPlay = video.play();
if (vPlay) {
vPlay
.then(() => {
this._interactionResolve();
video.pause();
})
.catch((e) => {
if (e.name === "AbortError") {
this._interactionResolve();
}
});
video.pause();
}
window.addEventListener(
"pointerdown",
() => {
this._interactionResolve();
},
{ once: true }
);
// if (this.fully) this._interactionResolve();
await this.firstInteraction;
interactSymbol.remove();
}
};
};

View File

@ -1,97 +0,0 @@
export const ScreenSaverMixin = (SuperClass) => {
class ScreenSaverMixinClass extends SuperClass {
private _panel;
private _listeners = {};
private _brightness = 255;
constructor() {
super();
const panel = (this._panel = document.createElement("div"));
document.body.append(panel);
panel.classList.add("browser-mod-blackout");
panel.attachShadow({ mode: "open" });
const styleEl = document.createElement("style");
panel.shadowRoot.append(styleEl);
styleEl.innerHTML = `
:host {
background: rgba(0,0,0, var(--darkness));
position: fixed;
left: 0;
top: 0;
bottom: 0;
right: 0;
width: 100%;
height: 100%;
z-index: 10000;
display: block;
pointer-events: none;
}
:host([dark]) {
background: rgba(0,0,0,1);
}
`;
this.addEventListener("command-screen_off", () => this._screen_off());
this.addEventListener("command-screen_on", (ev) => this._screen_on(ev));
this.addEventListener("fully-update", () => this.send_screen_status());
this.connectionPromise.then(() => this._screen_on());
}
send_screen_status() {
let screen_on = !this._panel.hasAttribute("dark");
let screen_brightness = this._brightness;
if (this.fully) {
screen_on = this.fully_screen;
screen_brightness = this.fully_brightness;
}
this.sendUpdate({ screen_on, screen_brightness });
}
private _screen_off() {
if (this.fully) {
this.fully_screen = false;
} else {
this._panel.setAttribute("dark", "");
}
this.send_screen_status();
const l = () => this._screen_on();
for (const ev of ["pointerdown", "pointermove", "keydown"]) {
this._listeners[ev] = l;
window.addEventListener(ev, l);
}
}
private _screen_on(ev = undefined) {
if (this.fully) {
this.fully_screen = true;
if (ev?.detail?.brightness) {
this.fully_brightness = ev.detail.brightness;
}
} else {
if (ev?.detail?.brightness) {
this._brightness = ev.detail.brightness;
this._panel.style.setProperty(
"--darkness",
1 - ev.detail.brightness / 255
);
}
this._panel.removeAttribute("dark");
}
this.send_screen_status();
for (const ev of ["pointerdown", "pointermove", "keydown"]) {
if (this._listeners[ev]) {
window.removeEventListener(ev, this._listeners[ev]);
this._listeners[ev] = undefined;
}
}
}
}
return ScreenSaverMixinClass;
};

View File

@ -1,175 +0,0 @@
export const ServicesMixin = (SuperClass) => {
return class ServicesMixinClass extends SuperClass {
/*
Structure of service call:
service: <service>
[data: <data>]
Sequence:
service: browser_mod.sequence
data:
sequence:
- <service call>
- <service call>
- ...
Delay
service: browser_mod.delay
data:
time: <number>
Popup:
service: browser_mod.popup
data:
[title: <string>]
[content: <string | Lovelace Card Configuration>]
[size: <NORMAL/wide/fullscreen>]
[right_button: <string>]
[right_button_action: <service call>]
[left_button: <string>]
[left_button_action: <service call>]
[dismissable: <TRUE/false>]
[dismiss_action: <service call>]
[autoclose: <true/FALSE>]
[timeout: <number>]
[timeout_action: <service call>]
[style: <string>]
More-info:
service: browser_mod.more_info
data:
entity: <string>
[large: <true/FALSE>]
[ignore_popup_card: <true/FALSE>]
Close popup:
service: browser_mod.close_popup
Navigate to path:
service: browser_mod.navigate
data:
path: <string>
Refresh browser:
service: browser_mod.refresh
Browser console print:
service: browser_mod.console
data:
message: <string>
Run javascript:
service: browser_mod.javascript
data:
code: <string>
*/
constructor() {
super();
const cmds = [
"sequence",
"delay",
"popup",
"more_info",
"close_popup",
"navigate",
"refresh",
"console",
"javascript",
];
for (const service of cmds) {
this.addEventListener(`command-${service}`, (ev) => {
this.service(service, ev.detail);
});
}
document.body.addEventListener("ll-custom", (ev: CustomEvent) => {
if (ev.detail.browser_mod) {
this._service_action(ev.detail.browser_mod);
}
});
}
async service(service, data) {
this._service_action({ service, data });
}
async _service_action({ service, data }) {
let _service: String = service;
if (
(!_service.startsWith("browser_mod.") && _service.includes(".")) ||
data.browser_id !== undefined
) {
const d = { ...data };
if (d.browser_id === "THIS") d.browser_id = this.browserID;
// CALL HOME ASSISTANT SERVICE
const [domain, srv] = _service.split(".");
return this.hass.callService(domain, srv, d);
}
if (_service.startsWith("browser_mod.")) {
_service = _service.substring(12);
}
switch (_service) {
case "sequence":
for (const a of data.sequence) await this._service_action(a);
break;
case "delay":
await new Promise((resolve) => setTimeout(resolve, data.time));
break;
case "more_info":
const { entity, large, ignore_popup_card } = data;
this.showMoreInfo(entity, large, ignore_popup_card);
break;
case "popup":
const { title, content, ...d } = data;
for (const [k, v] of Object.entries(d)) {
if (k.endsWith("_action")) {
d[k] = (ext_data?) => {
const { service, data } = v as any;
this._service_action({
service,
data: { ...data, ...ext_data },
});
};
}
}
this.showPopup(title, content, d);
break;
case "close_popup":
this.closePopup();
break;
case "navigate":
this.browser_navigate(data.path);
break;
case "refresh":
window.location.href = window.location.href;
break;
case "console":
if (
Object.keys(data).length > 1 ||
(data && data.message === undefined)
)
console.dir(data);
else console.log(data.message);
break;
case "javascript":
const code = `
"use strict";
${data.code}
`;
const fn = new Function("hass", "data", code);
fn(this.hass, data);
break;
}
}
};
};

View File

@ -1,41 +0,0 @@
const a = {};
import { BrowserMod } from "./main";
interface FullyKiosk {
// Get device info
getIp4Address: { (): String };
getDeviceId: { (): String };
getBatteryLevel: { (): Number };
getScreenBrightness: { (): Number };
getScreenOn: { (): Boolean };
isPlugged: { (): Boolean };
// Controll device, show notifications, send network data etc.
turnScreenOn: { () };
turnScreenOff: { (keepAlive?: Boolean) };
setScreenBrightness: { (lewvel: Number) };
// Control fully and browsing
startScreensaver: { () };
stopScreensaver: { () };
// Respond to events
bind: { (event: String, action: String) };
// Motion detection
getCamshotJpgBase64: { (): String };
getBooleanSetting: { (key: String): String };
}
declare global {
interface Window {
browser_mod?: BrowserMod;
browser_mod_log?: any;
fully?: FullyKiosk;
hassConnection?: Promise<any>;
customCards?: [{}?];
loadCardHelpers?: { () };
}
}

4632
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +1,21 @@
{ {
"name": "browser_mod", "name": "browser_mod",
"private": true, "private": true,
"version": "2.0.0", "version": "1.0.0",
"description": "", "description": "",
"scripts": { "scripts": {
"build": "rollup -c", "build": "webpack",
"watch": "rollup -c --watch" "watch": "webpack --watch --mode=development",
"update-card-tools": "npm uninstall card-tools && npm install thomasloven/lovelace-card-tools"
}, },
"keywords": [], "keywords": [],
"author": "Thomas Lovén", "author": "Thomas Lovén",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@babel/core": "^7.18.9", "webpack": "^4.41.2",
"@rollup/plugin-babel": "^5.3.1", "webpack-cli": "^3.3.10"
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^13.3.0",
"lit": "^2.2.8",
"rollup": "^2.77.2",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript2": "^0.32.1",
"typescript": "^4.7.4"
}, },
"dependencies": {} "dependencies": {
"card-tools": "github:thomasloven/lovelace-card-tools"
}
} }

View File

@ -1,42 +0,0 @@
import nodeResolve from "@rollup/plugin-node-resolve";
import json from "@rollup/plugin-json";
import typescript from "rollup-plugin-typescript2";
import { terser } from "rollup-plugin-terser";
import babel from "@rollup/plugin-babel";
const dev = process.env.ROLLUP_WATCH;
module.exports = [
{
input: "js/plugin/main.ts",
output: {
file: "custom_components/browser_mod/browser_mod.js",
format: "es",
},
plugins: [
nodeResolve(),
json(),
typescript(),
babel({
exclude: "node_modules/**",
}),
!dev && terser({ format: { comments: false } }),
],
},
{
input: "js/config_panel/main.ts",
output: {
file: "custom_components/browser_mod/browser_mod_panel.js",
format: "es",
},
plugins: [
nodeResolve(),
json(),
typescript(),
babel({
exclude: "node_modules/**",
}),
!dev && terser({ format: { comments: false } }),
],
},
];

View File

@ -1,25 +0,0 @@
- id: "1660669793583"
alias: Toggle bed light
description: ""
trigger:
- platform: time_pattern
seconds: /3
condition: []
action:
- type: toggle
device_id: 98861bdf58b3c79183c03be06da14f27
entity_id: light.bed_light
domain: light
mode: single
- alias: Popup when kitchen light togggled
trigger:
- platform: state
entity_id: light.kitchen_lights
action:
- service: browser_mod.popup
data:
title: automation
content:
type: markdown
content: "{%raw%}{{states('light.bed_light')}}{%endraw%}"

View File

@ -1,62 +0,0 @@
default_config:
automation: !include test/automations.yaml
demo:
http:
use_x_forwarded_for: true
trusted_proxies:
- 172.17.0.0/24
logger:
default: warning
logs:
custom_components.browser_mod: info
# debugpy:
# browser_mod:
# devices:
# camdevice:
# camera: true
# testdevice:
# alias: test
# fully:
# force_stay_awake: true
# fully2:
# screensaver: true
lovelace:
mode: storage
dashboards:
lovelace-yaml:
mode: yaml
title: yaml
filename: test/lovelace.yaml
frontend:
themes:
red:
primary-color: red
test:
card-mod-theme: test
card-mod-more-info-yaml: |
$: |
.mdc-dialog {
backdrop-filter: grayscale(0.7) blur(5px);
}
tts:
- platform: google_translate
# base_url: !env_var OUT_ADDR
script:
cm_debug:
sequence:
- service: browser_mod.debug
print_id:
sequence:
- service: system_log.write
data:
message: "Button was clicked in {{browser_id}}."

View File

@ -1,17 +0,0 @@
version: "3.0"
services:
test:
image: thomasloven/hass-custom-devcontainer
environment:
- HASS_USERNAME
- HASS_PASSWORD
- LOVELACE_LOCAL_FILES
- LOVELACE_PLUGINS
volumes:
- ./configuration.yaml:/config/configuration.yaml:ro
- .:/config/test:ro
- ..:/config/www/workspace
- ../custom_components:/config/custom_components
ports:
- 8125:8123

View File

@ -1,67 +0,0 @@
name: browser_mod
popup_cards:
sun.sun:
title: Global Popup-card
card:
type: markdown
content: Gobal popup for sun.sun
views:
- title: Player
cards:
- type: custom:browser-player
- type: picture-elements
image: https://placekitten.com/g/800/600
elements:
- type: state-icon
entity: light.bed_light
style:
top: 50%
left: 50%
transform: none
animation: spin 4s linear infinite
style: |
@keyframes spin {
100% {
transform: rotate(359deg);
}
}
- type: button
entity: sun.sun
name: Global popup-card
tap_action:
action: more-info
- !include views/popup.yaml
- !include views/frontend-backend.yaml
- title: Popup card
popup_cards:
sun.sun:
title: Local Popup-card
card:
type: markdown
content: Local popup for sun.sun
climate.hvac:
title: Local Popup-card
card:
type: markdown
content: Local popup for climate.hvac
light.kitchen_lights:
type: No card
cards:
- type: button
entity: climate.hvac
name: Local popup-card
tap_action:
action: more-info
- type: button
entity: sun.sun
name: Override global popup-card
tap_action:
action: more-info
- type: button
entity: light.kitchen_lights
name: No card config
tap_action:
action: more-info

View File

@ -1,30 +0,0 @@
title: frontend vs backend
cards:
- type: entities
entities:
- light.bed_light
- light.kitchen_lights
- type: button
name: fire-dom-event
tap_action:
action: fire-dom-event
browser_mod:
service: browser_mod.popup
data:
title: fire-dom-event
content:
type: markdown
content: "{{states('light.bed_light')}}"
- type: button
name: call-service
tap_action:
action: call-service
service: browser_mod.popup
data:
title: call-service
content:
type: markdown
content: "{{states('light.bed_light')}}"

View File

@ -1,225 +0,0 @@
x-anchors:
default: &default
type: button
icon: mdi:star
desc: &desc
type: markdown
style: |
code {
font-size: 8pt;
line-height: normal;
white-space: pre-wrap;
}
title: Popup
cards:
- <<: *desc
content: |
## Service: `browser_mod.popup`
- type: vertical-stack
cards:
- <<: *desc
content: |
```
title: Default
card:
type: markdown
content: Popup!
```
- <<: *default
name: Default
tap_action:
action: fire-dom-event
browser_mod:
command: popup
title: Default
card:
type: markdown
content: Popup!
- type: vertical-stack
cards:
- <<: *desc
content: |
```
title: Large
large: true
card:
type: markdown
content: Popup!
```
- <<: *default
name: Large
tap_action:
action: fire-dom-event
browser_mod:
command: popup
title: Large
large: true
card:
type: markdown
content: Popup!
- type: vertical-stack
cards:
- <<: *desc
content: |
```
title: Hide Header
hide_header: true
card:
type: markdown
content: Popup!
```
- <<: *default
name: Hide header
tap_action:
action: fire-dom-event
browser_mod:
command: popup
title: Hide Header
hide_header: true
card:
type: markdown
content: Popup!
- type: vertical-stack
cards:
- <<: *desc
content: |
```
title: Auto close
auto_close: true
card:
type: markdown
content: Popup!
```
- <<: *default
name: Auto close
tap_action:
action: fire-dom-event
browser_mod:
command: popup
title: Auto close
auto_close: true
card:
type: markdown
content: Popup!
- type: vertical-stack
cards:
- <<: *desc
content: |
```
title: Popup 1
card:
<<: *default
tap_action:
action: fire-dom-event
browser_mod:
command: popup
title: Popup 2
card:
type: markdown
content: Popup!
```
- <<: *default
name: Nested popup
tap_action:
action: fire-dom-event
browser_mod:
command: popup
title: Popup 1
card:
<<: *default
tap_action:
action: fire-dom-event
browser_mod:
command: popup
title: Popup 2
card:
type: markdown
content: Popup!
- type: vertical-stack
cards:
- <<: *desc
content: |
More info in popup.
May have different behavior depending on whether a more-info dialog
has ever been opened before a popup.
- type: entities
entities: [light.bed_light]
- <<: *default
name: More info in popup
tap_action:
action: fire-dom-event
browser_mod:
command: popup
title: More info in popup
card:
type: entities
entities: [light.bed_light]
- type: vertical-stack
cards:
- <<: *desc
content: |
```
style:
$: |
.mdc-dialog {
backdrop-filter: blur(17px);
-webkit-backdrop-filter: blur(17px);
}
.mdc-dialog .mdc-dialog__container .mdc-dialog__surface {
border-radius: 25px;
}
.: |
:host {
--mdc-theme-surface: rgba(0,0,0,0);
--secondary-background-color: rgba(0,0,0,0.5);
--ha-card-background: rgba(0,0,0,0.5);
}
:host .content {
width: 90vw;
height: 90vh;
}
```
- <<: *default
name: Styled
tap_action:
action: fire-dom-event
browser_mod:
command: popup
title: Styled popup
card:
type: markdown
content: Popup!
style:
$: |
.mdc-dialog {
backdrop-filter: blur(17px);
-webkit-backdrop-filter: blur(17px);
}
.mdc-dialog .mdc-dialog__container .mdc-dialog__surface {
border-radius: 25px;
}
.: |
:host {
--mdc-theme-surface: rgba(0,0,0,0);
--secondary-background-color: rgba(0,0,0,0.5);
--ha-card-background: rgba(0,0,0,0.5);
}
:host .content {
width: 90vw;
height: 90vh;
}
- <<: *default
name: Close popup
tap_action:
action: call-service
service: browser_mod.close_popup

View File

@ -1,9 +0,0 @@
{
"compilerOptions": {
"target": "es2017",
"moduleResolution": "node",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true
}
}

10
webpack.config.js Normal file
View File

@ -0,0 +1,10 @@
const path = require('path');
module.exports = {
entry: './js/main.js',
mode: 'production',
output: {
filename: 'custom_components/browser_mod/browser_mod.js',
path: path.resolve(__dirname)
}
};