├── .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 |
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?"":"");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
--------------------------------------------------------------------------------