├── .gitignore ├── tests ├── __init__.py └── test_config_flow.py ├── hacs.json ├── .devcontainer ├── configuration.yaml └── devcontainer.json ├── .github ├── release-drafter.yml └── workflows │ ├── test.yml │ ├── release-drafter.yml │ └── hacs-validation.yml ├── setup.cfg ├── custom_components └── fullykiosk │ ├── manifest.json │ ├── strings.json │ ├── translations │ ├── en.json │ ├── pt-BR.json │ └── pl.json │ ├── const.py │ ├── coordinator.py │ ├── __init__.py │ ├── binary_sensor.py │ ├── button.py │ ├── light.py │ ├── number.py │ ├── config_flow.py │ ├── sensor.py │ ├── switch.py │ ├── services.yaml │ └── media_player.py ├── .pre-commit-config.yaml ├── .vscode └── tasks.json ├── pylintrc ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | bin 3 | lib 4 | .DS_Store 5 | .pytest_cache/ -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the Fully Kiosk Browser integration.""" 2 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Fully Kiosk Browser", 3 | "render_readme": true, 4 | "homeassistant": "2022.7.0b0" 5 | } -------------------------------------------------------------------------------- /.devcontainer/configuration.yaml: -------------------------------------------------------------------------------- 1 | default_config: 2 | 3 | logger: 4 | default: error 5 | logs: 6 | custom_components.fullykiosk: debug 7 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | change-template: "- #$NUMBER - $TITLE (@$AUTHOR)" 2 | categories: 3 | - title: ":warning: Breaking Changes" 4 | labels: 5 | - "breaking change" 6 | template: | 7 | ## What’s Changed 8 | 9 | $CHANGES 10 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | formatting: 9 | runs-on: "ubuntu-latest" 10 | name: Check formatting 11 | steps: 12 | - uses: "actions/checkout@v2" 13 | - uses: "actions/setup-python@v1" 14 | with: 15 | python-version: "3.8" 16 | - uses: pre-commit/action@v2.0.0 17 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build 3 | doctests = True 4 | # To work with Black 5 | # E501: line too long 6 | # W503: Line break occurred before a binary operator 7 | # E203: Whitespace before ':' 8 | # D202 No blank lines allowed after function docstring 9 | # W504 line break after binary operator 10 | ignore = 11 | E501, 12 | W503, 13 | E203, 14 | D202, 15 | W504 -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | update_release_draft: 10 | runs-on: ubuntu-latest 11 | steps: 12 | # Drafts your next Release notes as Pull Requests are merged into "master" 13 | - uses: release-drafter/release-drafter@v5 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/hacs-validation.yml: -------------------------------------------------------------------------------- 1 | name: HACS Validation 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | comment: "false" 19 | -------------------------------------------------------------------------------- /custom_components/fullykiosk/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "fullykiosk", 3 | "name": "Fully Kiosk Browser", 4 | "config_flow": true, 5 | "documentation": "https://github.com/cgarwood/homeassistant-fullykiosk", 6 | "issue_tracker": "https://github.com/cgarwood/homeassistant-fullykiosk/issues", 7 | "requirements": [ 8 | "python-fullykiosk==0.0.11" 9 | ], 10 | "ssdp": [], 11 | "zeroconf": [], 12 | "homekit": {}, 13 | "dependencies": [], 14 | "codeowners": [ 15 | "@cgarwood" 16 | ], 17 | "version": "1.1.0" 18 | } -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 22.3.0 4 | hooks: 5 | - id: black 6 | args: 7 | - --safe 8 | - --quiet 9 | - repo: https://gitlab.com/pycqa/flake8 10 | rev: 4.0.1 11 | hooks: 12 | - id: flake8 13 | additional_dependencies: 14 | - flake8-docstrings==1.6.0 15 | - pydocstyle==6.1.1 16 | - repo: https://github.com/PyCQA/isort 17 | rev: 5.10.1 18 | hooks: 19 | - id: isort 20 | args: ["--profile", "black"] 21 | -------------------------------------------------------------------------------- /custom_components/fullykiosk/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Fully Kiosk Browser", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "Connect to the device", 7 | "data": { 8 | "host": "Host" 9 | } 10 | } 11 | }, 12 | "error": { 13 | "cannot_connect": "Failed to connect, please try again", 14 | "invalid_auth": "Invalid authentication", 15 | "unknown": "Unexpected error" 16 | }, 17 | "abort": { 18 | "already_configured": "Device is already configured" 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /custom_components/fullykiosk/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "title": "Fully Kiosk Browser", 4 | "step": { 5 | "user": { 6 | "title": "Connect to the device", 7 | "data": { 8 | "host": "IP/Host", 9 | "port": "Port", 10 | "password": "Remote Administration Password" 11 | } 12 | } 13 | }, 14 | "error": { 15 | "cannot_connect": "Failed to connect, please try again", 16 | "invalid_auth": "Invalid authentication", 17 | "unknown": "Unexpected error" 18 | }, 19 | "abort": { 20 | "already_configured": "Device is already configured" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /custom_components/fullykiosk/translations/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "title": "Fully Kiosk Browser", 4 | "step": { 5 | "user": { 6 | "title": "Conecte-se ao dispositivo", 7 | "data": { 8 | "host": "IP/Host", 9 | "port": "Porta", 10 | "password": "Senha de administração remota" 11 | } 12 | } 13 | }, 14 | "error": { 15 | "cannot_connect": "Falha ao conectar, tente novamente", 16 | "invalid_auth": "Autenticação inválida", 17 | "unknown": "Erro inesperado" 18 | }, 19 | "abort": { 20 | "already_configured": "O dispositivo já está configurado" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /custom_components/fullykiosk/translations/pl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "title": "Fully Kiosk Browser", 4 | "step": { 5 | "user": { 6 | "title": "Połącz się z urządzeniem", 7 | "data": { 8 | "host": "IP/Host", 9 | "port": "Port", 10 | "password": "Hasło administracji zdalnej" 11 | } 12 | } 13 | }, 14 | "error": { 15 | "cannot_connect": "Nie udało się połączyć, spróbuj ponownie", 16 | "invalid_auth": "Nieprawidłowe uwierzytelnienie", 17 | "unknown": "Niespodziewany błąd" 18 | }, 19 | "abort": { 20 | "already_configured": "Urządzenie jest już skonfigurowane" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 9123", 6 | "type": "shell", 7 | "command": "container start", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "Run Home Assistant configuration against /config", 12 | "type": "shell", 13 | "command": "container check", 14 | "problemMatcher": [] 15 | }, 16 | { 17 | "label": "Upgrade Home Assistant to latest dev", 18 | "type": "shell", 19 | "command": "container install", 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "Install a spesific version of Home Assistant", 24 | "type": "shell", 25 | "command": "container set-version", 26 | "problemMatcher": [] 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /custom_components/fullykiosk/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Fully Kiosk Browser integration.""" 2 | 3 | DOMAIN = "fullykiosk" 4 | 5 | ATTR_APPLICATION = "application" 6 | ATTR_CONFIG_TYPE = "config_type" 7 | ATTR_KEY = "key" 8 | ATTR_STREAM = "stream" 9 | ATTR_URL = "url" 10 | ATTR_VALUE = "value" 11 | 12 | AUDIOMANAGER_STREAM_MUSIC = 3 13 | 14 | SERVICE_LOAD_START_URL = "load_start_url" 15 | SERVICE_LOAD_URL = "load_url" 16 | SERVICE_PLAY_AUDIO = "play_audio" 17 | SERVICE_REBOOT_DEVICE = "reboot_device" 18 | SERVICE_RESTART_APP = "restart" 19 | SERVICE_SET_CONFIG = "set_config" 20 | SERVICE_START_APPLICATION = "start_application" 21 | SERVICE_TO_FOREGROUND = "to_foreground" 22 | SERVICE_TO_BACKGROUND = "to_background" 23 | 24 | UPDATE_INTERVAL = 30 25 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // See https://aka.ms/vscode-remote/devcontainer.json for format details. 2 | { 3 | "image": "ludeeus/container:integration", 4 | "context": "..", 5 | "appPort": ["9123:8123"], 6 | "postCreateCommand": "container install", 7 | "runArgs": ["-v", "${env:HOME}${env:USERPROFILE}/.ssh:/tmp/.ssh"], 8 | "extensions": [ 9 | "ms-python.python", 10 | "github.vscode-pull-request-github", 11 | "tabnine.tabnine-vscode" 12 | ], 13 | "settings": { 14 | "files.eol": "\n", 15 | "editor.tabSize": 4, 16 | "terminal.integrated.shell.linux": "/bin/bash", 17 | "python.pythonPath": "/usr/bin/python3", 18 | "python.linting.pylintEnabled": true, 19 | "python.linting.enabled": true, 20 | "python.formatting.provider": "black", 21 | "editor.formatOnPaste": false, 22 | "editor.formatOnSave": true, 23 | "editor.formatOnType": true, 24 | "files.trimTrailingWhitespace": true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /custom_components/fullykiosk/coordinator.py: -------------------------------------------------------------------------------- 1 | """Provides the The Fully Kiosk Browser DataUpdateCoordinator.""" 2 | import asyncio 3 | import logging 4 | from datetime import timedelta 5 | 6 | from aiohttp import ClientSession 7 | from aiohttp.client_exceptions import ClientConnectorError 8 | from async_timeout import timeout 9 | from fullykiosk import FullyKiosk 10 | from fullykiosk.exceptions import FullyKioskError 11 | from homeassistant.helpers.typing import HomeAssistantType 12 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 13 | 14 | from .const import UPDATE_INTERVAL 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): 20 | """Define an object to hold Fully Kiosk Browser data.""" 21 | 22 | def __init__( 23 | self, hass: HomeAssistantType, session: ClientSession, host, port, password 24 | ): 25 | """Initialize.""" 26 | self.fully = FullyKiosk(session, host, port, password) 27 | 28 | super().__init__( 29 | hass, 30 | _LOGGER, 31 | name=f"{host} deviceInfo", 32 | update_interval=timedelta(seconds=UPDATE_INTERVAL), 33 | ) 34 | 35 | async def _async_update_data(self): 36 | """Update data via library.""" 37 | try: 38 | with timeout(15): 39 | """Get device info and settings in parallel""" 40 | result = await asyncio.gather( 41 | self.fully.getDeviceInfo(), self.fully.getSettings() 42 | ) 43 | """Store settings under settings key in data""" 44 | result[0]["settings"] = result[1] 45 | return result[0] 46 | except (FullyKioskError, ClientConnectorError) as error: 47 | raise UpdateFailed(error) from error 48 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | ignore=tests 3 | # Use a conservative default here; 2 should speed up most setups and not hurt 4 | # any too bad. Override on command line as appropriate. 5 | jobs=2 6 | persistent=no 7 | 8 | [BASIC] 9 | good-names=id,i,j,k,ex,Run,_,fp 10 | 11 | [MESSAGES CONTROL] 12 | # Reasons disabled: 13 | # format - handled by black 14 | # locally-disabled - it spams too much 15 | # duplicate-code - unavoidable 16 | # cyclic-import - doesn't test if both import on load 17 | # abstract-class-little-used - prevents from setting right foundation 18 | # unused-argument - generic callbacks and setup methods create a lot of warnings 19 | # global-statement - used for the on-demand requirement installation 20 | # redefined-variable-type - this is Python, we're duck typing! 21 | # too-many-* - are not enforced for the sake of readability 22 | # too-few-* - same as too-many-* 23 | # abstract-method - with intro of async there are always methods missing 24 | # inconsistent-return-statements - doesn't handle raise 25 | # unnecessary-pass - readability for functions which only contain pass 26 | # import-outside-toplevel - TODO 27 | # too-many-ancestors - it's too strict. 28 | disable= 29 | format, 30 | abstract-class-little-used, 31 | abstract-method, 32 | cyclic-import, 33 | duplicate-code, 34 | global-statement, 35 | import-outside-toplevel, 36 | inconsistent-return-statements, 37 | locally-disabled, 38 | not-context-manager, 39 | redefined-variable-type, 40 | too-few-public-methods, 41 | too-many-ancestors, 42 | too-many-arguments, 43 | too-many-branches, 44 | too-many-instance-attributes, 45 | too-many-lines, 46 | too-many-locals, 47 | too-many-public-methods, 48 | too-many-return-statements, 49 | too-many-statements, 50 | too-many-boolean-expressions, 51 | unnecessary-pass, 52 | unused-argument 53 | 54 | [REPORTS] 55 | score=no 56 | 57 | [TYPECHECK] 58 | # For attrs 59 | ignored-classes=_CountingAttr 60 | 61 | [FORMAT] 62 | expected-line-ending-format=LF -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This is now a Home Assistant core integration 2 | This integration is now part of the Home Assistant core, and this repository is now archived. Documentation on the core integration are available at https://www.home-assistant.io/integrations/fully_kiosk/ 3 | 4 | 5 | # Fully Kiosk Browser integration for Home Assistant 6 | 7 | Provides support for controlling some common Fully Kiosk options through Home Assistant. Requires Remote Administration to be enabled in the Fully Kiosk Browser settings. 8 | 9 | Currently Supports: 10 | 11 | - Light entity for turning screen on/off and setting screen brightness 12 | - Switch entities for controlling screensaver, maintenance mode, and kiosk protection 13 | - A variety of sensors (battery level, charging status, wifi status, and more) 14 | - A few button entities for triggering functions on the tablet, such as restarting Fully, or reloading the start URL 15 | - A media player entity for playing audio files on the device 16 | 17 | The `media_player` entity has a few extra services that allow you to launch an app, load custom URLs, and more. See `custom_components/fullykiosk/services.yaml` for documentation on the various services. 18 | 19 | Uses upstream library [python-fullykiosk](https://github.com/cgarwood/python-fullykiosk) 20 | 21 | Buy Me A Coffee 22 | 23 | ## Installation 24 | 25 | The easiest way to install is through HACS. The Fully Kiosk Browser is already included in the HACS default repositories. 26 | 27 | 1. In Home Assistant, select HACS -> Integrations -> + Explore and Download Repositories. Search for Fully Kiosk Browser in the list and add it. 28 | 2. Restart Home Assistant 29 | 3. Set up and configure the integration: [![Add Integration](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=fullykiosk) 30 | 31 | You will need your tablet's IP address and Fully's remote administration password to set up the integration. 32 | 33 | ## Manual Installation 34 | 35 | Copy the `custom_components/fullykiosk` directory to your `custom_components` folder. Restart Home Assistant, and add the integration from the integrations page. 36 | -------------------------------------------------------------------------------- /custom_components/fullykiosk/__init__.py: -------------------------------------------------------------------------------- 1 | """The Fully Kiosk Browser integration.""" 2 | import asyncio 3 | import logging 4 | 5 | import voluptuous as vol 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT 8 | from homeassistant.exceptions import ConfigEntryNotReady 9 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 10 | from homeassistant.helpers.typing import HomeAssistantType 11 | 12 | from .const import DOMAIN 13 | from .coordinator import FullyKioskDataUpdateCoordinator 14 | 15 | CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) 16 | 17 | PLATFORMS = [ 18 | "binary_sensor", 19 | "button", 20 | "light", 21 | "media_player", 22 | "number", 23 | "sensor", 24 | "switch", 25 | ] 26 | 27 | _LOGGER = logging.getLogger(__name__) 28 | 29 | 30 | async def async_setup(hass: HomeAssistantType, config: dict): 31 | """Set up the Fully Kiosk Browser component.""" 32 | 33 | hass.data.setdefault(DOMAIN, {}) 34 | return True 35 | 36 | 37 | async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): 38 | """Set up Fully Kiosk Browser from a config entry.""" 39 | 40 | entry_data = entry.data 41 | coordinator = FullyKioskDataUpdateCoordinator( 42 | hass, 43 | async_get_clientsession(hass), 44 | entry_data[CONF_HOST], 45 | entry_data[CONF_PORT], 46 | entry_data[CONF_PASSWORD], 47 | ) 48 | 49 | await coordinator.async_refresh() 50 | 51 | if not coordinator.last_update_success: 52 | raise ConfigEntryNotReady 53 | 54 | hass.data[DOMAIN][entry.entry_id] = coordinator 55 | 56 | for component in PLATFORMS: 57 | hass.async_create_task( 58 | hass.config_entries.async_forward_entry_setup(entry, component) 59 | ) 60 | 61 | return True 62 | 63 | 64 | async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): 65 | """Unload a config entry.""" 66 | unload_ok = all( 67 | await asyncio.gather( 68 | *[ 69 | hass.config_entries.async_forward_entry_unload(entry, component) 70 | for component in PLATFORMS 71 | ] 72 | ) 73 | ) 74 | if unload_ok: 75 | hass.data[DOMAIN].pop(entry.entry_id) 76 | 77 | return unload_ok 78 | -------------------------------------------------------------------------------- /custom_components/fullykiosk/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Fully Kiosk Browser sensor.""" 2 | from homeassistant.components.binary_sensor import ( 3 | BinarySensorDeviceClass, 4 | BinarySensorEntity, 5 | BinarySensorEntityDescription, 6 | ) 7 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 8 | 9 | from .const import DOMAIN 10 | 11 | SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( 12 | BinarySensorEntityDescription(key="kioskMode", name="Kiosk Mode"), 13 | BinarySensorEntityDescription( 14 | key="plugged", 15 | name="Plugged In", 16 | device_class=BinarySensorDeviceClass.PLUG, 17 | ), 18 | BinarySensorEntityDescription( 19 | key="isDeviceAdmin", 20 | name="Device Admin", 21 | ), 22 | ) 23 | 24 | 25 | async def async_setup_entry(hass, config_entry, async_add_entities): 26 | """Set up the Fully Kiosk Browser sensor.""" 27 | coordinator = hass.data[DOMAIN][config_entry.entry_id] 28 | 29 | sensors = [ 30 | FullyBinarySensor(coordinator, sensor) 31 | for sensor in SENSOR_TYPES 32 | if sensor.key in coordinator.data 33 | ] 34 | 35 | async_add_entities(sensors, False) 36 | 37 | 38 | class FullyBinarySensor(CoordinatorEntity, BinarySensorEntity): 39 | """Representation of a Fully Kiosk Browser binary sensor.""" 40 | 41 | def __init__(self, coordinator, sensor): 42 | """Initialize the binary sensor.""" 43 | self.entity_description = sensor 44 | self._sensor = sensor.key 45 | self.coordinator = coordinator 46 | 47 | self._attr_name = f"{coordinator.data['deviceName']} {sensor.name}" 48 | self._attr_unique_id = f"{coordinator.data['deviceID']}-{sensor.key}" 49 | self._attr_device_info = { 50 | "identifiers": {(DOMAIN, self.coordinator.data["deviceID"])}, 51 | "name": self.coordinator.data["deviceName"], 52 | "manufacturer": self.coordinator.data["deviceManufacturer"], 53 | "model": self.coordinator.data["deviceModel"], 54 | "sw_version": self.coordinator.data["appVersionName"], 55 | "configuration_url": f"http://{self.coordinator.data['ip4']}:2323", 56 | } 57 | 58 | @property 59 | def is_on(self): 60 | """Return if the binary sensor is on.""" 61 | if self.coordinator.data: 62 | return self.coordinator.data[self._sensor] 63 | 64 | async def async_added_to_hass(self): 65 | """Connect to dispatcher listening for entity data notifications.""" 66 | self.async_on_remove( 67 | self.coordinator.async_add_listener(self.async_write_ha_state) 68 | ) 69 | 70 | async def async_update(self): 71 | """Update Fully Kiosk Browser entity.""" 72 | await self.coordinator.async_request_refresh() 73 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Test the Fully Kiosk Browser config flow.""" 2 | from asynctest import patch 3 | from homeassistant import config_entries, setup 4 | from homeassistant.components.fullykiosk.config_flow import CannotConnect, InvalidAuth 5 | from homeassistant.components.fullykiosk.const import DOMAIN 6 | 7 | 8 | async def test_form(hass): 9 | """Test we get the form.""" 10 | await setup.async_setup_component(hass, "persistent_notification", {}) 11 | result = await hass.config_entries.flow.async_init( 12 | DOMAIN, context={"source": config_entries.SOURCE_USER} 13 | ) 14 | assert result["type"] == "form" 15 | assert result["errors"] == {} 16 | 17 | with patch( 18 | "homeassistant.components.fullykiosk.config_flow.PlaceholderHub.authenticate", 19 | return_value=True, 20 | ), patch( 21 | "homeassistant.components.fullykiosk.async_setup", return_value=True 22 | ) as mock_setup, patch( 23 | "homeassistant.components.fullykiosk.async_setup_entry", return_value=True 24 | ) as mock_setup_entry: 25 | result2 = await hass.config_entries.flow.async_configure( 26 | result["flow_id"], 27 | { 28 | "host": "1.1.1.1", 29 | "username": "test-username", 30 | "password": "test-password", 31 | }, 32 | ) 33 | 34 | assert result2["type"] == "create_entry" 35 | assert result2["title"] == "Name of the device" 36 | assert result2["data"] == { 37 | "host": "1.1.1.1", 38 | "username": "test-username", 39 | "password": "test-password", 40 | } 41 | await hass.async_block_till_done() 42 | assert len(mock_setup.mock_calls) == 1 43 | assert len(mock_setup_entry.mock_calls) == 1 44 | 45 | 46 | async def test_form_invalid_auth(hass): 47 | """Test we handle invalid auth.""" 48 | result = await hass.config_entries.flow.async_init( 49 | DOMAIN, context={"source": config_entries.SOURCE_USER} 50 | ) 51 | 52 | with patch( 53 | "homeassistant.components.fullykiosk.config_flow.PlaceholderHub.authenticate", 54 | side_effect=InvalidAuth, 55 | ): 56 | result2 = await hass.config_entries.flow.async_configure( 57 | result["flow_id"], 58 | { 59 | "host": "1.1.1.1", 60 | "username": "test-username", 61 | "password": "test-password", 62 | }, 63 | ) 64 | 65 | assert result2["type"] == "form" 66 | assert result2["errors"] == {"base": "invalid_auth"} 67 | 68 | 69 | async def test_form_cannot_connect(hass): 70 | """Test we handle cannot connect error.""" 71 | result = await hass.config_entries.flow.async_init( 72 | DOMAIN, context={"source": config_entries.SOURCE_USER} 73 | ) 74 | 75 | with patch( 76 | "homeassistant.components.fullykiosk.config_flow.PlaceholderHub.authenticate", 77 | side_effect=CannotConnect, 78 | ): 79 | result2 = await hass.config_entries.flow.async_configure( 80 | result["flow_id"], 81 | { 82 | "host": "1.1.1.1", 83 | "username": "test-username", 84 | "password": "test-password", 85 | }, 86 | ) 87 | 88 | assert result2["type"] == "form" 89 | assert result2["errors"] == {"base": "cannot_connect"} 90 | -------------------------------------------------------------------------------- /custom_components/fullykiosk/button.py: -------------------------------------------------------------------------------- 1 | """Fully Kiosk Browser button.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import Callable 5 | 6 | from homeassistant.components.button import ButtonEntity, ButtonEntityDescription 7 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 8 | 9 | from .const import DOMAIN 10 | 11 | 12 | @dataclass 13 | class FullyButtonEntityDescription(ButtonEntityDescription): 14 | """Fully Kiosk Browser button description.""" 15 | 16 | press_action: Callable = None 17 | 18 | 19 | ENTITY_TYPES: tuple[FullyButtonEntityDescription, ...] = [ 20 | FullyButtonEntityDescription( 21 | key="restartApp", 22 | name="Restart Browser", 23 | press_action=lambda fully: fully.restartApp(), 24 | ), 25 | FullyButtonEntityDescription( 26 | key="rebootDevice", 27 | name="Reboot Device", 28 | press_action=lambda fully: fully.rebootDevice(), 29 | ), 30 | FullyButtonEntityDescription( 31 | key="toForeground", 32 | name="Bring to Foreground", 33 | press_action=lambda fully: fully.toForeground(), 34 | ), 35 | FullyButtonEntityDescription( 36 | key="toBackground", 37 | name="Send to Background", 38 | press_action=lambda fully: fully.toBackground(), 39 | ), 40 | FullyButtonEntityDescription( 41 | key="loadStartUrl", 42 | name="Load Start URL", 43 | press_action=lambda fully: fully.loadStartUrl(), 44 | ), 45 | ] 46 | 47 | 48 | async def async_setup_entry(hass, config_entry, async_add_entities): 49 | """Set up the Fully Kiosk Browser button entities.""" 50 | coordinator = hass.data[DOMAIN][config_entry.entry_id] 51 | 52 | entities = [FullyButtonEntity(coordinator, entity) for entity in ENTITY_TYPES] 53 | 54 | async_add_entities(entities, False) 55 | 56 | 57 | class FullyButtonEntity(CoordinatorEntity, ButtonEntity): 58 | """Representation of a Fully Kiosk Browser entity.""" 59 | 60 | def __init__(self, coordinator, description: FullyButtonEntityDescription): 61 | """Initialize the number entity.""" 62 | self.entity_description = description 63 | self._key = description.key 64 | self.coordinator = coordinator 65 | 66 | self._attr_name = f"{coordinator.data['deviceName']} {description.name}" 67 | self._attr_unique_id = f"{coordinator.data['deviceID']}-{description.key}" 68 | self._attr_device_info = { 69 | "identifiers": {(DOMAIN, self.coordinator.data["deviceID"])}, 70 | "name": self.coordinator.data["deviceName"], 71 | "manufacturer": self.coordinator.data["deviceManufacturer"], 72 | "model": self.coordinator.data["deviceModel"], 73 | "sw_version": self.coordinator.data["appVersionName"], 74 | "configuration_url": f"http://{self.coordinator.data['ip4']}:2323", 75 | } 76 | 77 | async def async_added_to_hass(self): 78 | """Connect to dispatcher listening for entity data notifications.""" 79 | self.async_on_remove( 80 | self.coordinator.async_add_listener(self.async_write_ha_state) 81 | ) 82 | 83 | async def async_update(self): 84 | """Update Fully Kiosk Browser entity.""" 85 | await self.coordinator.async_request_refresh() 86 | 87 | async def async_press(self): 88 | """Set the value of the entity.""" 89 | await self.entity_description.press_action(self.coordinator.fully) 90 | await self.coordinator.async_refresh() 91 | -------------------------------------------------------------------------------- /custom_components/fullykiosk/light.py: -------------------------------------------------------------------------------- 1 | """Fully Kiosk Browser light entity for controlling screen brightness & on/off.""" 2 | import logging 3 | 4 | from homeassistant.components.light import ( 5 | ATTR_BRIGHTNESS, 6 | SUPPORT_BRIGHTNESS, 7 | LightEntity, 8 | ) 9 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 10 | 11 | from .const import DOMAIN 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | async def async_setup_entry(hass, config_entry, async_add_entities): 17 | """Set up the Fully Kiosk Browser light.""" 18 | coordinator = hass.data[DOMAIN][config_entry.entry_id] 19 | async_add_entities([FullyLight(coordinator)], False) 20 | 21 | 22 | class FullyLight(CoordinatorEntity, LightEntity): 23 | """Representation of a Fully Kiosk Browser light.""" 24 | 25 | def __init__(self, coordinator): 26 | """Initialize the light (screen) entity.""" 27 | self._name = f"{coordinator.data['deviceName']} Screen" 28 | self.coordinator = coordinator 29 | self._unique_id = f"{coordinator.data['deviceID']}-screen" 30 | 31 | @property 32 | def name(self): 33 | """Return the name of the entity.""" 34 | return self._name 35 | 36 | @property 37 | def is_on(self): 38 | """Return if the screen is on.""" 39 | if self.coordinator.data: 40 | if self.coordinator.data["appVersionCode"] < 784: 41 | return self.coordinator.data["isScreenOn"] 42 | return self.coordinator.data["screenOn"] 43 | 44 | @property 45 | def brightness(self): 46 | """Return the screen brightness.""" 47 | return self.coordinator.data["screenBrightness"] 48 | 49 | @property 50 | def supported_features(self): 51 | """Return the supported features.""" 52 | return SUPPORT_BRIGHTNESS 53 | 54 | @property 55 | def device_info(self): 56 | """Return the device info.""" 57 | return { 58 | "identifiers": {(DOMAIN, self.coordinator.data["deviceID"])}, 59 | "name": self.coordinator.data["deviceName"], 60 | "manufacturer": self.coordinator.data["deviceManufacturer"], 61 | "model": self.coordinator.data["deviceModel"], 62 | "sw_version": self.coordinator.data["appVersionName"], 63 | "configuration_url": f"http://{self.coordinator.data['ip4']}:2323", 64 | } 65 | 66 | @property 67 | def unique_id(self): 68 | """Return the unique id.""" 69 | return self._unique_id 70 | 71 | async def async_turn_on(self, **kwargs): 72 | """Turn on the screen.""" 73 | await self.coordinator.fully.screenOn() 74 | brightness = kwargs.get(ATTR_BRIGHTNESS) 75 | if brightness is None: 76 | await self.coordinator.async_refresh() 77 | return 78 | if brightness != self.coordinator.data["screenBrightness"]: 79 | await self.coordinator.fully.setScreenBrightness(brightness) 80 | await self.coordinator.async_refresh() 81 | 82 | async def async_turn_off(self, **kwargs): 83 | """Turn off the screen.""" 84 | await self.coordinator.fully.screenOff() 85 | await self.coordinator.async_refresh() 86 | 87 | async def async_added_to_hass(self): 88 | """Connect to dispatcher listening for entity data notifications.""" 89 | self.async_on_remove( 90 | self.coordinator.async_add_listener(self.async_write_ha_state) 91 | ) 92 | 93 | async def async_update(self): 94 | """Update Fully Kiosk Browser entity.""" 95 | await self.coordinator.async_request_refresh() 96 | -------------------------------------------------------------------------------- /custom_components/fullykiosk/number.py: -------------------------------------------------------------------------------- 1 | """Fully Kiosk Browser number.""" 2 | 3 | from homeassistant.components.number import NumberEntity, NumberEntityDescription 4 | from homeassistant.helpers.entity import EntityCategory 5 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 6 | 7 | from .const import DOMAIN 8 | 9 | ENTITY_TYPES: tuple[NumberEntityDescription, ...] = ( 10 | NumberEntityDescription( 11 | key="timeToScreensaverV2", 12 | name="Screensaver Timer", 13 | entity_category=EntityCategory.CONFIG, 14 | ), 15 | NumberEntityDescription( 16 | key="screensaverBrightness", 17 | name="Screensaver Brightness", 18 | entity_category=EntityCategory.CONFIG, 19 | ), 20 | NumberEntityDescription( 21 | key="timeToScreenOffV2", 22 | name="Screen Off Timer", 23 | entity_category=EntityCategory.CONFIG, 24 | ), 25 | ) 26 | 27 | 28 | async def async_setup_entry(hass, config_entry, async_add_entities): 29 | """Set up the Fully Kiosk Browser number entities.""" 30 | coordinator = hass.data[DOMAIN][config_entry.entry_id] 31 | 32 | entities = [ 33 | FullyNumberEntity(coordinator, entity) 34 | for entity in ENTITY_TYPES 35 | if entity.key in coordinator.data["settings"] 36 | ] 37 | 38 | async_add_entities(entities, False) 39 | 40 | 41 | class FullyNumberEntity(CoordinatorEntity, NumberEntity): 42 | """Representation of a Fully Kiosk Browser entity.""" 43 | 44 | def __init__(self, coordinator, description: NumberEntityDescription): 45 | """Initialize the number entity.""" 46 | self.entity_description = description 47 | self._key = description.key 48 | self.coordinator = coordinator 49 | 50 | self._attr_name = f"{coordinator.data['deviceName']} {description.name}" 51 | self._attr_unique_id = f"{coordinator.data['deviceID']}-{description.key}" 52 | self._attr_device_info = { 53 | "identifiers": {(DOMAIN, self.coordinator.data["deviceID"])}, 54 | "name": self.coordinator.data["deviceName"], 55 | "manufacturer": self.coordinator.data["deviceManufacturer"], 56 | "model": self.coordinator.data["deviceModel"], 57 | "sw_version": self.coordinator.data["appVersionName"], 58 | "configuration_url": f"http://{self.coordinator.data['ip4']}:2323", 59 | } 60 | 61 | # Min, max, and step are not available in EntityDescription until HA 2022.2 release. 62 | self._attr_native_step = 1 63 | self._attr_native_min_value = 0 64 | 65 | if self._key in ["timeToScreensaverV2", "timeToScreenOffV2"]: 66 | self._attr_native_max_value = 9999 67 | if self._key == "screensaverBrightness": 68 | self._attr_native_max_value = 255 69 | 70 | @property 71 | def state(self): 72 | """Return the state of the number entity.""" 73 | if not self.coordinator.data: 74 | return None 75 | 76 | return self.coordinator.data["settings"][self._key] 77 | 78 | async def async_added_to_hass(self): 79 | """Connect to dispatcher listening for entity data notifications.""" 80 | self.async_on_remove( 81 | self.coordinator.async_add_listener(self.async_write_ha_state) 82 | ) 83 | 84 | async def async_update(self): 85 | """Update Fully Kiosk Browser entity.""" 86 | await self.coordinator.async_request_refresh() 87 | 88 | async def async_set_native_value(self, value): 89 | """Set the value of the entity.""" 90 | await self.coordinator.fully.setConfigurationString(self._key, int(value)) 91 | -------------------------------------------------------------------------------- /custom_components/fullykiosk/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Fully Kiosk Browser integration.""" 2 | import logging 3 | 4 | import voluptuous as vol 5 | from aiohttp.client_exceptions import ClientConnectorError 6 | from async_timeout import timeout 7 | from fullykiosk import FullyKiosk 8 | from fullykiosk.exceptions import FullyKioskError 9 | from homeassistant import config_entries, core, exceptions 10 | from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT 11 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 12 | 13 | from .const import DOMAIN # pylint:disable=unused-import 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | DEFAULT_PORT = 2323 18 | 19 | DATA_SCHEMA = vol.Schema( 20 | { 21 | vol.Required(CONF_HOST): str, 22 | vol.Required(CONF_PORT, default=DEFAULT_PORT): int, 23 | vol.Required(CONF_PASSWORD): str, 24 | } 25 | ) 26 | 27 | 28 | class PlaceholderHub: 29 | """Placeholder class to make tests pass. 30 | 31 | TODO Remove this placeholder class and replace with things from your PyPI package. 32 | """ 33 | 34 | def __init__(self, host): 35 | """Initialize.""" 36 | self.host = host 37 | 38 | async def authenticate(self, username, password) -> bool: 39 | """Test if we can authenticate with the host.""" 40 | return True 41 | 42 | 43 | async def validate_input(hass: core.HomeAssistant, data): 44 | """Validate the user input allows us to connect.""" 45 | session = async_get_clientsession(hass) 46 | fully = FullyKiosk(session, data["host"], data["port"], data["password"]) 47 | 48 | try: 49 | with timeout(15): 50 | deviceInfo = await fully.getDeviceInfo() 51 | except (FullyKioskError, ClientConnectorError): 52 | raise CannotConnect 53 | 54 | # If you cannot connect: 55 | # throw CannotConnect 56 | # If the authentication is wrong: 57 | # InvalidAuth 58 | 59 | # Return info that you want to store in the config entry. 60 | return { 61 | "title": f"{deviceInfo['deviceName']} {deviceInfo['deviceID']}", 62 | "host": data["host"], 63 | "port": data["port"], 64 | "password": data["password"], 65 | } 66 | 67 | 68 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 69 | """Handle a config flow for Fully Kiosk Browser.""" 70 | 71 | VERSION = 1 72 | # TODO pick one of the available connection classes in homeassistant/config_entries.py 73 | CONNECTION_CLASS = config_entries.CONN_CLASS_UNKNOWN 74 | 75 | async def async_step_user(self, user_input=None): 76 | """Handle the initial step.""" 77 | errors = {} 78 | if user_input is not None: 79 | try: 80 | info = await validate_input(self.hass, user_input) 81 | 82 | return self.async_create_entry(title=info["title"], data=user_input) 83 | except CannotConnect: 84 | errors["base"] = "cannot_connect" 85 | except InvalidAuth: 86 | errors["base"] = "invalid_auth" 87 | except Exception: # pylint: disable=broad-except 88 | _LOGGER.exception("Unexpected exception") 89 | errors["base"] = "unknown" 90 | 91 | return self.async_show_form( 92 | step_id="user", data_schema=DATA_SCHEMA, errors=errors 93 | ) 94 | 95 | 96 | class CannotConnect(exceptions.HomeAssistantError): 97 | """Error to indicate we cannot connect.""" 98 | 99 | 100 | class InvalidAuth(exceptions.HomeAssistantError): 101 | """Error to indicate there is invalid auth.""" 102 | -------------------------------------------------------------------------------- /custom_components/fullykiosk/sensor.py: -------------------------------------------------------------------------------- 1 | """Fully Kiosk Browser sensor.""" 2 | from homeassistant.components.sensor import ( 3 | SensorDeviceClass, 4 | SensorEntity, 5 | SensorEntityDescription, 6 | ) 7 | from homeassistant.const import DATA_MEGABYTES, PERCENTAGE 8 | from homeassistant.helpers.entity import EntityCategory 9 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 10 | 11 | from .const import DOMAIN 12 | 13 | SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( 14 | SensorEntityDescription( 15 | key="batteryLevel", 16 | name="Battery Level", 17 | device_class=SensorDeviceClass.BATTERY, 18 | native_unit_of_measurement=PERCENTAGE, 19 | ), 20 | SensorEntityDescription(key="screenOrientation", name="Screen Orientation"), 21 | SensorEntityDescription( 22 | key="foregroundApp", 23 | name="Foreground App", 24 | ), 25 | SensorEntityDescription( 26 | key="lastAppStart", 27 | name="Last App Start", 28 | ), 29 | SensorEntityDescription(key="currentPage", name="Current Page"), 30 | SensorEntityDescription( 31 | key="wifiSignalLevel", 32 | name="WiFi Signal Level", 33 | entity_category=EntityCategory.DIAGNOSTIC, 34 | device_class=SensorDeviceClass.SIGNAL_STRENGTH, 35 | ), 36 | SensorEntityDescription( 37 | key="internalStorageFreeSpace", 38 | name="Internal Storage Free Space", 39 | entity_category=EntityCategory.DIAGNOSTIC, 40 | native_unit_of_measurement=DATA_MEGABYTES, 41 | ), 42 | SensorEntityDescription( 43 | key="internalStorageTotalSpace", 44 | name="Internal Storage Total Space", 45 | entity_category=EntityCategory.DIAGNOSTIC, 46 | native_unit_of_measurement=DATA_MEGABYTES, 47 | ), 48 | SensorEntityDescription( 49 | key="ramFreeMemory", 50 | name="RAM Free Memory", 51 | entity_category=EntityCategory.DIAGNOSTIC, 52 | native_unit_of_measurement=DATA_MEGABYTES, 53 | ), 54 | SensorEntityDescription( 55 | key="ramTotalMemory", 56 | name="RAM Total Memory", 57 | entity_category=EntityCategory.DIAGNOSTIC, 58 | native_unit_of_measurement=DATA_MEGABYTES, 59 | ), 60 | ) 61 | 62 | STORAGE_SENSORS = [ 63 | "internalStorageFreeSpace", 64 | "internalStorageTotalSpace", 65 | "ramFreeMemory", 66 | "ramTotalMemory", 67 | ] 68 | 69 | 70 | async def async_setup_entry(hass, config_entry, async_add_entities): 71 | """Set up the Fully Kiosk Browser sensor.""" 72 | coordinator = hass.data[DOMAIN][config_entry.entry_id] 73 | 74 | sensors = [ 75 | FullySensor(coordinator, sensor) 76 | for sensor in SENSOR_TYPES 77 | if sensor.key in coordinator.data 78 | ] 79 | 80 | async_add_entities(sensors, False) 81 | 82 | 83 | class FullySensor(CoordinatorEntity, SensorEntity): 84 | """Representation of a Fully Kiosk Browser sensor.""" 85 | 86 | def __init__(self, coordinator, sensor: SensorEntityDescription): 87 | """Initialize the sensor entity.""" 88 | self.entity_description = sensor 89 | self._sensor = sensor.key 90 | self.coordinator = coordinator 91 | 92 | self._attr_name = f"{coordinator.data['deviceName']} {sensor.name}" 93 | self._attr_unique_id = f"{coordinator.data['deviceID']}-{sensor.key}" 94 | self._attr_device_info = { 95 | "identifiers": {(DOMAIN, self.coordinator.data["deviceID"])}, 96 | "name": self.coordinator.data["deviceName"], 97 | "manufacturer": self.coordinator.data["deviceManufacturer"], 98 | "model": self.coordinator.data["deviceModel"], 99 | "sw_version": self.coordinator.data["appVersionName"], 100 | "configuration_url": f"http://{self.coordinator.data['ip4']}:2323", 101 | } 102 | 103 | @property 104 | def state(self): 105 | """Return the state of the sensor.""" 106 | if not self.coordinator.data: 107 | return None 108 | 109 | if self._sensor in STORAGE_SENSORS: 110 | return round(self.coordinator.data[self._sensor] * 0.000001, 1) 111 | 112 | return self.coordinator.data.get(self._sensor) 113 | 114 | async def async_added_to_hass(self): 115 | """Connect to dispatcher listening for entity data notifications.""" 116 | self.async_on_remove( 117 | self.coordinator.async_add_listener(self.async_write_ha_state) 118 | ) 119 | 120 | async def async_update(self): 121 | """Update Fully Kiosk Browser entity.""" 122 | await self.coordinator.async_request_refresh() 123 | -------------------------------------------------------------------------------- /custom_components/fullykiosk/switch.py: -------------------------------------------------------------------------------- 1 | """Fully Kiosk Browser switch.""" 2 | import logging 3 | 4 | from homeassistant.components.switch import SwitchEntity 5 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 6 | 7 | from .const import DOMAIN 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | 12 | async def async_setup_entry(hass, config_entry, async_add_entities): 13 | """Set up the Fully Kiosk Browser switch.""" 14 | coordinator = hass.data[DOMAIN][config_entry.entry_id] 15 | 16 | async_add_entities([FullyScreenSaverSwitch(hass, coordinator)], False) 17 | async_add_entities([FullyMaintenanceModeSwitch(hass, coordinator)], False) 18 | async_add_entities([FullyKioskLockSwitch(hass, coordinator)], False) 19 | async_add_entities([FullyKioskMotionDetectionSwitch(hass, coordinator)], False) 20 | 21 | 22 | class FullySwitch(CoordinatorEntity, SwitchEntity): 23 | """Representation of a generic Fully Kiosk Browser switch entity.""" 24 | 25 | def __init__(self, hass, coordinator): 26 | """Initialize the switch.""" 27 | self.coordinator = coordinator 28 | self.hass = hass 29 | 30 | self._name = "" 31 | self._unique_id = "" 32 | 33 | @property 34 | def name(self): 35 | """Return the name of the switch.""" 36 | return self._name 37 | 38 | @property 39 | def device_info(self): 40 | """Return the device info.""" 41 | return { 42 | "identifiers": {(DOMAIN, self.coordinator.data["deviceID"])}, 43 | "name": self.coordinator.data["deviceName"], 44 | "manufacturer": self.coordinator.data["deviceManufacturer"], 45 | "model": self.coordinator.data["deviceModel"], 46 | "sw_version": self.coordinator.data["appVersionName"], 47 | "configuration_url": f"http://{self.coordinator.data['ip4']}:2323", 48 | } 49 | 50 | @property 51 | def unique_id(self): 52 | """Return the unique id.""" 53 | return self._unique_id 54 | 55 | async def async_added_to_hass(self): 56 | """Connect to dispatcher listening for entity data notifications.""" 57 | self.async_on_remove( 58 | self.coordinator.async_add_listener(self.async_write_ha_state) 59 | ) 60 | 61 | async def async_update(self): 62 | """Update Fully Kiosk Browser entity.""" 63 | await self.coordinator.async_request_refresh() 64 | 65 | 66 | class FullyScreenSaverSwitch(FullySwitch): 67 | """Representation of a Fully Kiosk Browser screensaver switch.""" 68 | 69 | def __init__(self, hass, coordinator): 70 | """Initialize the screensaver switch.""" 71 | super().__init__(hass, coordinator) 72 | self._name = f"{coordinator.data['deviceName']} Screensaver" 73 | self._unique_id = f"{coordinator.data['deviceID']}-screensaver" 74 | 75 | @property 76 | def is_on(self): 77 | """Return if switch is on.""" 78 | if self.coordinator.data: 79 | if self.coordinator.data["appVersionCode"] < 784: 80 | return self.coordinator.data["currentFragment"] == "screensaver" 81 | return self.coordinator.data["isInScreensaver"] 82 | 83 | async def async_turn_on(self, **kwargs): 84 | """Turn on the screensaver.""" 85 | await self.coordinator.fully.startScreensaver() 86 | await self.coordinator.async_refresh() 87 | 88 | async def async_turn_off(self, **kwargs): 89 | """Turn off the screensaver.""" 90 | await self.coordinator.fully.stopScreensaver() 91 | await self.coordinator.async_refresh() 92 | 93 | 94 | class FullyMaintenanceModeSwitch(FullySwitch): 95 | """Representation of a Fully Kiosk Browser maintenance mode switch.""" 96 | 97 | def __init__(self, hass, coordinator): 98 | """Initialize the maintenance mode switch.""" 99 | super().__init__(hass, coordinator) 100 | self._name = f"{coordinator.data['deviceName']} Maintenance Mode" 101 | self._unique_id = f"{coordinator.data['deviceID']}-maintenance" 102 | 103 | @property 104 | def is_on(self): 105 | """Return if maintenance mode is on.""" 106 | return self.coordinator.data["maintenanceMode"] 107 | 108 | async def async_turn_on(self, **kwargs): 109 | """Turn on maintenance mode.""" 110 | await self.coordinator.fully.enableLockedMode() 111 | await self.coordinator.async_refresh() 112 | 113 | async def async_turn_off(self, **kwargs): 114 | """Turn off maintenance mode.""" 115 | await self.coordinator.fully.disableLockedMode() 116 | await self.coordinator.async_refresh() 117 | 118 | 119 | class FullyKioskLockSwitch(FullySwitch): 120 | """Representation of a Fully Kiosk Browser kiosk lock switch.""" 121 | 122 | def __init__(self, hass, coordinator): 123 | """Intialize the kiosk lock switch.""" 124 | super().__init__(hass, coordinator) 125 | self._name = f"{coordinator.data['deviceName']} Kiosk Lock" 126 | self._unique_id = f"{coordinator.data['deviceID']}-kiosk" 127 | 128 | @property 129 | def is_on(self): 130 | """Return if kiosk lock is on.""" 131 | return self.coordinator.data["kioskLocked"] 132 | 133 | async def async_turn_on(self, **kwargs): 134 | """Turn on kiosk lock.""" 135 | await self.coordinator.fully.lockKiosk() 136 | await self.coordinator.async_refresh() 137 | 138 | async def async_turn_off(self, **kwargs): 139 | """Turn off kiosk lock.""" 140 | await self.coordinator.fully.unlockKiosk() 141 | await self.coordinator.async_refresh() 142 | 143 | 144 | class FullyKioskMotionDetectionSwitch(FullySwitch): 145 | """Representation of a Fully Kiosk Browser kiosk lock switch.""" 146 | 147 | def __init__(self, hass, coordinator): 148 | """Intialize the kiosk lock switch.""" 149 | super().__init__(hass, coordinator) 150 | self._name = f"{coordinator.data['deviceName']} Motion Detection" 151 | self._unique_id = f"{coordinator.data['deviceID']}-motion-detection" 152 | 153 | @property 154 | def is_on(self): 155 | """Return if motion detection is on.""" 156 | return self.coordinator.data["settings"]["motionDetection"] 157 | 158 | async def async_turn_on(self, **kwargs): 159 | """Turn on kiosk lock.""" 160 | await self.coordinator.fully.enableMotionDetection() 161 | await self.coordinator.async_refresh() 162 | 163 | async def async_turn_off(self, **kwargs): 164 | """Turn off kiosk lock.""" 165 | await self.coordinator.fully.disableMotionDetection() 166 | await self.coordinator.async_refresh() 167 | -------------------------------------------------------------------------------- /custom_components/fullykiosk/services.yaml: -------------------------------------------------------------------------------- 1 | # Describes the format for available fullykiosk services 2 | 3 | load_start_url: 4 | name: Load Start URL (deprecated) 5 | description: Load Fully Kiosk Browser start URL. ***This service is deprecated and will be removed in a later update. Use the corresponding button entity instead*** 6 | fields: 7 | entity_id: 8 | description: Name(s) of entities to load the start URL on. 9 | example: "media_player.amazon_fire_media_player" 10 | name: Entity 11 | required: true 12 | selector: 13 | entity: 14 | integration: fullykiosk 15 | domain: media_player 16 | 17 | load_url: 18 | name: Load URL 19 | description: Load URL on Fully Kiosk Browser. 20 | fields: 21 | entity_id: 22 | description: Name(s) of entities to load the start URL on. 23 | example: "media_player.amazon_fire_media_player" 24 | name: Entity 25 | required: true 26 | selector: 27 | entity: 28 | integration: fullykiosk 29 | domain: media_player 30 | url: 31 | description: URL to be loaded. 32 | example: "https://www.fully-kiosk.com" 33 | name: URL 34 | required: true 35 | selector: 36 | text: 37 | 38 | play_audio: 39 | name: Play Audio 40 | description: Play audio on a Fully Kiosk Browser. 41 | fields: 42 | entity_id: 43 | description: Name(s) of entities to play media on 44 | example: "media_player.amazon_fire_media_player" 45 | name: Entity 46 | required: true 47 | selector: 48 | entity: 49 | integration: fullykiosk 50 | domain: media_player 51 | url: 52 | description: The URL of the audio to play. 53 | example: "https://home-assistant.io/audio/test.mp3" 54 | name: URL 55 | required: true 56 | selector: 57 | text: 58 | stream: 59 | name: Stream 60 | description: Stream on which to play the audio 61 | example: 3 62 | selector: 63 | number: 64 | min: 0 65 | max: 10 66 | mode: box 67 | 68 | reboot_device: 69 | name: Reboot Device (deprecated) 70 | description: Reboot the device running the Fully Kiosk Browser app. ***This service is deprecated and will be removed in a later update. Use the corresponding button entity instead*** 71 | fields: 72 | entity_id: 73 | description: Name(s) of entities to reboot. 74 | example: "media_player.amazon_fire_media_player" 75 | name: Entity 76 | required: true 77 | selector: 78 | entity: 79 | integration: fullykiosk 80 | domain: media_player 81 | 82 | restart: 83 | name: Restart (deprecated) 84 | description: Restart the Fully Kiosk Browser app. ***This service is deprecated and will be removed in a later update. Use the corresponding button entity instead*** 85 | fields: 86 | entity_id: 87 | description: Name(s) of entities to restart the app on. 88 | example: "media_player.amazon_fire_media_player" 89 | name: Entity 90 | required: true 91 | selector: 92 | entity: 93 | integration: fullykiosk 94 | domain: media_player 95 | 96 | set_config: 97 | name: Set Config 98 | description: Modify a setting in Fully Kiosk Browser. 99 | fields: 100 | entity_id: 101 | description: Name(s) of entities to modify the setting on. 102 | example: "media_player.amazon_fire_media_player" 103 | name: Entity 104 | required: true 105 | selector: 106 | entity: 107 | integration: fullykiosk 108 | domain: media_player 109 | config_type: 110 | description: Type of setting (either 'string' or 'bool') 111 | example: "string" 112 | key: 113 | description: Key of the setting to change 114 | example: "startURL" 115 | name: Key 116 | required: true 117 | selector: 118 | text: 119 | value: 120 | description: New value of the setting 121 | example: "https://www.fully-kiosk.com" 122 | name: Value 123 | required: true 124 | selector: 125 | text: 126 | 127 | start_application: 128 | name: Start Application 129 | description: Start an application on the device. 130 | fields: 131 | entity_id: 132 | description: Name(s) of entities to start the app on. 133 | example: "media_player.amazon_fire_media_player" 134 | name: Entity 135 | required: true 136 | selector: 137 | entity: 138 | integration: fullykiosk 139 | domain: media_player 140 | application: 141 | description: Package name of the application to start. 142 | example: "de.ozerov.fully" 143 | name: Application 144 | required: true 145 | selector: 146 | text: 147 | 148 | to_foreground: 149 | name: To Foreground (deprecated) 150 | description: Bring the Fully Kiosk Browser app to the foreground. ***This service is deprecated and will be removed in a later update. Use the corresponding button entity instead*** 151 | fields: 152 | entity_id: 153 | description: Name(s) of entities on which to bring the app to the foreground. 154 | example: "media_player.amazon_fire_media_player" 155 | name: Entity 156 | required: true 157 | selector: 158 | entity: 159 | integration: fullykiosk 160 | domain: media_player 161 | 162 | to_background: 163 | name: To Background (deprecated) 164 | description: Bring the Fully Kiosk Browser app to the background. ***This service is deprecated and will be removed in a later update. Use the corresponding button entity instead*** 165 | fields: 166 | entity_id: 167 | description: Name(s) of entities on which to bring the app to the background. 168 | example: "media_player.amazon_fire_media_player" 169 | name: Entity 170 | required: true 171 | selector: 172 | entity: 173 | integration: fullykiosk 174 | domain: media_player 175 | 176 | volume_set: 177 | name: Set Volume 178 | description: Set a Fully Kiosk Browser volume level. 179 | fields: 180 | entity_id: 181 | description: Name(s) of entities to set volume level on. 182 | example: "media_player.amazon_fire_media_player" 183 | name: Entity 184 | required: true 185 | selector: 186 | entity: 187 | integration: fullykiosk 188 | domain: media_player 189 | volume_level: 190 | name: Volume Level 191 | description: Volume level to set as float. 192 | example: 0.6 193 | selector: 194 | number: 195 | min: 0 196 | max: 1 197 | step: .1 198 | mode: slider 199 | stream: 200 | name: Stream 201 | description: Stream for which to set the volume level (1-10) 202 | example: 3 203 | selector: 204 | number: 205 | min: 0 206 | max: 10 207 | mode: box 208 | -------------------------------------------------------------------------------- /custom_components/fullykiosk/media_player.py: -------------------------------------------------------------------------------- 1 | """Fully Kiosk Browser media_player entity.""" 2 | import logging 3 | 4 | import voluptuous as vol 5 | from homeassistant.components import media_source 6 | from homeassistant.components.media_player import ( 7 | ATTR_MEDIA_VOLUME_LEVEL, 8 | SERVICE_VOLUME_SET, 9 | SUPPORT_PLAY_MEDIA, 10 | SUPPORT_VOLUME_SET, 11 | MediaPlayerEntity, 12 | ) 13 | from homeassistant.components.media_player.browse_media import ( 14 | async_process_play_media_url, 15 | ) 16 | from homeassistant.helpers import config_validation as cv 17 | from homeassistant.helpers import entity_platform 18 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 19 | 20 | from .const import ( 21 | ATTR_APPLICATION, 22 | ATTR_CONFIG_TYPE, 23 | ATTR_KEY, 24 | ATTR_STREAM, 25 | ATTR_URL, 26 | ATTR_VALUE, 27 | AUDIOMANAGER_STREAM_MUSIC, 28 | DOMAIN, 29 | SERVICE_LOAD_START_URL, 30 | SERVICE_LOAD_URL, 31 | SERVICE_PLAY_AUDIO, 32 | SERVICE_REBOOT_DEVICE, 33 | SERVICE_RESTART_APP, 34 | SERVICE_SET_CONFIG, 35 | SERVICE_START_APPLICATION, 36 | SERVICE_TO_BACKGROUND, 37 | SERVICE_TO_FOREGROUND, 38 | ) 39 | 40 | SUPPORT_FULLYKIOSK = SUPPORT_PLAY_MEDIA | SUPPORT_VOLUME_SET 41 | 42 | 43 | _LOGGER = logging.getLogger(__name__) 44 | 45 | 46 | async def async_setup_entry(hass, config_entry, async_add_entities): 47 | """Set up the Fully Kiosk Browser media player.""" 48 | coordinator = hass.data[DOMAIN][config_entry.entry_id] 49 | platform = entity_platform.current_platform.get() 50 | 51 | platform.async_register_entity_service( 52 | SERVICE_LOAD_START_URL, {}, "async_fullykiosk_load_start_url" 53 | ) 54 | 55 | platform.async_register_entity_service( 56 | SERVICE_LOAD_URL, 57 | {vol.Required(ATTR_URL): cv.string}, 58 | "async_fullykiosk_load_url", 59 | ) 60 | 61 | platform.async_register_entity_service( 62 | SERVICE_PLAY_AUDIO, 63 | { 64 | vol.Required(ATTR_URL): cv.string, 65 | vol.Required(ATTR_STREAM): vol.All(vol.Number(scale=0), vol.Range(1, 10)), 66 | }, 67 | "async_fullykiosk_play_audio", 68 | ) 69 | 70 | platform.async_register_entity_service( 71 | SERVICE_REBOOT_DEVICE, {}, "async_fullykiosk_reboot_device" 72 | ) 73 | 74 | platform.async_register_entity_service( 75 | SERVICE_RESTART_APP, {}, "async_fullykiosk_restart" 76 | ) 77 | 78 | platform.async_register_entity_service( 79 | SERVICE_SET_CONFIG, 80 | { 81 | vol.Required(ATTR_CONFIG_TYPE): vol.In(["string", "bool"]), 82 | vol.Required(ATTR_KEY): cv.string, 83 | vol.Required(ATTR_VALUE): vol.Any(cv.string, cv.boolean), 84 | }, 85 | "async_fullykiosk_set_config", 86 | ) 87 | 88 | platform.async_register_entity_service( 89 | SERVICE_VOLUME_SET, 90 | { 91 | vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float, 92 | vol.Required(ATTR_STREAM): vol.All(vol.Number(scale=0), vol.Range(1, 10)), 93 | }, 94 | "async_fullykiosk_set_volume_level", 95 | ) 96 | 97 | platform.async_register_entity_service( 98 | SERVICE_START_APPLICATION, 99 | {vol.Required(ATTR_APPLICATION): cv.string}, 100 | "async_fullykiosk_start_app", 101 | ) 102 | 103 | platform.async_register_entity_service( 104 | SERVICE_TO_FOREGROUND, {}, "async_fullykiosk_to_foreground" 105 | ) 106 | 107 | platform.async_register_entity_service( 108 | SERVICE_TO_BACKGROUND, {}, "async_fullykiosk_to_background" 109 | ) 110 | 111 | async_add_entities([FullyMediaPlayer(coordinator)], False) 112 | 113 | 114 | class FullyMediaPlayer(CoordinatorEntity, MediaPlayerEntity): 115 | """Representation of a Fully Kiosk Browser media player.""" 116 | 117 | def __init__(self, coordinator): 118 | """Initialize a Fully Kiosk Browser media player.""" 119 | self._name = f"{coordinator.data['deviceName']} Media Player" 120 | self.coordinator = coordinator 121 | self._unique_id = f"{coordinator.data['deviceID']}-mediaplayer" 122 | 123 | @property 124 | def name(self): 125 | """Return the name of the media player.""" 126 | return self._name 127 | 128 | @property 129 | def supported_features(self): 130 | """Return the supported features.""" 131 | return SUPPORT_FULLYKIOSK 132 | 133 | @property 134 | def device_info(self): 135 | """Return the device info.""" 136 | return { 137 | "identifiers": {(DOMAIN, self.coordinator.data["deviceID"])}, 138 | "name": self.coordinator.data["deviceName"], 139 | "manufacturer": self.coordinator.data["deviceManufacturer"], 140 | "model": self.coordinator.data["deviceModel"], 141 | "sw_version": self.coordinator.data["appVersionName"], 142 | "configuration_url": f"http://{self.coordinator.data['ip4']}:2323", 143 | } 144 | 145 | @property 146 | def unique_id(self): 147 | """Return the unique id.""" 148 | return self._unique_id 149 | 150 | async def async_play_media(self, media_type, media_id, **kwargs): 151 | """Play a piece of media.""" 152 | if media_source.is_media_source_id(media_id): 153 | play_item = await media_source.async_resolve_media(self.hass, media_id) 154 | media_id = async_process_play_media_url(self.hass, play_item.url) 155 | 156 | await self.async_fullykiosk_play_audio(media_id, AUDIOMANAGER_STREAM_MUSIC) 157 | 158 | async def async_set_volume_level(self, volume): 159 | """Set volume level, range 0..1.""" 160 | await self.async_fullykiosk_set_volume_level(volume, AUDIOMANAGER_STREAM_MUSIC) 161 | 162 | async def async_fullykiosk_load_start_url(self): 163 | """Load the start URL on a fullykiosk browser.""" 164 | _LOGGER.warning( 165 | "The fullykiosk.load_start_url service is deprecated and will be removed in a future update. Use the corresponding button entity instead." 166 | ) 167 | await self.coordinator.fully.loadStartUrl() 168 | await self.coordinator.async_refresh() 169 | 170 | async def async_fullykiosk_load_url(self, url): 171 | """Load URL on a fullykiosk browser.""" 172 | await self.coordinator.fully.loadUrl(url) 173 | await self.coordinator.async_refresh() 174 | 175 | async def async_fullykiosk_play_audio(self, url, stream): 176 | """Play a piece of audio on a specific stream.""" 177 | await self.coordinator.fully.playSound(url, stream) 178 | 179 | async def async_fullykiosk_reboot_device(self): 180 | """Reboot the device running the fullykiosk browser app.""" 181 | _LOGGER.warning( 182 | "The fullykiosk.reboot_device service is deprecated and will be removed in a future update. Use the corresponding button entity instead." 183 | ) 184 | await self.coordinator.fully.rebootDevice() 185 | 186 | async def async_fullykiosk_restart(self): 187 | """Restart the fullykiosk browser app.""" 188 | _LOGGER.warning( 189 | "The fullykiosk.restart service is deprecated and will be removed in a future update. Use the corresponding button entity instead." 190 | ) 191 | await self.coordinator.fully.restartApp() 192 | 193 | async def async_fullykiosk_set_config(self, config_type, key, value): 194 | """Set fullykiosk configuration value.""" 195 | if config_type == "string": 196 | await self.coordinator.fully.setConfigurationString(key, value) 197 | elif config_type == "bool": 198 | await self.coordinator.fully.setConfigurationBool(key, value) 199 | 200 | async def async_fullykiosk_set_volume_level(self, volume_level, stream): 201 | """Set volume level for a stream, range 0..1.""" 202 | await self.coordinator.fully.setAudioVolume(int(volume_level * 100), stream) 203 | 204 | async def async_fullykiosk_start_app(self, application): 205 | """Start an application on the device running the fullykiosk browser app.""" 206 | await self.coordinator.fully.startApplication(application) 207 | await self.coordinator.async_refresh() 208 | 209 | async def async_fullykiosk_to_background(self): 210 | """Bring the fullykiosk browser app back to the background.""" 211 | _LOGGER.warning( 212 | "The fullykiosk.to_background service is deprecated and will be removed in a future update. Use the corresponding button entity instead." 213 | ) 214 | await self.coordinator.fully.toBackground() 215 | await self.coordinator.async_refresh() 216 | 217 | async def async_fullykiosk_to_foreground(self): 218 | """Bring the fullykiosk browser app back to the foreground.""" 219 | _LOGGER.warning( 220 | "The fullykiosk.to_foreground service is deprecated and will be removed in a future update. Use the corresponding button entity instead." 221 | ) 222 | await self.coordinator.fully.toForeground() 223 | await self.coordinator.async_refresh() 224 | 225 | async def async_added_to_hass(self): 226 | """Connect to dispatcher listening for entity data notifications.""" 227 | self.async_on_remove( 228 | self.coordinator.async_add_listener(self.async_write_ha_state) 229 | ) 230 | 231 | async def async_update(self): 232 | """Update Fully Kiosk Browser entity.""" 233 | await self.coordinator.async_request_refresh() 234 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------