├── .devcontainer └── devcontainer.json ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── issue.md ├── dependabot.yml └── workflows │ ├── cron.yaml │ ├── pull.yml │ └── push.yml ├── .gitignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── LICENSE ├── README.md ├── custom_components ├── __init__.py └── qr_generator │ ├── __init__.py │ ├── config_flow.py │ ├── const.py │ ├── icons.json │ ├── image.py │ ├── manifest.json │ ├── services.py │ ├── services.yaml │ ├── strings.json │ └── translations │ ├── en.json │ └── sk.json ├── hacs.json ├── requirements_dev.txt ├── requirements_test.txt ├── setup.cfg └── tests ├── __init__.py ├── conftest.py ├── test_config_flow.py └── test_image.py /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // See https://aka.ms/vscode-remote/devcontainer.json for format details. 2 | { 3 | "image": "python:3.13", 4 | "name": "QR-Code Generator integration development", 5 | "context": "..", 6 | "appPort": [ 7 | "9123:8123" 8 | ], 9 | "extensions": [ 10 | "ms-python.python", 11 | "github.vscode-pull-request-github", 12 | "ryanluker.vscode-coverage-gutters", 13 | "ms-python.vscode-pylance" 14 | ], 15 | "settings": { 16 | "files.eol": "\n", 17 | "editor.tabSize": 4, 18 | "terminal.integrated.shell.linux": "/bin/bash", 19 | "python.pythonPath": "/usr/bin/python3", 20 | "python.analysis.autoSearchPaths": false, 21 | "python.linting.pylintEnabled": true, 22 | "python.linting.enabled": true, 23 | "python.formatting.provider": "black", 24 | "editor.formatOnPaste": false, 25 | "editor.formatOnSave": true, 26 | "editor.formatOnType": true, 27 | "files.trimTrailingWhitespace": true 28 | } 29 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | 16 | 17 | ## Version of the custom_component 18 | 21 | 22 | ## Describe the bug 23 | A clear and concise description of what the bug is. 24 | 25 | 26 | ## Debug log 27 | 28 | 29 | 30 | ```text 31 | 32 | Add your logs here. 33 | 34 | ``` -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: pip 8 | directory: "/" 9 | schedule: 10 | interval: daily -------------------------------------------------------------------------------- /.github/workflows/cron.yaml: -------------------------------------------------------------------------------- 1 | name: Cron actions 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | 7 | jobs: 8 | validate: 9 | runs-on: "ubuntu-latest" 10 | name: Validate 11 | steps: 12 | - uses: "actions/checkout@v4" 13 | 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | ignore: brands 19 | 20 | - name: Hassfest validation 21 | uses: "home-assistant/actions/hassfest@master" -------------------------------------------------------------------------------- /.github/workflows/pull.yml: -------------------------------------------------------------------------------- 1 | name: Pull actions 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | validate: 8 | runs-on: "ubuntu-latest" 9 | name: Validate 10 | steps: 11 | - uses: "actions/checkout@v4" 12 | 13 | - name: HACS validation 14 | uses: "hacs/action@main" 15 | with: 16 | category: "integration" 17 | ignore: brands 18 | 19 | - name: Hassfest validation 20 | uses: "home-assistant/actions/hassfest@master" 21 | 22 | style: 23 | runs-on: "ubuntu-latest" 24 | name: Check style formatting 25 | steps: 26 | - uses: "actions/checkout@v4" 27 | - uses: "actions/setup-python@v5" 28 | with: 29 | python-version: "3.x" 30 | - run: python3 -m pip install black 31 | - run: black . 32 | 33 | tests: 34 | runs-on: "ubuntu-latest" 35 | name: Run tests 36 | steps: 37 | - name: Check out code from GitHub 38 | uses: "actions/checkout@v4" 39 | - name: Setup Python 40 | uses: "actions/setup-python@v5" 41 | with: 42 | python-version: "3.13" 43 | - name: Install requirements 44 | run: python3 -m pip install -r requirements_test.txt 45 | - name: Run tests 46 | run: | 47 | pytest \ 48 | -qq \ 49 | --timeout=9 \ 50 | --durations=10 \ 51 | --asyncio-mode=auto \ 52 | -n auto \ 53 | --cov custom_components.qr_generator \ 54 | -o console_output_style=count \ 55 | -p no:sugar \ 56 | tests 57 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Push actions 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - dev 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | name: Validate 13 | steps: 14 | - uses: "actions/checkout@v4" 15 | 16 | - name: HACS validation 17 | uses: "hacs/action@main" 18 | with: 19 | category: "integration" 20 | ignore: brands 21 | 22 | - name: Hassfest validation 23 | uses: "home-assistant/actions/hassfest@master" 24 | 25 | style: 26 | runs-on: "ubuntu-latest" 27 | name: Check style formatting 28 | steps: 29 | - uses: "actions/checkout@v4" 30 | - uses: "actions/setup-python@v5" 31 | with: 32 | python-version: "3.x" 33 | - run: python3 -m pip install black 34 | - run: black . 35 | 36 | tests: 37 | runs-on: "ubuntu-latest" 38 | name: Run tests 39 | steps: 40 | - name: Check out code from GitHub 41 | uses: "actions/checkout@v4" 42 | - name: Setup Python 43 | uses: "actions/setup-python@v5" 44 | with: 45 | python-version: "3.13" 46 | - name: Install requirements 47 | run: python3 -m pip install -r requirements_test.txt 48 | - name: Run tests 49 | run: | 50 | pytest \ 51 | -qq \ 52 | --timeout=9 \ 53 | --durations=10 \ 54 | --asyncio-mode=auto \ 55 | -n auto \ 56 | --cov custom_components.qr_generator \ 57 | -o console_output_style=count \ 58 | -p no:sugar \ 59 | tests -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | pythonenv* 3 | venv 4 | .venv 5 | .coverage 6 | .idea 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | // Example of attaching to local debug server 7 | "name": "Python: Attach Local", 8 | "type": "python", 9 | "request": "attach", 10 | "port": 5678, 11 | "host": "localhost", 12 | "pathMappings": [ 13 | { 14 | "localRoot": "${workspaceFolder}", 15 | "remoteRoot": "." 16 | } 17 | ] 18 | }, 19 | { 20 | // Example of attaching to my production server 21 | "name": "Python: Attach Remote", 22 | "type": "python", 23 | "request": "attach", 24 | "port": 5678, 25 | "host": "homeassistant.local", 26 | "pathMappings": [ 27 | { 28 | "localRoot": "${workspaceFolder}", 29 | "remoteRoot": "/usr/src/homeassistant" 30 | } 31 | ] 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.pylintEnabled": true, 3 | "python.linting.enabled": true, 4 | "python.pythonPath": "/usr/local/bin/python", 5 | "files.associations": { 6 | "*.yaml": "home-assistant" 7 | } 8 | } -------------------------------------------------------------------------------- /.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 specific version of Home Assistant", 24 | "type": "shell", 25 | "command": "container set-version", 26 | "problemMatcher": [] 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Maximilian Maier 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Home Assistant integration to create a QR-Code 2 | [![GitHub Release][releases-shield]][releases] 3 | [![hacs][hacsbadge]][hacs] 4 | 5 | This integration allows to create QR Codes with static or dynamic content. 6 | 7 | ## Features 8 | * Static content 9 | * Dynamic content with the help of Templates 10 | * Custom QR-Code color 11 | * Custom QR-Code background color 12 | * Error correction adjustment 13 | 14 | {% if not installed %} 15 | ## Installation 16 | 17 | The easiest way to install is through HACS. This integration is already included in the HACS default repositories. 18 | 19 | 1. In Home Assistant, select HACS -> Integrations -> + Explore and Download Repositories. Search for QR-Code Generator in the list and add it. 20 | 2. Restart Home Assistant 21 | 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=qr_generator) 22 | 23 | ## Manual Installation 24 | 25 | Copy the `custom_components/qr_generator` directory to your `custom_components` folder. Restart Home Assistant, and add the integration from the integrations page. 26 | 27 | {% endif %} 28 | 29 | ## Example configuration 30 | 31 | ### For wifi access 32 | 33 | Name: `My wifi access`
34 | Content: `WIFI:T:WPA2;S:MyNetworkName;P:ThisIsMyPassword;H:true;` 35 | 36 | ### For wifi access with template 37 | 38 | Name: `My wifi access`
39 | Content: `WIFI:T:WPA2;S:MyNetworkName;P:{{states("input_text.wlan_password")}};H:true;` 40 | 41 | > To update the QR code, simply change the value of `input_text.wlan_password`. 42 | 43 | ## Details 44 | 45 | ### Options 46 | 47 | | Options | Description | Default 48 | | ------------ | -------------------------------------- | -------- | 49 | | `Name` | *(str)* Name of the QR Code. | - | 50 | | `Content` | *(str)* Content of the QR Code. Can be a template. | - | 51 | | `Color` | *(str)* Color of the QR Code in hex. Supports transparency (#RRGGBBAA). | #000 | 52 | | `Background color` | *(str)* Color of the background in hex. Supports transparency (#RRGGBBAA). | #FFF | 53 | | `Scale` | *(int)* Scale of the QR Code. | 10 | 54 | | `Border` | *(int)* Thickness of the QR Code border in "QR Code boxes". | 2 | 55 | | `Error correction` | *(str)* Strength of error correction. Possible values:
L - About 7% or less errors can be corrected.
M - About 15% or less errors can be corrected.
Q - About 25% or less errors can be corrected.
H - About 30% or less errors can be corrected. | H | 56 | 57 | ### ATTRIBUTES 58 | 59 | | Attribute | Description | 60 | | ------------ | -------------------------------------- | 61 | | `text` | *(str)* Rendered content of the QR Code. | 62 | | `color` | *(str)* Color of the QR Code in hex. | 63 | | `background_color` | *(str)* Color of the background in hex.| 64 | | `scale` | *(int)* Scale of the QR Code. | 65 | | `border` | *(int)* Thickness of the QR Code border in "QR Code boxes". | 66 | | `error_correction` | *(str)* Strength of error correction. Possible values:
L
M
Q
H | 67 | 68 | [hacs]: https://hacs.xyz 69 | [hacsbadge]: https://img.shields.io/badge/HACS-Default-41BDF5.svg?style=for-the-badge 70 | [releases-shield]: https://img.shields.io/github/v/release/DeerMaximum/QR-Code-Generator.svg?style=for-the-badge 71 | [releases]: https://github.com/DeerMaximum/QR-Code-Generator/releases 72 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """Custom components module.""" 2 | -------------------------------------------------------------------------------- /custom_components/qr_generator/__init__.py: -------------------------------------------------------------------------------- 1 | """The QR Generator integration.""" 2 | from types import MappingProxyType 3 | 4 | from homeassistant.config_entries import ConfigEntry 5 | from homeassistant.const import Platform 6 | from homeassistant.core import HomeAssistant 7 | 8 | from .const import DOMAIN 9 | from .services import register_services 10 | 11 | PLATFORMS: list[str] = [Platform.IMAGE] 12 | 13 | 14 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 15 | """Set up platform from a ConfigEntry.""" 16 | if hass.data.get(DOMAIN) is None: 17 | hass.data.setdefault(DOMAIN, {}) 18 | 19 | entry.async_on_unload(entry.add_update_listener(_async_update_listener)) 20 | 21 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 22 | 23 | register_services(hass) 24 | 25 | return True 26 | 27 | 28 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 29 | """Unload a config entry.""" 30 | return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 31 | 32 | 33 | async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: 34 | """Handle options update.""" 35 | hass.config_entries.async_update_entry(entry, data=entry.options) 36 | await hass.config_entries.async_reload(entry.entry_id) 37 | -------------------------------------------------------------------------------- /custom_components/qr_generator/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for the QR Generator integration.""" 2 | from __future__ import annotations 3 | 4 | import re 5 | from typing import Any 6 | 7 | import voluptuous as vol 8 | 9 | from homeassistant import config_entries 10 | from homeassistant.const import CONF_NAME, CONF_VALUE_TEMPLATE 11 | from homeassistant.core import callback 12 | from homeassistant.data_entry_flow import FlowResult 13 | from homeassistant.exceptions import TemplateError 14 | from homeassistant.helpers import config_validation as cv, template 15 | 16 | from .const import ( 17 | CONF_ADVANCED, 18 | CONF_BACKGROUND_COLOR, 19 | CONF_BORDER, 20 | CONF_COLOR, 21 | CONF_ERROR_CORRECTION, 22 | CONF_SCALE, 23 | DEFAULT_BACKGROUND_COLOR, 24 | DEFAULT_BORDER, 25 | DEFAULT_COLOR, 26 | DEFAULT_ERROR_CORRECTION, 27 | DEFAULT_SCALE, 28 | DOMAIN, 29 | ERROR_CORRECTION_LEVEL, 30 | HEX_COLOR_REGEX, 31 | ) 32 | 33 | 34 | def get_schema(config: dict[str, Any] | None = None) -> vol.Schema: 35 | """Generate the schema.""" 36 | if not config: 37 | config = {} 38 | 39 | default_name: str = config.get(CONF_NAME, "") 40 | default_value: str = config.get(CONF_VALUE_TEMPLATE, "") 41 | 42 | return vol.Schema( 43 | { 44 | vol.Required(CONF_NAME, default=default_name): cv.string, 45 | vol.Required(CONF_VALUE_TEMPLATE, default=default_value): cv.string, 46 | vol.Optional(CONF_ADVANCED, default=False): cv.boolean, 47 | } 48 | ) 49 | 50 | 51 | def get_schema_advanced(config: dict[str, Any] | None = None) -> vol.Schema: 52 | """Generate the schema for the advanced settings.""" 53 | if not config: 54 | config = {} 55 | 56 | default_color: str = config.get(CONF_COLOR, DEFAULT_COLOR) 57 | default_scale: int = config.get(CONF_SCALE, DEFAULT_SCALE) 58 | default_border: int = config.get(CONF_BORDER, DEFAULT_BORDER) 59 | default_error_correction: str = config.get( 60 | CONF_ERROR_CORRECTION, DEFAULT_ERROR_CORRECTION 61 | ) 62 | default_background_color: str = config.get( 63 | CONF_BACKGROUND_COLOR, DEFAULT_BACKGROUND_COLOR 64 | ) 65 | 66 | return vol.Schema( 67 | { 68 | vol.Required(CONF_COLOR, default=default_color): cv.string, 69 | vol.Required( 70 | CONF_BACKGROUND_COLOR, default=default_background_color 71 | ): cv.string, 72 | vol.Required(CONF_SCALE, default=default_scale): cv.positive_int, 73 | vol.Required(CONF_BORDER, default=default_border): cv.positive_int, 74 | vol.Required( 75 | CONF_ERROR_CORRECTION, default=default_error_correction 76 | ): vol.In(ERROR_CORRECTION_LEVEL), 77 | } 78 | ) 79 | 80 | 81 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 82 | """Handle a config flow for QR Generator.""" 83 | 84 | VERSION: int = 1 85 | 86 | override_config: dict[str, Any] = {} 87 | 88 | def __init__(self) -> None: 89 | """Initialize the config flow.""" 90 | super().__init__() 91 | 92 | self.config: dict[str, Any] = self.override_config 93 | 94 | async def async_step_user( 95 | self: ConfigFlow, 96 | user_input: dict[str, Any] | None = None, 97 | ) -> FlowResult: 98 | """Handle the initial step.""" 99 | errors: dict[str, Any] = {} 100 | 101 | if user_input is not None and not errors: 102 | try: 103 | template.Template( # type: ignore[no-untyped-call] 104 | user_input[CONF_VALUE_TEMPLATE], self.hass 105 | ).async_render() 106 | except TemplateError: 107 | errors["base"] = "invalid_template" 108 | return self.async_show_form( 109 | step_id="user", data_schema=get_schema(), errors=errors 110 | ) 111 | 112 | self.config = user_input 113 | 114 | if user_input[CONF_ADVANCED]: 115 | return await self.async_step_advanced() 116 | 117 | return self.async_create_entry( 118 | title=self.config[CONF_NAME], data=self.config 119 | ) 120 | 121 | return self.async_show_form( 122 | step_id="user", data_schema=get_schema(), errors=errors 123 | ) 124 | 125 | async def async_step_advanced( 126 | self, user_input: dict[str, Any] | None = None 127 | ) -> FlowResult: 128 | """Step for advanced settings.""" 129 | errors: dict[str, Any] = {} 130 | 131 | if user_input is not None and not errors: 132 | regex = re.compile(HEX_COLOR_REGEX) 133 | 134 | if regex.match(user_input[CONF_COLOR]) and regex.match( 135 | user_input[CONF_BACKGROUND_COLOR] 136 | ): 137 | self.config.update(user_input) 138 | 139 | return self.async_create_entry( 140 | title=self.config[CONF_NAME], data=self.config 141 | ) 142 | 143 | errors["base"] = "invalid_color" 144 | 145 | return self.async_show_form( 146 | step_id="advanced", data_schema=get_schema_advanced(), errors=errors 147 | ) 148 | 149 | @staticmethod 150 | @callback 151 | def async_get_options_flow( 152 | config_entry: config_entries.ConfigEntry, 153 | ) -> OptionsFlowHandler: 154 | """Get the options flow for this handler.""" 155 | return OptionsFlowHandler(config_entry) 156 | 157 | 158 | class OptionsFlowHandler(config_entries.OptionsFlow): 159 | """Handle a option flow for QR Generator.""" 160 | 161 | def __init__(self, config_entry: config_entries.ConfigEntry) -> None: 162 | """Initialize options flow.""" 163 | self.config_entry = config_entry 164 | self.data = dict(self.config_entry.data) 165 | 166 | async def async_step_init( 167 | self, user_input: dict[str, Any] | None = None 168 | ) -> FlowResult: 169 | """Handle options flow.""" 170 | errors: dict[str, Any] = {} 171 | 172 | if user_input is not None and not errors: 173 | 174 | try: 175 | template.Template( # type: ignore[no-untyped-call] 176 | user_input[CONF_VALUE_TEMPLATE], self.hass 177 | ).async_render() 178 | except TemplateError: 179 | errors["base"] = "invalid_template" 180 | return self.async_show_form( 181 | step_id="init", data_schema=get_schema(self.data), errors=errors 182 | ) 183 | 184 | self.data.update(user_input) 185 | 186 | if user_input[CONF_ADVANCED]: 187 | return await self.async_step_advanced() 188 | 189 | return self.async_create_entry(title="", data=self.data) 190 | 191 | return self.async_show_form( 192 | step_id="init", data_schema=get_schema(self.data), errors=errors 193 | ) 194 | 195 | async def async_step_advanced( 196 | self, user_input: dict[str, Any] | None = None 197 | ) -> FlowResult: 198 | """Step for advanced settings.""" 199 | errors: dict[str, Any] = {} 200 | 201 | if user_input is not None and not errors: 202 | regex = re.compile(HEX_COLOR_REGEX) 203 | 204 | if regex.match(user_input[CONF_COLOR]) and regex.match( 205 | user_input[CONF_BACKGROUND_COLOR] 206 | ): 207 | self.data.update(user_input) 208 | 209 | return self.async_create_entry(title="", data=self.data) 210 | 211 | errors["base"] = "invalid_color" 212 | 213 | return self.async_show_form( 214 | step_id="advanced", 215 | data_schema=get_schema_advanced(self.data), 216 | errors=errors, 217 | ) 218 | -------------------------------------------------------------------------------- /custom_components/qr_generator/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the QR Generator integration.""" 2 | from __future__ import annotations 3 | 4 | from logging import Logger, getLogger 5 | 6 | _LOGGER: Logger = getLogger(__package__) 7 | 8 | DOMAIN: str = "qr_generator" 9 | 10 | # Regex to mach color with the #RRGGBBAA format and it short forms 11 | HEX_COLOR_REGEX: str = ( 12 | r"^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4}|[A-Fa-f0-9]{8})$" 13 | ) 14 | 15 | ERROR_CORRECTION_LEVEL = ["L", "M", "Q", "H"] 16 | 17 | CONF_COLOR: str = "color" 18 | CONF_SCALE: str = "scale" 19 | CONF_BORDER: str = "border" 20 | CONF_ADVANCED: str = "advanced" 21 | CONF_ERROR_CORRECTION: str = "error_correction" 22 | CONF_BACKGROUND_COLOR: str = "background_color" 23 | 24 | DEFAULT_COLOR: str = "#000000" 25 | DEFAULT_SCALE: int = 10 26 | DEFAULT_BORDER: int = 2 27 | DEFAULT_ERROR_CORRECTION: str = "H" 28 | DEFAULT_BACKGROUND_COLOR: str = "#FFFFFF" 29 | 30 | ATTR_TEXT: str = "text" 31 | ATTR_COLOR: str = CONF_COLOR 32 | ATTR_SCALE: str = CONF_SCALE 33 | ATTR_BORDER: str = CONF_BORDER 34 | ATTR_ERROR_CORRECTION: str = CONF_ERROR_CORRECTION 35 | ATTR_BACKGROUND_COLOR: str = CONF_BACKGROUND_COLOR 36 | 37 | ATTR_FILENAME: str = "filename" 38 | -------------------------------------------------------------------------------- /custom_components/qr_generator/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "save": "mdi:content-save" 4 | } 5 | } -------------------------------------------------------------------------------- /custom_components/qr_generator/image.py: -------------------------------------------------------------------------------- 1 | """QR Generator image platform.""" 2 | 3 | from __future__ import annotations 4 | 5 | import io 6 | from typing import Any 7 | 8 | from PIL import ImageColor 9 | import pyqrcode 10 | 11 | from homeassistant.util import dt as dt_util 12 | from homeassistant.components.image import ImageEntity 13 | from homeassistant.config_entries import ConfigEntry 14 | from homeassistant.const import CONF_NAME, CONF_VALUE_TEMPLATE 15 | from homeassistant.core import HomeAssistant, callback, Event, EventStateChangedData 16 | from homeassistant.helpers import template 17 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 18 | from homeassistant.helpers.event import async_track_state_change_event 19 | 20 | from .const import ( 21 | _LOGGER, 22 | ATTR_BACKGROUND_COLOR, 23 | ATTR_BORDER, 24 | ATTR_COLOR, 25 | ATTR_ERROR_CORRECTION, 26 | ATTR_SCALE, 27 | ATTR_TEXT, 28 | CONF_BACKGROUND_COLOR, 29 | CONF_BORDER, 30 | CONF_COLOR, 31 | CONF_ERROR_CORRECTION, 32 | CONF_SCALE, 33 | DEFAULT_BACKGROUND_COLOR, 34 | DEFAULT_BORDER, 35 | DEFAULT_COLOR, 36 | DEFAULT_ERROR_CORRECTION, 37 | DEFAULT_SCALE, 38 | ) 39 | 40 | 41 | async def async_setup_entry( 42 | hass: HomeAssistant, 43 | config_entry: ConfigEntry, 44 | async_add_entities: AddEntitiesCallback, 45 | ) -> None: 46 | """Set up entries.""" 47 | 48 | entity = QRImage(config_entry, hass) 49 | 50 | async_add_entities([entity]) 51 | 52 | 53 | class QRImage(ImageEntity): 54 | """Representation of a QR code.""" 55 | 56 | def __init__(self, entry: ConfigEntry, hass: HomeAssistant) -> None: 57 | """Initialize the camera.""" 58 | super().__init__(hass) 59 | 60 | self.hass: HomeAssistant = hass 61 | 62 | self.image: io.BytesIO = io.BytesIO() 63 | 64 | self.value_template: str = entry.data[CONF_VALUE_TEMPLATE] 65 | self.template = template.Template(self.value_template, self.hass) # type: ignore[no-untyped-call] 66 | self.rendered_template: template.RenderInfo = template.RenderInfo(self.template) 67 | 68 | self.color_hex = entry.data.get(CONF_COLOR, DEFAULT_COLOR) 69 | self.color = ImageColor.getcolor(self.color_hex, "RGBA") 70 | 71 | self.background_color_hex = entry.data.get( 72 | CONF_BACKGROUND_COLOR, DEFAULT_BACKGROUND_COLOR 73 | ) 74 | self.background_color = ImageColor.getcolor(self.background_color_hex, "RGBA") 75 | 76 | self.scale: int = entry.data.get(CONF_SCALE, DEFAULT_SCALE) 77 | self.border: int = entry.data.get(CONF_BORDER, DEFAULT_BORDER) 78 | self.error_correction: str = entry.data.get( 79 | CONF_ERROR_CORRECTION, DEFAULT_ERROR_CORRECTION 80 | ) 81 | 82 | self._attr_name: str = entry.data[CONF_NAME] 83 | self._attr_unique_id: str = f"{entry.entry_id}-qr-code" 84 | self._attr_content_type: str = "image/png" 85 | 86 | def _render(self) -> None: 87 | """Render template.""" 88 | self.rendered_template = self.template.async_render_to_info() 89 | 90 | async def async_added_to_hass(self) -> None: 91 | """Register callbacks.""" 92 | 93 | self._render() 94 | await self._refresh() 95 | 96 | @callback 97 | async def _update(event: Event[EventStateChangedData]) -> None: 98 | """Handle state changes.""" 99 | old_state = event.data["old_state"] 100 | new_state = event.data["new_state"] 101 | 102 | if old_state is None or new_state is None: 103 | return 104 | 105 | if old_state.state == new_state.state: 106 | return 107 | 108 | self._render() 109 | await self._refresh() 110 | 111 | async_track_state_change_event( 112 | self.hass, list(self.rendered_template.entities), _update 113 | ) 114 | 115 | async def async_image(self) -> bytes | None: 116 | """Return bytes of image.""" 117 | return self.image.getvalue() 118 | 119 | async def _refresh(self) -> None: 120 | """Create the QR code.""" 121 | 122 | _LOGGER.debug('Print "%s" with: %s', self.name, self.rendered_template.result()) 123 | 124 | code = pyqrcode.create( 125 | self.rendered_template.result(), error=self.error_correction 126 | ) 127 | 128 | self.image = io.BytesIO() 129 | code.png( 130 | self.image, 131 | scale=self.scale, 132 | module_color=self.color, 133 | background=self.background_color, 134 | quiet_zone=self.border, 135 | ) 136 | 137 | self._attr_image_last_updated = dt_util.utcnow() 138 | self.async_write_ha_state() 139 | 140 | @property 141 | def extra_state_attributes(self) -> dict[str, Any]: 142 | """Return extra attributes of the sensor.""" 143 | return { 144 | ATTR_TEXT: self.rendered_template.result(), 145 | ATTR_COLOR: self.color_hex, 146 | ATTR_BACKGROUND_COLOR: self.background_color_hex, 147 | ATTR_SCALE: self.scale, 148 | ATTR_BORDER: self.border, 149 | ATTR_ERROR_CORRECTION: self.error_correction, 150 | } 151 | -------------------------------------------------------------------------------- /custom_components/qr_generator/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "qr_generator", 3 | "name": "QR-Code Generator", 4 | "codeowners": ["@DeerMaximum"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/DeerMaximum/QR-Code-Generator", 8 | "iot_class": "calculated", 9 | "issue_tracker" : "https://github.com/DeerMaximum/QR-Code-Generator/issues", 10 | "loggers": ["qr_generator"], 11 | "requirements": ["pyqrcode==1.2.1", "pypng==0.0.21"], 12 | "version" : "2.1.16" 13 | } 14 | -------------------------------------------------------------------------------- /custom_components/qr_generator/services.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from homeassistant.components.image import ImageEntity 4 | from homeassistant.const import ATTR_ENTITY_ID 5 | from homeassistant.core import HomeAssistant, ServiceCall 6 | from homeassistant.exceptions import HomeAssistantError 7 | from homeassistant.helpers.entity_platform import async_get_platforms 8 | 9 | from custom_components.qr_generator import DOMAIN 10 | import voluptuous as vol 11 | from homeassistant.helpers import config_validation as cv 12 | 13 | from custom_components.qr_generator.const import ATTR_FILENAME, _LOGGER 14 | 15 | 16 | def register_services(hass: HomeAssistant) -> None: 17 | """Register the services.""" 18 | 19 | async def save_image(service_call: ServiceCall) -> None: 20 | """Save the image to path.""" 21 | entity_id = service_call.data[ATTR_ENTITY_ID] 22 | filepath = service_call.data[ATTR_FILENAME] 23 | 24 | if not hass.config.is_allowed_path(filepath): 25 | raise HomeAssistantError( 26 | f"Cannot write `{filepath}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" 27 | ) 28 | 29 | platforms = async_get_platforms(hass, DOMAIN) 30 | 31 | if len(platforms) < 1: 32 | raise HomeAssistantError( 33 | f"Integration not found: {DOMAIN}" 34 | ) 35 | 36 | entity: ImageEntity | None = None 37 | 38 | for platform in platforms: 39 | entity_tmp: ImageEntity | None = platform.entities.get(entity_id, None) 40 | if entity_tmp is not None: 41 | entity = entity_tmp 42 | break 43 | 44 | if not entity: 45 | raise HomeAssistantError( 46 | f"Could not find entity {entity_id} from integration {DOMAIN}" 47 | ) 48 | 49 | image = await entity.async_image() 50 | 51 | def _write_image(to_file: str, image_data: bytes) -> None: 52 | """Executor helper to write image.""" 53 | os.makedirs(os.path.dirname(to_file), exist_ok=True) 54 | with open(to_file, "wb") as img_file: 55 | img_file.write(image_data) 56 | 57 | try: 58 | await hass.async_add_executor_job(_write_image, filepath, image) 59 | except OSError as err: 60 | _LOGGER.error("Can't write image to file: %s", err) 61 | 62 | hass.services.async_register(DOMAIN, "save", save_image, vol.Schema({ 63 | vol.Required(ATTR_ENTITY_ID) : cv.entity_id, 64 | vol.Required(ATTR_FILENAME): cv.string 65 | })) 66 | 67 | 68 | -------------------------------------------------------------------------------- /custom_components/qr_generator/services.yaml: -------------------------------------------------------------------------------- 1 | save: 2 | name: Save current image 3 | description: Save the current image to a path 4 | fields: 5 | entity_id: 6 | name: Entity 7 | description: Identifier of the image entity. 8 | example: "image.qr_code" 9 | required: true 10 | selector: 11 | entity: 12 | integration: qr_generator 13 | domain: image 14 | filename: 15 | required: true 16 | name: Filename 17 | description: Target filename. 18 | example: "/tmp/snapshot_{{ entity_id.name }}.mp4" 19 | selector: 20 | text: -------------------------------------------------------------------------------- /custom_components/qr_generator/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Create QR Code", 6 | "data": { 7 | "name": "Name", 8 | "value_template": "Content (text or template)", 9 | "advanced": "Advanced settings" 10 | } 11 | }, 12 | "advanced": { 13 | "title": "Advanced settings", 14 | "data": { 15 | "color": "Color", 16 | "scale": "Scale", 17 | "border": "Border", 18 | "error_correction": "Error correction:", 19 | "background_color": "Background color" 20 | } 21 | } 22 | }, 23 | "error": { 24 | "unknown": "[%key:common::config_flow::error::unknown%]", 25 | "invalid_template": "Invalid template", 26 | "invalid_color": "Invalid color" 27 | } 28 | }, 29 | "options": { 30 | "step": { 31 | "init": { 32 | "title": "Edit QR Code", 33 | "data": { 34 | "name": "Name", 35 | "value_template": "Content (text or template)", 36 | "advanced": "Advanced settings" 37 | } 38 | }, 39 | "advanced": { 40 | "title": "Advanced settings", 41 | "data": { 42 | "color": "Color", 43 | "scale": "Scale", 44 | "border": "Border", 45 | "error_correction": "Error correction:", 46 | "background_color": "Background color" 47 | } 48 | } 49 | }, 50 | "error": { 51 | "unknown": "[%key:common::config_flow::error::unknown%]", 52 | "invalid_template": "Invalid template", 53 | "invalid_color": "Invalid color" 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /custom_components/qr_generator/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "invalid_color": "Invalid color", 5 | "invalid_template": "Invalid template", 6 | "unknown": "Unexpected error" 7 | }, 8 | "step": { 9 | "advanced": { 10 | "data": { 11 | "background_color": "Background color", 12 | "border": "Border", 13 | "color": "Color", 14 | "error_correction": "Error correction:", 15 | "scale": "Scale" 16 | }, 17 | "title": "Advanced settings" 18 | }, 19 | "user": { 20 | "data": { 21 | "advanced": "Advanced settings", 22 | "name": "Name", 23 | "value_template": "Content (text or template)" 24 | }, 25 | "title": "Create QR Code" 26 | } 27 | } 28 | }, 29 | "options": { 30 | "error": { 31 | "invalid_color": "Invalid color", 32 | "invalid_template": "Invalid template", 33 | "unknown": "Unexpected error" 34 | }, 35 | "step": { 36 | "advanced": { 37 | "data": { 38 | "background_color": "Background color", 39 | "border": "Border", 40 | "color": "Color", 41 | "error_correction": "Error correction:", 42 | "scale": "Scale" 43 | }, 44 | "title": "Advanced settings" 45 | }, 46 | "init": { 47 | "data": { 48 | "advanced": "Advanced settings", 49 | "name": "Name", 50 | "value_template": "Content (text or template)" 51 | }, 52 | "title": "Edit QR Code" 53 | } 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /custom_components/qr_generator/translations/sk.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "invalid_color": "Neplatná farba", 5 | "invalid_template": "Neplatná šablóna", 6 | "unknown": "Neočakávaná chyba" 7 | }, 8 | "step": { 9 | "advanced": { 10 | "data": { 11 | "background_color": "Farba pozadia", 12 | "border": "Okraj", 13 | "color": "Farba", 14 | "error_correction": "Korekcia chyby:", 15 | "scale": "Mierka" 16 | }, 17 | "title": "Pokročilé nastavenia" 18 | }, 19 | "user": { 20 | "data": { 21 | "advanced": "Pokročilé nastavenia", 22 | "name": "Názov", 23 | "value_template": "Obsah (text alebo šablóna)" 24 | }, 25 | "title": "Vytvoriť QR kód" 26 | } 27 | } 28 | }, 29 | "options": { 30 | "error": { 31 | "invalid_color": "Neplatná farba", 32 | "invalid_template": "Neplatná šablóna", 33 | "unknown": "Neočakávaná chyba" 34 | }, 35 | "step": { 36 | "advanced": { 37 | "data": { 38 | "background_color": "Farba pozadia", 39 | "border": "Okraj", 40 | "color": "Farba", 41 | "error_correction": "Korekcia chyby:", 42 | "scale": "Mierka" 43 | }, 44 | "title": "Pokročilé nastavenia" 45 | }, 46 | "init": { 47 | "data": { 48 | "advanced": "Pokročilé nastavenia", 49 | "name": "Názov", 50 | "value_template": "Obsah (text alebo šablóna)" 51 | }, 52 | "title": "Upraviť QR kód" 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "QR-Code Generator", 3 | "render_readme": true 4 | } -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | homeassistant -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | pyqrcode==1.2.1 2 | pypng==0.20220715.0 3 | 4 | #Test will fail witout 5 | aiohttp_cors 6 | 7 | pytest-homeassistant-custom-component==0.13.224 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build 3 | doctests = True 4 | # To work with Black 5 | max-line-length = 88 6 | # E501: line too long 7 | # W503: Line break occurred before a binary operator 8 | # E203: Whitespace before ':' 9 | # D202 No blank lines allowed after function docstring 10 | # W504 line break after binary operator 11 | ignore = 12 | E501, 13 | W503, 14 | E203, 15 | D202, 16 | W504 17 | 18 | [isort] 19 | # https://github.com/timothycrosley/isort 20 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 21 | # splits long import on multiple lines indented by 4 spaces 22 | multi_line_output = 3 23 | include_trailing_comma=True 24 | force_grid_wrap=0 25 | use_parentheses=True 26 | line_length=88 27 | indent = " " 28 | # by default isort don't check module indexes 29 | not_skip = __init__.py 30 | # will group `import x` and `from x import` of the same module. 31 | force_sort_within_sections = true 32 | sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 33 | default_section = THIRDPARTY 34 | known_first_party = custom_components.qr_generator, tests 35 | combine_as_imports = true 36 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Test for the QR Generator integration.""" 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Fixtures for testing.""" 2 | import pytest 3 | 4 | 5 | @pytest.fixture(autouse=True) 6 | def auto_enable_custom_integrations(enable_custom_integrations): 7 | """Enable enable_custom_integrations""" 8 | yield 9 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Test the QR Generator config flow.""" 2 | from __future__ import annotations 3 | 4 | from typing import Any 5 | from unittest.mock import patch 6 | 7 | import pytest 8 | from homeassistant.config_entries import SOURCE_USER 9 | from homeassistant.const import CONF_NAME, CONF_VALUE_TEMPLATE 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.data_entry_flow import FlowResultType 12 | from pytest_homeassistant_custom_component.common import MockConfigEntry 13 | 14 | from custom_components.qr_generator.config_flow import ConfigFlow 15 | from custom_components.qr_generator.const import ( 16 | CONF_ADVANCED, 17 | CONF_BACKGROUND_COLOR, 18 | CONF_BORDER, 19 | CONF_COLOR, 20 | CONF_ERROR_CORRECTION, 21 | CONF_SCALE, 22 | DEFAULT_BACKGROUND_COLOR, 23 | DEFAULT_BORDER, 24 | DEFAULT_COLOR, 25 | DEFAULT_ERROR_CORRECTION, 26 | DEFAULT_SCALE, 27 | DOMAIN, 28 | ) 29 | 30 | DUMMY_DATA_SIMPLE: dict[str, Any] = { 31 | CONF_NAME: "Test QR Code", 32 | CONF_VALUE_TEMPLATE: "Sample content", 33 | CONF_ADVANCED: False, 34 | } 35 | DUMMY_DATA_SIMPLE_ADVANCED: dict[str, Any] = { 36 | CONF_NAME: "Test QR Code", 37 | CONF_VALUE_TEMPLATE: "Sample content", 38 | CONF_ADVANCED: True, 39 | } 40 | 41 | DUMMY_DATA_SIMPLE_INVALID_TEMPLATE: dict[str, Any] = { 42 | CONF_NAME: "Test QR Code", 43 | CONF_VALUE_TEMPLATE: "{{novalid template}}", 44 | CONF_ADVANCED: False, 45 | } 46 | 47 | DUMMY_DATA_ADVANCED: dict[str, Any] = { 48 | CONF_COLOR: DEFAULT_COLOR, 49 | CONF_SCALE: DEFAULT_SCALE, 50 | CONF_BORDER: DEFAULT_BORDER, 51 | CONF_ERROR_CORRECTION: DEFAULT_ERROR_CORRECTION, 52 | CONF_BACKGROUND_COLOR: DEFAULT_BACKGROUND_COLOR, 53 | } 54 | 55 | DUMMY_DATA_ADVANCED_INVALID_COLOR: dict[str, Any] = { 56 | CONF_COLOR: "black", 57 | CONF_SCALE: DEFAULT_SCALE, 58 | CONF_BORDER: DEFAULT_BORDER, 59 | CONF_ERROR_CORRECTION: DEFAULT_ERROR_CORRECTION, 60 | CONF_BACKGROUND_COLOR: DEFAULT_BACKGROUND_COLOR, 61 | } 62 | 63 | DUMMY_ENTRY: dict[str, Any] = { 64 | CONF_NAME: "Test QR Code", 65 | CONF_VALUE_TEMPLATE: "Sample content", 66 | CONF_ADVANCED: True, 67 | CONF_COLOR: DEFAULT_COLOR, 68 | CONF_SCALE: DEFAULT_SCALE, 69 | CONF_BORDER: DEFAULT_BORDER, 70 | CONF_ERROR_CORRECTION: DEFAULT_ERROR_CORRECTION, 71 | CONF_BACKGROUND_COLOR: DEFAULT_BACKGROUND_COLOR, 72 | } 73 | 74 | DUMMY_ENTRY_CHANGE: dict[str, Any] = { 75 | CONF_VALUE_TEMPLATE: "New test", 76 | CONF_ADVANCED: False, 77 | } 78 | 79 | DUMMY_ENTRY_ADVANCED_CHANGE: dict[str, Any] = { 80 | CONF_SCALE: 50, 81 | } 82 | 83 | DUMMY_ENTRY_UPDATED: dict[str, Any] = { 84 | CONF_NAME: "Test QR Code", 85 | CONF_VALUE_TEMPLATE: "New test", 86 | CONF_ADVANCED: False, 87 | CONF_COLOR: DEFAULT_COLOR, 88 | CONF_SCALE: DEFAULT_SCALE, 89 | CONF_BORDER: DEFAULT_BORDER, 90 | CONF_ERROR_CORRECTION: DEFAULT_ERROR_CORRECTION, 91 | CONF_BACKGROUND_COLOR: DEFAULT_BACKGROUND_COLOR, 92 | } 93 | 94 | DUMMY_ENTRY_ADVANCED_UPDATED: dict[str, Any] = { 95 | CONF_NAME: "Test QR Code", 96 | CONF_VALUE_TEMPLATE: "Sample content", 97 | CONF_ADVANCED: True, 98 | CONF_COLOR: DEFAULT_COLOR, 99 | CONF_SCALE: 50, 100 | CONF_BORDER: DEFAULT_BORDER, 101 | CONF_ERROR_CORRECTION: DEFAULT_ERROR_CORRECTION, 102 | CONF_BACKGROUND_COLOR: DEFAULT_BACKGROUND_COLOR, 103 | } 104 | 105 | @pytest.mark.asyncio 106 | async def test_show_set_form(hass: HomeAssistant) -> None: 107 | """Test that the setup form is served.""" 108 | 109 | result: dict[str, Any] = await hass.config_entries.flow.async_init( 110 | DOMAIN, context={"source": SOURCE_USER} 111 | ) 112 | 113 | assert result["type"] == FlowResultType.FORM 114 | assert result["step_id"] == "user" 115 | 116 | 117 | @pytest.mark.asyncio 118 | async def test_step_user(hass: HomeAssistant) -> None: 119 | """Test starting a flow by user with valid values.""" 120 | 121 | result: dict[str, Any] = await hass.config_entries.flow.async_init( 122 | DOMAIN, context={"source": SOURCE_USER}, data=DUMMY_DATA_SIMPLE 123 | ) 124 | 125 | assert result["type"] == FlowResultType.CREATE_ENTRY 126 | assert result["title"] == DUMMY_DATA_SIMPLE[CONF_NAME] 127 | 128 | 129 | @pytest.mark.asyncio 130 | async def test_step_user_template_error(hass: HomeAssistant) -> None: 131 | """Test starting a flow by user with an invalid template.""" 132 | 133 | result: dict[str, Any] = await hass.config_entries.flow.async_init( 134 | DOMAIN, context={"source": SOURCE_USER}, data=DUMMY_DATA_SIMPLE_INVALID_TEMPLATE 135 | ) 136 | 137 | assert result["type"] == FlowResultType.FORM 138 | assert result["step_id"] == "user" 139 | assert result["errors"] == {"base": "invalid_template"} 140 | 141 | 142 | @pytest.mark.asyncio 143 | async def test_show_set_form_advanced_from_user(hass: HomeAssistant) -> None: 144 | """Test that the advanced form is served as a step.""" 145 | 146 | result: dict[str, Any] = await hass.config_entries.flow.async_init( 147 | DOMAIN, context={"source": "user"}, data=DUMMY_DATA_SIMPLE_ADVANCED 148 | ) 149 | 150 | assert result["type"] == FlowResultType.FORM 151 | assert result["step_id"] == "advanced" 152 | 153 | 154 | @pytest.mark.asyncio 155 | async def test_show_set_form_advanced(hass: HomeAssistant) -> None: 156 | """Test that the advanced form is served.""" 157 | 158 | result: dict[str, Any] = await hass.config_entries.flow.async_init( 159 | DOMAIN, context={"source": "advanced"} 160 | ) 161 | 162 | assert result["type"] == FlowResultType.FORM 163 | assert result["step_id"] == "advanced" 164 | 165 | 166 | @pytest.mark.asyncio 167 | async def test_step_advanced(hass: HomeAssistant) -> None: 168 | """Test starting a flow by advanced with valid values.""" 169 | 170 | with patch.object(ConfigFlow, "override_config", DUMMY_DATA_SIMPLE): 171 | 172 | result: dict[str, Any] = await hass.config_entries.flow.async_init( 173 | DOMAIN, context={"source": "advanced"}, data=DUMMY_DATA_ADVANCED 174 | ) 175 | 176 | assert result["type"] == FlowResultType.CREATE_ENTRY 177 | assert result["title"] == DUMMY_DATA_SIMPLE[CONF_NAME] 178 | 179 | 180 | @pytest.mark.asyncio 181 | async def test_step_advanced_invalid_color(hass: HomeAssistant) -> None: 182 | """Test starting a flow by advanced with an invalid color.""" 183 | 184 | with patch.object(ConfigFlow, "override_config", DUMMY_DATA_SIMPLE): 185 | 186 | result: dict[str, Any] = await hass.config_entries.flow.async_init( 187 | DOMAIN, 188 | context={"source": "advanced"}, 189 | data=DUMMY_DATA_ADVANCED_INVALID_COLOR, 190 | ) 191 | 192 | assert result["type"] == FlowResultType.FORM 193 | assert result["step_id"] == "advanced" 194 | assert result["errors"] == {"base": "invalid_color"} 195 | 196 | 197 | @pytest.mark.asyncio 198 | async def test_options_flow_init(hass: HomeAssistant) -> None: 199 | """Test config flow options.""" 200 | config_entry = MockConfigEntry( 201 | domain=DOMAIN, 202 | title=DUMMY_ENTRY[CONF_NAME], 203 | data=DUMMY_ENTRY, 204 | ) 205 | config_entry.add_to_hass(hass) 206 | 207 | with patch("custom_components.qr_generator.async_setup_entry", return_value=True): 208 | result = await hass.config_entries.options.async_init(config_entry.entry_id) 209 | 210 | await hass.config_entries.async_setup(config_entry.entry_id) 211 | await hass.async_block_till_done() 212 | 213 | assert result["type"] == FlowResultType.FORM 214 | assert result["step_id"] == "init" 215 | 216 | result = await hass.config_entries.options.async_configure( 217 | result["flow_id"], 218 | user_input=DUMMY_ENTRY_CHANGE, 219 | ) 220 | 221 | assert result["type"] == FlowResultType.CREATE_ENTRY 222 | assert dict(config_entry.options) == DUMMY_ENTRY_UPDATED 223 | 224 | 225 | @pytest.mark.asyncio 226 | async def test_options_flow_invalid_template(hass: HomeAssistant) -> None: 227 | """Test config flow options with invalid template.""" 228 | config_entry = MockConfigEntry( 229 | domain=DOMAIN, 230 | title=DUMMY_ENTRY[CONF_NAME], 231 | data=DUMMY_ENTRY, 232 | ) 233 | config_entry.add_to_hass(hass) 234 | 235 | with patch("custom_components.qr_generator.async_setup_entry", return_value=True): 236 | result = await hass.config_entries.options.async_init(config_entry.entry_id) 237 | 238 | await hass.config_entries.async_setup(config_entry.entry_id) 239 | await hass.async_block_till_done() 240 | 241 | assert result["type"] == FlowResultType.FORM 242 | assert result["step_id"] == "init" 243 | 244 | result = await hass.config_entries.options.async_configure( 245 | result["flow_id"], 246 | user_input=DUMMY_DATA_SIMPLE_INVALID_TEMPLATE, 247 | ) 248 | 249 | assert result["type"] == FlowResultType.FORM 250 | assert result["step_id"] == "init" 251 | assert result["errors"] == {"base": "invalid_template"} 252 | 253 | 254 | @pytest.mark.asyncio 255 | async def test_options_flow_to_advanced(hass: HomeAssistant) -> None: 256 | """Test config flow options.""" 257 | config_entry = MockConfigEntry( 258 | domain=DOMAIN, 259 | title=DUMMY_ENTRY[CONF_NAME], 260 | data=DUMMY_ENTRY, 261 | ) 262 | config_entry.add_to_hass(hass) 263 | 264 | with patch("custom_components.qr_generator.async_setup_entry", return_value=True): 265 | result = await hass.config_entries.options.async_init(config_entry.entry_id) 266 | 267 | await hass.config_entries.async_setup(config_entry.entry_id) 268 | await hass.async_block_till_done() 269 | 270 | assert result["type"] == FlowResultType.FORM 271 | assert result["step_id"] == "init" 272 | 273 | result = await hass.config_entries.options.async_configure( 274 | result["flow_id"], 275 | user_input=DUMMY_DATA_SIMPLE_ADVANCED, 276 | ) 277 | 278 | assert result["type"] == FlowResultType.FORM 279 | assert result["step_id"] == "advanced" 280 | assert result["errors"] == {} 281 | 282 | 283 | @pytest.mark.asyncio 284 | async def test_options_flow_advanced(hass: HomeAssistant) -> None: 285 | """Test config flow options.""" 286 | config_entry = MockConfigEntry( 287 | domain=DOMAIN, 288 | title=DUMMY_ENTRY[CONF_NAME], 289 | data=DUMMY_ENTRY, 290 | ) 291 | config_entry.add_to_hass(hass) 292 | 293 | with patch("custom_components.qr_generator.async_setup_entry", return_value=True): 294 | result = await hass.config_entries.options.async_init(config_entry.entry_id) 295 | 296 | await hass.config_entries.async_setup(config_entry.entry_id) 297 | await hass.async_block_till_done() 298 | 299 | assert result["type"] == FlowResultType.FORM 300 | assert result["step_id"] == "init" 301 | 302 | result = await hass.config_entries.options.async_configure( 303 | result["flow_id"], 304 | user_input=DUMMY_DATA_SIMPLE_ADVANCED, 305 | ) 306 | 307 | assert result["type"] == FlowResultType.FORM 308 | assert result["step_id"] == "advanced" 309 | assert result["errors"] == {} 310 | 311 | result = await hass.config_entries.options.async_configure( 312 | result["flow_id"], 313 | user_input=DUMMY_ENTRY_ADVANCED_CHANGE, 314 | ) 315 | 316 | assert result["type"] == FlowResultType.CREATE_ENTRY 317 | assert dict(config_entry.options) == DUMMY_ENTRY_ADVANCED_UPDATED 318 | 319 | 320 | @pytest.mark.asyncio 321 | async def test_options_flow_advanced_invalid_color(hass: HomeAssistant) -> None: 322 | """Test config flow options with invalid template.""" 323 | config_entry = MockConfigEntry( 324 | domain=DOMAIN, 325 | title=DUMMY_ENTRY[CONF_NAME], 326 | data=DUMMY_ENTRY, 327 | ) 328 | config_entry.add_to_hass(hass) 329 | 330 | with patch("custom_components.qr_generator.async_setup_entry", return_value=True): 331 | result = await hass.config_entries.options.async_init(config_entry.entry_id) 332 | 333 | await hass.config_entries.async_setup(config_entry.entry_id) 334 | await hass.async_block_till_done() 335 | 336 | assert result["type"] == FlowResultType.FORM 337 | assert result["step_id"] == "init" 338 | 339 | result = await hass.config_entries.options.async_configure( 340 | result["flow_id"], 341 | user_input=DUMMY_DATA_SIMPLE_ADVANCED, 342 | ) 343 | 344 | assert result["type"] == FlowResultType.FORM 345 | assert result["step_id"] == "advanced" 346 | assert result["errors"] == {} 347 | 348 | result = await hass.config_entries.options.async_configure( 349 | result["flow_id"], 350 | user_input=DUMMY_DATA_ADVANCED_INVALID_COLOR, 351 | ) 352 | 353 | assert result["type"] == FlowResultType.FORM 354 | assert result["step_id"] == "advanced" 355 | assert result["errors"] == {"base": "invalid_color"} 356 | -------------------------------------------------------------------------------- /tests/test_image.py: -------------------------------------------------------------------------------- 1 | """Test the QR Generator image.""" 2 | from typing import Any 3 | 4 | import pytest 5 | 6 | from homeassistant.config_entries import ConfigEntryState 7 | from homeassistant.const import CONF_NAME, CONF_VALUE_TEMPLATE 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.helpers import entity_registry as er 10 | 11 | from pytest_homeassistant_custom_component.common import MockConfigEntry 12 | 13 | from custom_components.qr_generator.const import ( 14 | ATTR_BACKGROUND_COLOR, 15 | ATTR_BORDER, 16 | ATTR_COLOR, 17 | ATTR_ERROR_CORRECTION, 18 | ATTR_SCALE, 19 | ATTR_TEXT, 20 | CONF_ADVANCED, 21 | CONF_BACKGROUND_COLOR, 22 | CONF_BORDER, 23 | CONF_COLOR, 24 | CONF_ERROR_CORRECTION, 25 | CONF_SCALE, 26 | DEFAULT_BACKGROUND_COLOR, 27 | DEFAULT_BORDER, 28 | DEFAULT_COLOR, 29 | DEFAULT_ERROR_CORRECTION, 30 | DEFAULT_SCALE, 31 | DOMAIN, 32 | ) 33 | 34 | DUMMY_ENTRY: dict[str, Any] = { 35 | CONF_NAME: "Test QR Code", 36 | CONF_VALUE_TEMPLATE: "Sample content", 37 | CONF_ADVANCED: True, 38 | CONF_COLOR: DEFAULT_COLOR, 39 | CONF_SCALE: DEFAULT_SCALE, 40 | CONF_BORDER: DEFAULT_BORDER, 41 | CONF_ERROR_CORRECTION: DEFAULT_ERROR_CORRECTION, 42 | CONF_BACKGROUND_COLOR: DEFAULT_BACKGROUND_COLOR, 43 | } 44 | 45 | @pytest.mark.asyncio 46 | async def test_image(hass: HomeAssistant) -> None: 47 | """Test the creation and values of the image.""" 48 | config_entry: MockConfigEntry = MockConfigEntry( 49 | domain=DOMAIN, title="NINA", data=DUMMY_ENTRY 50 | ) 51 | 52 | entity_registry: er = er.async_get(hass) 53 | config_entry.add_to_hass(hass) 54 | 55 | await hass.config_entries.async_setup(config_entry.entry_id) 56 | await hass.async_block_till_done() 57 | 58 | assert config_entry.state == ConfigEntryState.LOADED 59 | 60 | state = hass.states.get("image.test_qr_code") 61 | entry = entity_registry.async_get("image.test_qr_code") 62 | 63 | assert state.attributes.get(ATTR_TEXT) == "Sample content" 64 | assert state.attributes.get(ATTR_COLOR) == DEFAULT_COLOR 65 | assert state.attributes.get(ATTR_SCALE) == DEFAULT_SCALE 66 | assert state.attributes.get(ATTR_BORDER) == DEFAULT_BORDER 67 | assert state.attributes.get(ATTR_ERROR_CORRECTION) == DEFAULT_ERROR_CORRECTION 68 | assert state.attributes.get(ATTR_BACKGROUND_COLOR) == DEFAULT_BACKGROUND_COLOR 69 | 70 | assert entry.unique_id == f"{config_entry.entry_id}-qr-code" 71 | --------------------------------------------------------------------------------