├── .github └── ISSUE_TEMPLATE │ └── bug_report.md ├── .gitignore ├── README.md ├── custom_components └── scrypted │ ├── __init__.py │ ├── config_flow.py │ ├── const.py │ ├── entrypoint.html │ ├── entrypoint.js │ ├── http.py │ ├── lit-core.min.js │ ├── manifest.json │ ├── sensor.py │ ├── strings.json │ └── translations │ └── en.json ├── hacs.json └── requirements_dev.txt /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | - [ ] I have searched the issue tracker for an existing issue. 11 | 12 | **Describe the bug** 13 | A clear and concise description of what the bug is. 14 | 15 | **To Reproduce** 16 | Steps to reproduce the behavior: 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | **Expected behavior** 23 | A clear and concise description of what you expected to happen. 24 | 25 | **Screenshots** 26 | If applicable, add screenshots to help explain your problem. 27 | 28 | **Desktop (please complete the following information):** 29 | - OS: [e.g. iOS] 30 | - Browser [e.g. chrome, safari] 31 | - Version [e.g. 22] 32 | 33 | **Smartphone (please complete the following information):** 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | venv 3 | __pycache__ 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scrypted Custom Component for Home Assistant 2 | 3 | The Scrypted Custom Component for Home Assistant adds support for managing Scrypted from your Home Assistant Dashboard, and creation of Scrypted NVR cards. 4 | 5 | image 6 | 7 | Visit the [Scrypted Documentation](https://docs.scrypted.app/home-assistant.html) for setup instructions. 8 | -------------------------------------------------------------------------------- /custom_components/scrypted/__init__.py: -------------------------------------------------------------------------------- 1 | """The Scrypted integration.""" 2 | from __future__ import annotations 3 | 4 | from aiohttp import ClientConnectorError 5 | 6 | from typing import Any 7 | 8 | from homeassistant.components.frontend import ( 9 | async_register_built_in_panel, 10 | async_remove_panel, 11 | ) 12 | from homeassistant.components.persistent_notification import async_create 13 | from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, ConfigEntry 14 | from homeassistant.const import CONF_ICON, CONF_NAME, Platform 15 | from homeassistant.core import HomeAssistant, callback 16 | from homeassistant.exceptions import ConfigEntryNotReady 17 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 18 | from homeassistant.helpers.typing import ConfigType 19 | 20 | from .const import DOMAIN 21 | from .http import ScryptedView, retrieve_token 22 | 23 | from homeassistant.components import panel_custom, websocket_api 24 | 25 | 26 | PLATFORMS = [ 27 | Platform.SENSOR 28 | ] 29 | 30 | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 31 | """Auth setup.""" 32 | session = async_get_clientsession(hass, verify_ssl=False) 33 | hass.http.register_view(ScryptedView(hass, session)) 34 | 35 | if DOMAIN in config: 36 | async_create( 37 | hass, 38 | ( 39 | "Your Scrypted configuration has been imported as a config entry and " 40 | "can safely be removed from your configuration.yaml." 41 | ), 42 | "Scrypted Config Import", 43 | ) 44 | hass.async_create_task( 45 | hass.config_entries.flow.async_init( 46 | DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] 47 | ) 48 | ) 49 | return False 50 | return True 51 | 52 | 53 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 54 | """Set up a Scrypted config entry.""" 55 | 56 | @callback 57 | def _reauth(data: dict[str, Any]) -> bool: 58 | """Start Reauth flow.""" 59 | hass.async_create_task( 60 | hass.config_entries.flow.async_init( 61 | DOMAIN, 62 | context={ 63 | "source": SOURCE_REAUTH, 64 | "entry_id": config_entry.entry_id, 65 | "data": dict(data), 66 | }, 67 | ) 68 | ) 69 | return False 70 | 71 | if not config_entry.data: 72 | return _reauth(config_entry.options) 73 | 74 | session = async_get_clientsession(hass, verify_ssl=False) 75 | try: 76 | if not (token := await retrieve_token(config_entry.data, session)): 77 | return _reauth(config_entry.data) 78 | except Exception as e: 79 | if isinstance(e, ClientConnectorError): 80 | raise ConfigEntryNotReady("ClientConnectorError. Is the Scrypted host down? Retrying.") 81 | raise e 82 | 83 | hass.data.setdefault(DOMAIN, {})[token] = config_entry 84 | 85 | custom_panel_config = { 86 | "name": "ha-panel-scrypted", 87 | # "embed_iframe": True, 88 | "trust_external": False, 89 | "module_url": f"/api/{DOMAIN}/{token}/entrypoint.js", 90 | } 91 | 92 | panelconf = {} 93 | panelconf["_panel_custom"] = custom_panel_config 94 | panelconf["version"] = "1.0.0" 95 | 96 | async_register_built_in_panel( 97 | hass, 98 | "custom", 99 | config_entry.data[CONF_NAME], 100 | config_entry.data[CONF_ICON], 101 | f"{DOMAIN}_{token}", 102 | panelconf, 103 | require_admin=False, 104 | ) 105 | 106 | # Set up token sensor 107 | await hass.config_entries.async_forward_entry_setups( 108 | config_entry, PLATFORMS 109 | ) 110 | return True 111 | 112 | 113 | async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 114 | """Unload a config entry.""" 115 | token = next( 116 | token 117 | for token, entry in hass.data[DOMAIN].items() 118 | if entry.entry_id == config_entry.entry_id 119 | ) 120 | hass.data[DOMAIN].pop(token) 121 | if not hass.data[DOMAIN]: 122 | hass.data.pop(DOMAIN) 123 | async_remove_panel(hass, f"{DOMAIN}_{config_entry.entry_id}") 124 | return True 125 | -------------------------------------------------------------------------------- /custom_components/scrypted/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Scrypted integration.""" 2 | from __future__ import annotations 3 | 4 | from typing import Any 5 | 6 | import voluptuous as vol 7 | from homeassistant import config_entries 8 | from homeassistant.const import ( 9 | CONF_HOST, 10 | CONF_ICON, 11 | CONF_NAME, 12 | CONF_PASSWORD, 13 | CONF_USERNAME, 14 | ) 15 | from homeassistant.helpers import selector 16 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 17 | from homeassistant.util import slugify 18 | 19 | from .const import DOMAIN, CONF_SCRYPTED_NVR 20 | from .http import retrieve_token 21 | 22 | 23 | def text_selector(type: selector.TextSelectorType) -> selector.TextSelector: 24 | """Create a text selector.""" 25 | return selector.TextSelector(selector.TextSelectorConfig(type=type)) 26 | 27 | 28 | def _get_config_schema(default: dict[str, Any] | None) -> vol.Schema: 29 | """Get config schema.""" 30 | if not default: 31 | default = {} 32 | return vol.Schema( 33 | { 34 | vol.Required( 35 | CONF_NAME, default=default.get(CONF_NAME, DOMAIN.title()) 36 | ): text_selector(type=selector.TextSelectorType.TEXT), 37 | vol.Required( 38 | CONF_ICON, default=default.get(CONF_ICON, "mdi:memory") 39 | ): selector.IconSelector(selector.IconSelectorConfig()), 40 | vol.Required(CONF_HOST, default=default.get(CONF_HOST)): text_selector( 41 | type=selector.TextSelectorType.TEXT 42 | ), 43 | vol.Required( 44 | CONF_USERNAME, default=default.get(CONF_USERNAME) 45 | ): text_selector(type=selector.TextSelectorType.TEXT), 46 | vol.Required( 47 | CONF_PASSWORD, default=default.get(CONF_PASSWORD) 48 | ): text_selector(type=selector.TextSelectorType.PASSWORD), 49 | vol.Optional( 50 | CONF_SCRYPTED_NVR, default=False 51 | ): bool, 52 | } 53 | ) 54 | 55 | 56 | class ScryptedConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 57 | """Handle a Scrypted config flow.""" 58 | 59 | VERSION = 1 60 | 61 | def __init__(self) -> None: 62 | """Initialize flow.""" 63 | self.data = {} 64 | 65 | async def validate_input(self, data: dict[str, Any]) -> bool: 66 | """Validate that the host is valid.""" 67 | session = async_get_clientsession(self.hass, verify_ssl=False) 68 | for key in (CONF_HOST, CONF_ICON, CONF_NAME, CONF_USERNAME): 69 | if key not in data: 70 | return False 71 | try: 72 | await retrieve_token(data, session) 73 | except ValueError: 74 | return False 75 | 76 | return True 77 | 78 | async def async_step_user( 79 | self, user_input: dict[str, Any] | None = None 80 | ) -> config_entries.FlowResult: 81 | """Handle user flow.""" 82 | errors = {} 83 | if user_input is not None and CONF_USERNAME in user_input: 84 | if await self.validate_input(user_input): 85 | await self.async_set_unique_id(slugify(user_input[CONF_HOST])) 86 | self._abort_if_unique_id_configured() 87 | return self.async_create_entry( 88 | title=user_input[CONF_HOST], data=user_input 89 | ) 90 | errors["base"] = "invalid_host_or_credentials" 91 | 92 | return self.async_show_form( 93 | step_id="user", 94 | data_schema=_get_config_schema(user_input), 95 | errors=errors, 96 | ) 97 | 98 | async_step_import = async_step_user 99 | 100 | async def async_step_reauth( 101 | self, _: dict[str, Any] | None 102 | ) -> config_entries.FlowResult: 103 | """Handle reauth flow.""" 104 | if CONF_PASSWORD not in self.context["data"]: 105 | return await self.async_step_upgrade() 106 | return await self.async_step_credentials() 107 | 108 | async def _async_step_reauth( 109 | self, step_id: str, user_input: dict[str, Any] | None = None 110 | ) -> config_entries.FlowResult: 111 | """Handle reauth step.""" 112 | errors = {} 113 | if user_input is not None: 114 | if await self.validate_input(user_input): 115 | unique_id = slugify(user_input[CONF_HOST]) 116 | config_entry = self.hass.config_entries.async_get_entry( 117 | self.context["entry_id"] 118 | ) 119 | assert config_entry 120 | if not any( 121 | entry 122 | for entry in self.hass.config_entries.async_entries(DOMAIN) 123 | if entry != config_entry and entry.unique_id == unique_id 124 | ): 125 | self.hass.config_entries.async_update_entry( 126 | config_entry, data=user_input, options={}, unique_id=unique_id 127 | ) 128 | self.hass.async_create_task( 129 | self.hass.config_entries.async_reload(config_entry.entry_id) 130 | ) 131 | return self.async_abort(reason="success") 132 | 133 | errors[CONF_NAME] = "already_configured" 134 | 135 | if not errors: 136 | errors["base"] = "invalid_host_or_credentials" 137 | 138 | return self.async_show_form( 139 | step_id=step_id, 140 | data_schema=_get_config_schema(user_input or self.context["data"]), 141 | errors=errors, 142 | ) 143 | 144 | async def async_step_credentials( 145 | self, user_input: dict[str, Any] | None = None 146 | ) -> config_entries.FlowResult: 147 | """Handle credentials step.""" 148 | return await self._async_step_reauth("credentials", user_input) 149 | 150 | async def async_step_upgrade( 151 | self, user_input: dict[str, Any] | None = None 152 | ) -> config_entries.FlowResult: 153 | """Handle upgrade step.""" 154 | return await self._async_step_reauth("upgrade", user_input) 155 | -------------------------------------------------------------------------------- /custom_components/scrypted/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Scrypted integration.""" 2 | 3 | DOMAIN = "scrypted" 4 | CONF_SCRYPTED_NVR = "scrypted_nvr" 5 | -------------------------------------------------------------------------------- /custom_components/scrypted/entrypoint.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Scrypted> 6 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /custom_components/scrypted/entrypoint.js: -------------------------------------------------------------------------------- 1 | import { 2 | LitElement, 3 | html, 4 | css, 5 | } from "./lit-core.min.js"; 6 | 7 | class ExamplePanel extends LitElement { 8 | static get properties() { 9 | return { 10 | hass: { type: Object }, 11 | narrow: { type: Boolean }, 12 | panel: { type: Object }, 13 | }; 14 | } 15 | 16 | render() { 17 | return html` 18 |
19 |
20 | 25 | ${this.narrow ? html` 26 |
Home Assistant: Scrypted
27 | ` : ""} 28 |
29 |
30 | 35 |
36 |
37 | `; 38 | } 39 | 40 | static get styles() { 41 | return css` 42 | iframe { 43 | border: 0; 44 | width: 100%; 45 | position: absolute; 46 | height: 100%; 47 | background-color: var(--primary-background-color); 48 | } 49 | `; 50 | } 51 | } 52 | customElements.define("ha-panel-scrypted", ExamplePanel); 53 | -------------------------------------------------------------------------------- /custom_components/scrypted/http.py: -------------------------------------------------------------------------------- 1 | """The Scrypted integration.""" 2 | from __future__ import annotations 3 | 4 | import asyncio 5 | import logging 6 | from collections.abc import Iterable 7 | from functools import lru_cache 8 | from ipaddress import ip_address 9 | import os 10 | from typing import Any 11 | from urllib.parse import quote 12 | import threading 13 | 14 | import aiohttp 15 | from aiohttp import ClientTimeout, hdrs, web 16 | from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest 17 | from homeassistant.components.http import HomeAssistantView 18 | from homeassistant.config_entries import ConfigEntry 19 | from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME 20 | from homeassistant.core import HomeAssistant 21 | from multidict import CIMultiDict 22 | from yarl import URL 23 | 24 | from .const import DOMAIN, CONF_SCRYPTED_NVR 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | 29 | async def retrieve_token(data: dict[str, Any], session: aiohttp.ClientSession) -> str: 30 | """Retrieve token from Scrypted server.""" 31 | username = data[CONF_USERNAME] 32 | password = data.get(CONF_PASSWORD, "") 33 | host = data[CONF_HOST] 34 | ipport = host.split(":") 35 | if len(ipport) > 2: 36 | raise Exception("invalid Scrypted host") 37 | ip = ipport[0] 38 | if len(ipport) == 2: 39 | port = ipport[1] 40 | else: 41 | port = "10443" 42 | 43 | resp = await session.get( 44 | f"https://{ip}:{port}/login", 45 | headers={"authorization": aiohttp.BasicAuth(username, password).encode()}, 46 | json={"username": username}, 47 | raise_for_status=True, 48 | verify_ssl=False, 49 | ) 50 | resp_json = await resp.json() 51 | if "token" not in resp_json: 52 | raise ValueError("No token in response") 53 | 54 | return resp_json["token"] 55 | 56 | 57 | class ScryptedView(HomeAssistantView): 58 | """Hass.io view to handle base part.""" 59 | 60 | name = "api:scrypted" 61 | url = "/api/scrypted/{token}/{path:.*}" 62 | requires_auth = False 63 | 64 | def __init__(self, hass: HomeAssistant, session: aiohttp.ClientSession) -> None: 65 | """Initialize a Hass.io ingress view.""" 66 | self.hass = hass 67 | self._session = session 68 | self.lit_core = asyncio.Future[str]() 69 | self.entrypoint_js = asyncio.Future[str]() 70 | self.entrypoint_html = asyncio.Future[str]() 71 | hass.async_add_executor_job(lambda: self.load_files(session.loop)) 72 | 73 | def load_files(self, loop: asyncio.AbstractEventLoop): 74 | lit_core = str(open(os.path.join(os.path.dirname(__file__), "lit-core.min.js")).read()) 75 | entrypoint_js = str(open(os.path.join(os.path.dirname(__file__), "entrypoint.js")).read()) 76 | entrypoint_html = str(open(os.path.join(os.path.dirname(__file__), "entrypoint.html")).read()) 77 | loop.call_soon_threadsafe(lambda: self.lit_core.set_result(lit_core)) 78 | loop.call_soon_threadsafe(lambda: self.entrypoint_js.set_result(entrypoint_js)) 79 | loop.call_soon_threadsafe(lambda: self.entrypoint_html.set_result(entrypoint_html)) 80 | 81 | @lru_cache 82 | def _create_url(self, token: str, path: str) -> str: 83 | """Create URL to service.""" 84 | entry: ConfigEntry = self.hass.data[DOMAIN][token] 85 | host = entry.data[CONF_HOST] 86 | ipport = host.split(":") 87 | if len(ipport) > 2: 88 | raise Exception("invalid Scrypted host") 89 | ip = ipport[0] 90 | if len(ipport) == 2: 91 | port = ipport[1] 92 | else: 93 | port = "10443" 94 | 95 | base_path = "/" 96 | url = f"https://{ip}:{port}/{quote(path)}" 97 | 98 | try: 99 | if not URL(url).path.startswith(base_path): 100 | raise HTTPBadRequest() 101 | except ValueError as err: 102 | raise HTTPBadRequest() from err 103 | 104 | return url 105 | 106 | async def _handle( 107 | self, request: web.Request, token: str, path: str 108 | ) -> web.Response | web.StreamResponse | web.WebSocketResponse: 109 | """Route data to Hass.io ingress service.""" 110 | try: 111 | if path == "lit-core.min.js": 112 | response = web.Response( 113 | body=await self.lit_core, 114 | headers={ 115 | "Content-Type": "text/javascript", 116 | "Cache-Control": "no-store, max-age=0", 117 | }, 118 | ) 119 | return response 120 | 121 | if path == "entrypoint.js": 122 | body = (await self.entrypoint_js).replace("__DOMAIN__", DOMAIN).replace("__TOKEN__", token) 123 | response = web.Response( 124 | body=body, 125 | headers={ 126 | "Content-Type": "text/javascript", 127 | "Cache-Control": "no-store, max-age=0", 128 | }, 129 | ) 130 | return response 131 | 132 | if path == "entrypoint.html": 133 | body = (await self.entrypoint_html).replace("__DOMAIN__", DOMAIN).replace("__TOKEN__", token) 134 | entry: ConfigEntry = self.hass.data[DOMAIN][token] 135 | if CONF_SCRYPTED_NVR in entry.data and entry.data[CONF_SCRYPTED_NVR]: 136 | body = body.replace("core", "nvr") 137 | 138 | response = web.Response( 139 | body=body, 140 | headers={ 141 | "Content-Type": "text/html", 142 | "Cache-Control": "no-store, max-age=0", 143 | }, 144 | ) 145 | return response 146 | 147 | # Websocket 148 | if _is_websocket(request): 149 | return await self._handle_websocket(request, token, path) 150 | 151 | # Request 152 | return await self._handle_request(request, token, path) 153 | 154 | except aiohttp.ClientError as err: 155 | _LOGGER.debug("Ingress error with %s: %s", path, err) 156 | 157 | raise HTTPBadGateway() from None 158 | 159 | get = _handle 160 | post = _handle 161 | put = _handle 162 | delete = _handle 163 | patch = _handle 164 | # options = _handle 165 | 166 | async def _handle_websocket( 167 | self, request: web.Request, token: str, path: str 168 | ) -> web.WebSocketResponse: 169 | """Ingress route for websocket.""" 170 | req_protocols: Iterable[str] 171 | if hdrs.SEC_WEBSOCKET_PROTOCOL in request.headers: 172 | req_protocols = [ 173 | str(proto.strip()) 174 | for proto in request.headers[hdrs.SEC_WEBSOCKET_PROTOCOL].split(",") 175 | ] 176 | else: 177 | req_protocols = () 178 | 179 | ws_server = web.WebSocketResponse( 180 | protocols=req_protocols, autoclose=False, autoping=False 181 | ) 182 | await ws_server.prepare(request) 183 | 184 | # Preparing 185 | url = self._create_url(token, path) 186 | source_header = _init_header(request) 187 | source_header["Authorization"] = f"Bearer {token}" 188 | 189 | # Support GET query 190 | if request.query_string: 191 | url = f"{url}?{request.query_string}" 192 | 193 | # Start proxy 194 | async with self._session.ws_connect( 195 | url, 196 | verify_ssl=False, 197 | headers=source_header, 198 | protocols=req_protocols, 199 | autoclose=False, 200 | autoping=False, 201 | ) as ws_client: 202 | # Proxy requests 203 | await asyncio.wait( 204 | [ 205 | asyncio.create_task(_websocket_forward(ws_server, ws_client)), 206 | asyncio.create_task(_websocket_forward(ws_client, ws_server)), 207 | ], 208 | return_when=asyncio.FIRST_COMPLETED, 209 | ) 210 | 211 | return ws_server 212 | 213 | async def _handle_request( 214 | self, request: web.Request, token: str, path: str 215 | ) -> web.Response | web.StreamResponse: 216 | """Ingress route for request.""" 217 | url = self._create_url(token, path) 218 | source_header = _init_header(request) 219 | source_header["Authorization"] = f"Bearer {token}" 220 | 221 | async with self._session.request( 222 | request.method, 223 | url, 224 | verify_ssl=False, 225 | headers=source_header, 226 | params=request.query, 227 | allow_redirects=False, 228 | data=request.content, 229 | timeout=ClientTimeout(total=None), 230 | skip_auto_headers={hdrs.CONTENT_TYPE}, 231 | ) as result: 232 | headers = _response_header(result) 233 | 234 | # Simple request 235 | if ( 236 | hdrs.CONTENT_LENGTH in result.headers 237 | and int(result.headers.get(hdrs.CONTENT_LENGTH, 0)) < 4194000 238 | ) or result.status in (204, 304): 239 | # Return Response 240 | body = await result.read() 241 | return web.Response( 242 | headers=headers, 243 | status=result.status, 244 | content_type=result.content_type, 245 | body=body, 246 | ) 247 | 248 | # Stream response 249 | response = web.StreamResponse(status=result.status, headers=headers) 250 | response.content_type = result.content_type 251 | 252 | try: 253 | await response.prepare(request) 254 | async for data in result.content.iter_chunked(4096): 255 | await response.write(data) 256 | 257 | except ( 258 | aiohttp.ClientError, 259 | aiohttp.ClientPayloadError, 260 | ConnectionResetError, 261 | ) as err: 262 | _LOGGER.debug("Stream error %s: %s", path, err) 263 | 264 | return response 265 | 266 | 267 | def _init_header(request: web.Request) -> CIMultiDict | dict[str, str]: 268 | """Create initial header.""" 269 | headers = {} 270 | 271 | # filter flags 272 | for name, value in request.headers.items(): 273 | if name in ( 274 | hdrs.CONTENT_LENGTH, 275 | hdrs.CONTENT_ENCODING, 276 | hdrs.TRANSFER_ENCODING, 277 | hdrs.CONNECTION, 278 | hdrs.SEC_WEBSOCKET_EXTENSIONS, 279 | hdrs.SEC_WEBSOCKET_PROTOCOL, 280 | hdrs.SEC_WEBSOCKET_VERSION, 281 | hdrs.SEC_WEBSOCKET_KEY, 282 | hdrs.HOST, 283 | ): 284 | continue 285 | headers[name] = value 286 | 287 | # Set X-Forwarded-For 288 | forward_for = request.headers.get(hdrs.X_FORWARDED_FOR) 289 | assert request.transport 290 | if (peername := request.transport.get_extra_info("peername")) is None: 291 | _LOGGER.error("Can't set forward_for header, missing peername") 292 | raise HTTPBadRequest() 293 | 294 | connected_ip = ip_address(peername[0]) 295 | if forward_for: 296 | forward_for = f"{forward_for}, {connected_ip!s}" 297 | else: 298 | forward_for = f"{connected_ip!s}" 299 | headers[hdrs.X_FORWARDED_FOR] = forward_for 300 | 301 | # Set X-Forwarded-Host 302 | if not (forward_host := request.headers.get(hdrs.X_FORWARDED_HOST)): 303 | forward_host = request.host 304 | headers[hdrs.X_FORWARDED_HOST] = forward_host 305 | 306 | # Set X-Forwarded-Proto 307 | forward_proto = request.headers.get(hdrs.X_FORWARDED_PROTO) 308 | if not forward_proto: 309 | forward_proto = request.url.scheme 310 | headers[hdrs.X_FORWARDED_PROTO] = forward_proto 311 | 312 | return headers 313 | 314 | 315 | def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]: 316 | """Create response header.""" 317 | headers = {} 318 | 319 | for name, value in response.headers.items(): 320 | if name in ( 321 | hdrs.TRANSFER_ENCODING, 322 | hdrs.CONTENT_LENGTH, 323 | hdrs.CONTENT_TYPE, 324 | hdrs.CONTENT_ENCODING, 325 | ): 326 | continue 327 | headers[name] = value 328 | 329 | return headers 330 | 331 | 332 | def _is_websocket(request: web.Request) -> bool: 333 | """Return True if request is a websocket.""" 334 | headers = request.headers 335 | 336 | if ( 337 | "upgrade" in headers.get(hdrs.CONNECTION, "").lower() 338 | and headers.get(hdrs.UPGRADE, "").lower() == "websocket" 339 | ): 340 | return True 341 | return False 342 | 343 | 344 | async def _websocket_forward(ws_from, ws_to): 345 | """Handle websocket message directly.""" 346 | try: 347 | async for msg in ws_from: 348 | if msg.type == aiohttp.WSMsgType.TEXT: 349 | await ws_to.send_str(msg.data) 350 | elif msg.type == aiohttp.WSMsgType.BINARY: 351 | await ws_to.send_bytes(msg.data) 352 | elif msg.type == aiohttp.WSMsgType.PING: 353 | await ws_to.ping() 354 | elif msg.type == aiohttp.WSMsgType.PONG: 355 | await ws_to.pong() 356 | elif ws_to.closed: 357 | await ws_to.close(code=ws_to.close_code, message=msg.extra) 358 | except RuntimeError: 359 | _LOGGER.debug("Ingress Websocket runtime error") 360 | except ConnectionResetError: 361 | _LOGGER.debug("Ingress Websocket Connection Reset") 362 | -------------------------------------------------------------------------------- /custom_components/scrypted/lit-core.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2019 Google LLC 4 | * SPDX-License-Identifier: BSD-3-Clause 5 | */ 6 | const t=window,i=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,s=Symbol(),e=new WeakMap;class o{constructor(t,i,e){if(this._$cssResult$=!0,e!==s)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=i}get styleSheet(){let t=this.i;const s=this.t;if(i&&void 0===t){const i=void 0!==s&&1===s.length;i&&(t=e.get(s)),void 0===t&&((this.i=t=new CSSStyleSheet).replaceSync(this.cssText),i&&e.set(s,t))}return t}toString(){return this.cssText}}const n=t=>new o("string"==typeof t?t:t+"",void 0,s),r=(t,...i)=>{const e=1===t.length?t[0]:i.reduce(((i,s,e)=>i+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(s)+t[e+1]),t[0]);return new o(e,t,s)},h=(s,e)=>{i?s.adoptedStyleSheets=e.map((t=>t instanceof CSSStyleSheet?t:t.styleSheet)):e.forEach((i=>{const e=document.createElement("style"),o=t.litNonce;void 0!==o&&e.setAttribute("nonce",o),e.textContent=i.cssText,s.appendChild(e)}))},l=i?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let i="";for(const s of t.cssRules)i+=s.cssText;return n(i)})(t):t 7 | /** 8 | * @license 9 | * Copyright 2017 Google LLC 10 | * SPDX-License-Identifier: BSD-3-Clause 11 | */;var a;const u=window,c=u.trustedTypes,d=c?c.emptyScript:"",v=u.reactiveElementPolyfillSupport,p={toAttribute(t,i){switch(i){case Boolean:t=t?d:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,i){let s=t;switch(i){case Boolean:s=null!==t;break;case Number:s=null===t?null:Number(t);break;case Object:case Array:try{s=JSON.parse(t)}catch(t){s=null}}return s}},f=(t,i)=>i!==t&&(i==i||t==t),m={attribute:!0,type:String,converter:p,reflect:!1,hasChanged:f};class y extends HTMLElement{constructor(){super(),this.o=new Map,this.isUpdatePending=!1,this.hasUpdated=!1,this.l=null,this.u()}static addInitializer(t){var i;null!==(i=this.v)&&void 0!==i||(this.v=[]),this.v.push(t)}static get observedAttributes(){this.finalize();const t=[];return this.elementProperties.forEach(((i,s)=>{const e=this.p(s,i);void 0!==e&&(this.m.set(e,s),t.push(e))})),t}static createProperty(t,i=m){if(i.state&&(i.attribute=!1),this.finalize(),this.elementProperties.set(t,i),!i.noAccessor&&!this.prototype.hasOwnProperty(t)){const s="symbol"==typeof t?Symbol():"__"+t,e=this.getPropertyDescriptor(t,s,i);void 0!==e&&Object.defineProperty(this.prototype,t,e)}}static getPropertyDescriptor(t,i,s){return{get(){return this[i]},set(e){const o=this[t];this[i]=e,this.requestUpdate(t,o,s)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)||m}static finalize(){if(this.hasOwnProperty("finalized"))return!1;this.finalized=!0;const t=Object.getPrototypeOf(this);if(t.finalize(),this.elementProperties=new Map(t.elementProperties),this.m=new Map,this.hasOwnProperty("properties")){const t=this.properties,i=[...Object.getOwnPropertyNames(t),...Object.getOwnPropertySymbols(t)];for(const s of i)this.createProperty(s,t[s])}return this.elementStyles=this.finalizeStyles(this.styles),!0}static finalizeStyles(t){const i=[];if(Array.isArray(t)){const s=new Set(t.flat(1/0).reverse());for(const t of s)i.unshift(l(t))}else void 0!==t&&i.push(l(t));return i}static p(t,i){const s=i.attribute;return!1===s?void 0:"string"==typeof s?s:"string"==typeof t?t.toLowerCase():void 0}u(){var t;this._=new Promise((t=>this.enableUpdating=t)),this._$AL=new Map,this.g(),this.requestUpdate(),null===(t=this.constructor.v)||void 0===t||t.forEach((t=>t(this)))}addController(t){var i,s;(null!==(i=this.S)&&void 0!==i?i:this.S=[]).push(t),void 0!==this.renderRoot&&this.isConnected&&(null===(s=t.hostConnected)||void 0===s||s.call(t))}removeController(t){var i;null===(i=this.S)||void 0===i||i.splice(this.S.indexOf(t)>>>0,1)}g(){this.constructor.elementProperties.forEach(((t,i)=>{this.hasOwnProperty(i)&&(this.o.set(i,this[i]),delete this[i])}))}createRenderRoot(){var t;const i=null!==(t=this.shadowRoot)&&void 0!==t?t:this.attachShadow(this.constructor.shadowRootOptions);return h(i,this.constructor.elementStyles),i}connectedCallback(){var t;void 0===this.renderRoot&&(this.renderRoot=this.createRenderRoot()),this.enableUpdating(!0),null===(t=this.S)||void 0===t||t.forEach((t=>{var i;return null===(i=t.hostConnected)||void 0===i?void 0:i.call(t)}))}enableUpdating(t){}disconnectedCallback(){var t;null===(t=this.S)||void 0===t||t.forEach((t=>{var i;return null===(i=t.hostDisconnected)||void 0===i?void 0:i.call(t)}))}attributeChangedCallback(t,i,s){this._$AK(t,s)}$(t,i,s=m){var e;const o=this.constructor.p(t,s);if(void 0!==o&&!0===s.reflect){const n=(void 0!==(null===(e=s.converter)||void 0===e?void 0:e.toAttribute)?s.converter:p).toAttribute(i,s.type);this.l=t,null==n?this.removeAttribute(o):this.setAttribute(o,n),this.l=null}}_$AK(t,i){var s;const e=this.constructor,o=e.m.get(t);if(void 0!==o&&this.l!==o){const t=e.getPropertyOptions(o),n="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==(null===(s=t.converter)||void 0===s?void 0:s.fromAttribute)?t.converter:p;this.l=o,this[o]=n.fromAttribute(i,t.type),this.l=null}}requestUpdate(t,i,s){let e=!0;void 0!==t&&(((s=s||this.constructor.getPropertyOptions(t)).hasChanged||f)(this[t],i)?(this._$AL.has(t)||this._$AL.set(t,i),!0===s.reflect&&this.l!==t&&(void 0===this.C&&(this.C=new Map),this.C.set(t,s))):e=!1),!this.isUpdatePending&&e&&(this._=this.T())}async T(){this.isUpdatePending=!0;try{await this._}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){var t;if(!this.isUpdatePending)return;this.hasUpdated,this.o&&(this.o.forEach(((t,i)=>this[i]=t)),this.o=void 0);let i=!1;const s=this._$AL;try{i=this.shouldUpdate(s),i?(this.willUpdate(s),null===(t=this.S)||void 0===t||t.forEach((t=>{var i;return null===(i=t.hostUpdate)||void 0===i?void 0:i.call(t)})),this.update(s)):this.P()}catch(t){throw i=!1,this.P(),t}i&&this._$AE(s)}willUpdate(t){}_$AE(t){var i;null===(i=this.S)||void 0===i||i.forEach((t=>{var i;return null===(i=t.hostUpdated)||void 0===i?void 0:i.call(t)})),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}P(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._}shouldUpdate(t){return!0}update(t){void 0!==this.C&&(this.C.forEach(((t,i)=>this.$(i,this[i],t))),this.C=void 0),this.P()}updated(t){}firstUpdated(t){}} 12 | /** 13 | * @license 14 | * Copyright 2017 Google LLC 15 | * SPDX-License-Identifier: BSD-3-Clause 16 | */ 17 | var _;y.finalized=!0,y.elementProperties=new Map,y.elementStyles=[],y.shadowRootOptions={mode:"open"},null==v||v({ReactiveElement:y}),(null!==(a=u.reactiveElementVersions)&&void 0!==a?a:u.reactiveElementVersions=[]).push("1.4.1");const b=window,g=b.trustedTypes,w=g?g.createPolicy("lit-html",{createHTML:t=>t}):void 0,S=`lit$${(Math.random()+"").slice(9)}$`,$="?"+S,C=`<${$}>`,T=document,P=(t="")=>T.createComment(t),x=t=>null===t||"object"!=typeof t&&"function"!=typeof t,A=Array.isArray,k=t=>A(t)||"function"==typeof(null==t?void 0:t[Symbol.iterator]),E=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,M=/-->/g,U=/>/g,N=RegExp(">|[ \t\n\f\r](?:([^\\s\"'>=/]+)([ \t\n\f\r]*=[ \t\n\f\r]*(?:[^ \t\n\f\r\"'`<>=]|(\"|')|))|$)","g"),R=/'/g,O=/"/g,V=/^(?:script|style|textarea|title)$/i,j=t=>(i,...s)=>({_$litType$:t,strings:i,values:s}),z=j(1),L=j(2),I=Symbol.for("lit-noChange"),H=Symbol.for("lit-nothing"),B=new WeakMap,D=T.createTreeWalker(T,129,null,!1),q=(t,i)=>{const s=t.length-1,e=[];let o,n=2===i?"":"",r=E;for(let i=0;i"===l[0]?(r=null!=o?o:E,a=-1):void 0===l[1]?a=-2:(a=r.lastIndex-l[2].length,h=l[1],r=void 0===l[3]?N:'"'===l[3]?O:R):r===O||r===R?r=N:r===M||r===U?r=E:(r=N,o=void 0);const c=r===N&&t[i+1].startsWith("/>")?" ":"";n+=r===E?s+C:a>=0?(e.push(h),s.slice(0,a)+"$lit$"+s.slice(a)+S+c):s+S+(-2===a?(e.push(void 0),i):c)}const h=n+(t[s]||"")+(2===i?"":"");if(!Array.isArray(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return[void 0!==w?w.createHTML(h):h,e]};class J{constructor({strings:t,_$litType$:i},s){let e;this.parts=[];let o=0,n=0;const r=t.length-1,h=this.parts,[l,a]=q(t,i);if(this.el=J.createElement(l,s),D.currentNode=this.el.content,2===i){const t=this.el.content,i=t.firstChild;i.remove(),t.append(...i.childNodes)}for(;null!==(e=D.nextNode())&&h.length0){e.textContent=g?g.emptyScript:"";for(let s=0;s2||""!==s[0]||""!==s[1]?(this._$AH=Array(s.length-1).fill(new String),this.strings=s):this._$AH=H}get tagName(){return this.element.tagName}get _$AU(){return this._$AM._$AU}_$AI(t,i=this,s,e){const o=this.strings;let n=!1;if(void 0===o)t=W(this,t,i,0),n=!x(t)||t!==this._$AH&&t!==I,n&&(this._$AH=t);else{const e=t;let r,h;for(t=o[0],r=0;r{var e,o;const n=null!==(e=null==s?void 0:s.renderBefore)&&void 0!==e?e:i;let r=n._$litPart$;if(void 0===r){const t=null!==(o=null==s?void 0:s.renderBefore)&&void 0!==o?o:null;n._$litPart$=r=new F(i.insertBefore(P(),t),t,void 0,null!=s?s:{})}return r._$AI(t),r}; 18 | /** 19 | * @license 20 | * Copyright 2017 Google LLC 21 | * SPDX-License-Identifier: BSD-3-Clause 22 | */var ot,nt;const rt=y;class ht extends y{constructor(){super(...arguments),this.renderOptions={host:this},this.et=void 0}createRenderRoot(){var t,i;const s=super.createRenderRoot();return null!==(t=(i=this.renderOptions).renderBefore)&&void 0!==t||(i.renderBefore=s.firstChild),s}update(t){const i=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this.et=et(i,this.renderRoot,this.renderOptions)}connectedCallback(){var t;super.connectedCallback(),null===(t=this.et)||void 0===t||t.setConnected(!0)}disconnectedCallback(){var t;super.disconnectedCallback(),null===(t=this.et)||void 0===t||t.setConnected(!1)}render(){return I}}ht.finalized=!0,ht._$litElement$=!0,null===(ot=globalThis.litElementHydrateSupport)||void 0===ot||ot.call(globalThis,{LitElement:ht});const lt=globalThis.litElementPolyfillSupport;null==lt||lt({LitElement:ht});const at={_$AK:(t,i,s)=>{t._$AK(i,s)},_$AL:t=>t._$AL};(null!==(nt=globalThis.litElementVersions)&&void 0!==nt?nt:globalThis.litElementVersions=[]).push("3.2.2"); 23 | /** 24 | * @license 25 | * Copyright 2022 Google LLC 26 | * SPDX-License-Identifier: BSD-3-Clause 27 | */ 28 | const ut=!1;export{o as CSSResult,ht as LitElement,y as ReactiveElement,rt as UpdatingElement,at as _$LE,it as _$LH,h as adoptStyles,r as css,p as defaultConverter,l as getCompatibleStyle,z as html,ut as isServer,I as noChange,f as notEqual,H as nothing,et as render,i as supportsAdoptingStyleSheets,L as svg,n as unsafeCSS}; 29 | -------------------------------------------------------------------------------- /custom_components/scrypted/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "scrypted", 3 | "name": "Scrypted", 4 | "version": "0.0.9", 5 | "codeowners": ["@koush"], 6 | "config_flow": true, 7 | "dependencies": ["http"], 8 | "issue_tracker": "https://github.com/koush/ha_scrypted/issues", 9 | "documentation": "https://www.home-assistant.io/integrations/scrypted", 10 | "homekit": {}, 11 | "integration_type": "service", 12 | "iot_class": "local_push", 13 | "requirements": [], 14 | "ssdp": [], 15 | "zeroconf": [] 16 | } 17 | -------------------------------------------------------------------------------- /custom_components/scrypted/sensor.py: -------------------------------------------------------------------------------- 1 | """Representation of Z-Wave sensors.""" 2 | from __future__ import annotations 3 | 4 | from homeassistant.components.sensor import SensorEntity 5 | from homeassistant.config_entries import ConfigEntry 6 | from homeassistant.const import CONF_HOST 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 9 | 10 | from .const import DOMAIN 11 | 12 | 13 | async def async_setup_entry( 14 | hass: HomeAssistant, 15 | config_entry: ConfigEntry, 16 | async_add_entities: AddEntitiesCallback, 17 | ) -> None: 18 | """Set up Scrypted token sensor from config entry.""" 19 | token = next( 20 | token 21 | for token, entry in hass.data[DOMAIN].items() 22 | if entry.entry_id == config_entry.entry_id 23 | ) 24 | async_add_entities([ScryptedTokenSensor(config_entry, token)]) 25 | 26 | 27 | class ScryptedTokenSensor(SensorEntity): 28 | """Representation of a Scrypted token sensor.""" 29 | 30 | def __init__( 31 | self, 32 | config_entry: ConfigEntry, 33 | token: str, 34 | ) -> None: 35 | """Initialize a ScryptedTokenSensor entity.""" 36 | self._attr_name = f"{DOMAIN.title()} token: {config_entry.data[CONF_HOST]}" 37 | self._attr_unique_id = config_entry.data[CONF_HOST] 38 | self._attr_native_value = token 39 | self._attr_icon = "mdi:shield-key" 40 | self._attr_should_poll = False 41 | self._attr_extra_state_attributes = {CONF_HOST: config_entry.data[CONF_HOST]} 42 | -------------------------------------------------------------------------------- /custom_components/scrypted/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "already_configured": "Each Scrypted entry needs a unique name.", 5 | "invalid_host_or_credentials": "Unable to login to Scrypted server, either host or credentials are invalid." 6 | }, 7 | "abort": { 8 | "success": "Your config entry was successfully updated" 9 | }, 10 | "step": { 11 | "user": { 12 | "description": "Enter your Scrypted server details (you must use an HTTPS endpoint) as well as a name and icon to use for the new panel on the UI.\n\nHost examples:\n- `192.168.1.124`\n- `192.168.1.124:10443`", 13 | "data": { 14 | "host": "Host", 15 | "icon": "Icon", 16 | "name": "Name", 17 | "password": "Password", 18 | "username": "Username", 19 | "scrypted_nvr": "Replace Scrypted Management Console sidebar with Scrypted NVR sidebar. Requires Scrypted NVR." 20 | } 21 | }, 22 | "upgrade": { 23 | "description": "Once you enter your credentials, your config entry will now automatically set up a panel for you in the UI with the specified name and icon. Once this is complete, you can remove the `panel_iframe` in your configuration.yaml if you created one for this entry. If your credentials don't work, validate that the server host is an HTTPS endpoint and that your credentials are correct.", 24 | "data": { 25 | "host": "Host", 26 | "icon": "Icon", 27 | "name": "Name", 28 | "password": "Password", 29 | "username": "Username" 30 | } 31 | }, 32 | "credentials": { 33 | "description": "The host/credentials combination is no longer working. Please update your host and/or credentials to continue.", 34 | "data": { 35 | "host": "Host", 36 | "icon": "Icon", 37 | "name": "Name", 38 | "password": "Password", 39 | "username": "Username" 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /custom_components/scrypted/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "success": "Your config entry was successfully updated" 5 | }, 6 | "error": { 7 | "already_configured": "Each Scrypted entry needs a unique name.", 8 | "invalid_host_or_credentials": "Unable to login to Scrypted server, either host or credentials are invalid." 9 | }, 10 | "step": { 11 | "credentials": { 12 | "data": { 13 | "host": "Host", 14 | "icon": "Icon", 15 | "name": "Name", 16 | "password": "Password", 17 | "username": "Username" 18 | }, 19 | "description": "The host/credentials combination is no longer working. Please update your host and/or credentials to continue." 20 | }, 21 | "upgrade": { 22 | "data": { 23 | "host": "Host", 24 | "icon": "Icon", 25 | "name": "Name", 26 | "password": "Password", 27 | "username": "Username" 28 | }, 29 | "description": "Once you enter your credentials, your config entry will now automatically set up a panel for you in the UI with the specified name and icon. Once this is complete, you can remove the `panel_iframe` in your configuration.yaml if you created one for this entry. If your credentials don't work, validate that the server host is an HTTPS endpoint and that your credentials are correct." 30 | }, 31 | "user": { 32 | "data": { 33 | "host": "Host", 34 | "icon": "Icon", 35 | "name": "Name", 36 | "password": "Password", 37 | "username": "Username", 38 | "scrypted_nvr": "Replace Scrypted Management Console sidebar with Scrypted NVR sidebar. Requires Scrypted NVR." 39 | }, 40 | "description": "Enter your Scrypted server details (you must use an HTTPS endpoint) as well as a name and icon to use for the new panel on the UI.\n\nHost examples:\n- `192.168.1.124`\n- `192.168.1.124:10443`" 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Scrypted", 3 | "render_readme": true 4 | } 5 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | homeassistant>=2023.6.2 --------------------------------------------------------------------------------