committed by
dermotduffy
parent
9384785d37
commit
1cd5520154
+5
-2
@@ -9,7 +9,10 @@ module.exports = {
|
|||||||
'prettier', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier
|
'prettier', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
// Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs
|
// Place to specify ESLint rules. Can be used to overwrite rules specified
|
||||||
// e.g. "@typescript-eslint/explicit-function-return-type": "off",
|
// from the extended configs e.g.
|
||||||
|
// "@typescript-eslint/explicit-function-return-type": "off",
|
||||||
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
|
'no-multi-assign': 'error',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,6 +35,9 @@
|
|||||||
- Never use non-null assertions (`!`); use a null check with an early return or conditional instead.
|
- Never use non-null assertions (`!`); use a null check with an early return or conditional instead.
|
||||||
- Use `zod` for runtime validation if external data is involved.
|
- Use `zod` for runtime validation if external data is involved.
|
||||||
- `noUnusedParameters` and `noImplicitReturns` are enforced — all parameters must be used and all code paths must return.
|
- `noUnusedParameters` and `noImplicitReturns` are enforced — all parameters must be used and all code paths must return.
|
||||||
|
- Prefer explicitly returning `null` rather than `undefined` for absent or empty state values.
|
||||||
|
- Prefer `async`/`await` over verbose Promise chaining (`.then().catch()`).
|
||||||
|
- Prefer instantiating `Date` objects for timestamps rather than using raw numbers (e.g. `Date.now()`).
|
||||||
|
|
||||||
- **Testing (Vitest):**
|
- **Testing (Vitest):**
|
||||||
|
|
||||||
|
|||||||
@@ -334,12 +334,7 @@ Configures whether and how the content is proxied via
|
|||||||
[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration)
|
[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration)
|
||||||
(this must be installed separately). This allows fetching media or live streams
|
(this must be installed separately). This allows fetching media or live streams
|
||||||
**through** the Home Assistant process itself, allowing the card to access
|
**through** the Home Assistant process itself, allowing the card to access
|
||||||
resources it otherwise would not be able to directly access. There are [security
|
resources it otherwise would not be able to directly access.
|
||||||
and performance
|
|
||||||
implications](https://github.com/dermotduffy/hass-web-proxy-integration?tab=readme-ov-file#considerations)
|
|
||||||
to consider before installing
|
|
||||||
[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration)
|
|
||||||
and using this functionality.
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
cameras:
|
cameras:
|
||||||
@@ -350,6 +345,8 @@ cameras:
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
[](../common/proxy-warning.md ':include')
|
||||||
|
|
||||||
For live streams, only the `go2rtc` [live provider](./live-provider.md) currently supports live stream proxying.
|
For live streams, only the `go2rtc` [live provider](./live-provider.md) currently supports live stream proxying.
|
||||||
|
|
||||||
For media, not all [engines](./engine.md) benefit from proxying:
|
For media, not all [engines](./engine.md) benefit from proxying:
|
||||||
@@ -360,17 +357,12 @@ For media, not all [engines](./engine.md) benefit from proxying:
|
|||||||
| `reolink`, `motioneye` | May be used to fetch videos in cases where the browser may not be able to access the camera/NVR, or the camera/NVR may use a self-signed SSL certificate that your browser would otherwise reject due to [mixed content](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Mixed_content). |
|
| `reolink`, `motioneye` | May be used to fetch videos in cases where the browser may not be able to access the camera/NVR, or the camera/NVR may use a self-signed SSL certificate that your browser would otherwise reject due to [mixed content](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Mixed_content). |
|
||||||
| `generic` | `generic` cameras do not have media, so proxying currently would serve no purpose. |
|
| `generic` | `generic` cameras do not have media, so proxying currently would serve no purpose. |
|
||||||
|
|
||||||
Regardless of the parameters, the integration will never attempt to proxy
|
|
||||||
content if the
|
|
||||||
[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration)
|
|
||||||
is not detected.
|
|
||||||
|
|
||||||
Proxying parameters:
|
Proxying parameters:
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| ------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `live` | `auto` | Whether or not to proxy live streams. `true` to proxy, `false` to not proxy, or `auto` to allow the camera engine to decide whether to proxy or not. Not all [live providers](./live-provider.md) support proxying live streams. |
|
| `live` | `auto` | Whether or not to proxy live streams. `true` to proxy (will show an error if the proxy integration is unavailable), `false` to not proxy, or `auto` to allow the camera engine to decide whether to proxy or not. If `auto` resolves to proxying, the card will still fall back to the original URL if the proxy integration is unavailable. Not all [live providers](./live-provider.md) support proxying live streams. |
|
||||||
| `media` | `auto` | Whether or not to proxy media items. `true` to proxy, `false` to not proxy, or `auto` to allow the camera engine to decide whether to proxy or not. |
|
| `media` | `auto` | Whether or not to proxy media items. `true` to proxy (will show an error if the proxy integration is unavailable), `false` to not proxy, or `auto` to allow the camera engine to decide whether to proxy or not. If `auto` resolves to proxying, the card will still fall back to the original URL if the proxy integration is unavailable. |
|
||||||
| `dynamic` | `true` | Whether to dynamically (at the time) request proxying of the required media item, or rely on statically user-configured pre-existing proxying. See the [hass-web-proxy-integration documentation](https://github.com/dermotduffy/hass-web-proxy-integration). |
|
| `dynamic` | `true` | Whether to dynamically (at the time) request proxying of the required media item, or rely on statically user-configured pre-existing proxying. See the [hass-web-proxy-integration documentation](https://github.com/dermotduffy/hass-web-proxy-integration). |
|
||||||
| `ssl_verification` | `auto` | Whether to verify the validity of SSL certificates. If `true` always verifies, if `false` never verifies and if `auto` the [engine](./engine.md) decides the best setting for that camera ecosystem. |
|
| `ssl_verification` | `auto` | Whether to verify the validity of SSL certificates. If `true` always verifies, if `false` never verifies and if `auto` the [engine](./engine.md) decides the best setting for that camera ecosystem. |
|
||||||
| `ssl_ciphers` | `auto` | Whether to use `default`, `intermediate`, `insecure` or `modern` SSL ciphers. See the [Home Assistant code](https://github.com/home-assistant/core/blob/dev/homeassistant/util/ssl.py) for the precise list of SSL ciphers each implies. If `auto` the [engine](./engine.md) decides the best setting for that camera ecosystem. |
|
| `ssl_ciphers` | `auto` | Whether to use `default`, `intermediate`, `insecure` or `modern` SSL ciphers. See the [Home Assistant code](https://github.com/home-assistant/core/blob/dev/homeassistant/util/ssl.py) for the precise list of SSL ciphers each implies. If `auto` the [engine](./engine.md) decides the best setting for that camera ecosystem. |
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
A "Camera Engine" defines what "type" of camera is being configured (e.g. `frigate`), each engine offers different capabilities:
|
A "Camera Engine" defines what "type" of camera is being configured (e.g. `frigate`), each engine offers different capabilities:
|
||||||
|
|
||||||
| Engine | Live | Supports clips | Supports Snapshots | Supports Recordings | Supports Timeline | Supports PTZ out of the box | Supports manually configured PTZ | Favorite events | Favorite recordings | Detect new events | Detect new snapshots | Detect new clips | May requiring [proxying](./README.md?id=proxy) | Thumbnails |
|
| Engine | Live | Supports clips | Supports Snapshots | Supports Recordings | Supports Timeline | Supports PTZ out of the box | Supports manually configured PTZ | Favorite events | Favorite recordings | Detect new events | Detect new snapshots | Detect new clips | May require [proxying](./README.md?id=proxy) | Thumbnails |
|
||||||
| ----------- | ------------------ | ------------------------ | ------------------------ | ------------------------ | ------------------------ | --------------------------- | -------------------------------- | ------------------------ | ------------------------ | ------------------------ | ------------------------ | ------------------------ | ---------------------------------------------- | ------------------------ |
|
| ----------- | ------------------ | ------------------------ | ------------------------ | ------------------------ | ------------------------ | --------------------------- | -------------------------------- | ------------------------ | ------------------------ | ------------------------ | ------------------------ | ------------------------ | -------------------------------------------- | ------------------------ |
|
||||||
| `frigate` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: |
|
| `frigate` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: |
|
||||||
| `generic` | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: |
|
| `generic` | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: |
|
||||||
| `motioneye` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | :white_check_mark: |
|
| `motioneye` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | :white_check_mark: |
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ cameras:
|
|||||||
| `entity_parameters` | | Optional URL parameters to add to the URL generated for entity-based modes (i.e. when `mode` is `camera` or `entity`). |
|
| `entity_parameters` | | Optional URL parameters to add to the URL generated for entity-based modes (i.e. when `mode` is `camera` or `entity`). |
|
||||||
| `mode` | `auto` | Value must be one of `url` (to fetch an arbitrary image URL), `camera` (to show a still of the currently selected camera entity using either `camera_entity` or `webrtc_card.entity` in that order of precedence), `entity` to show an image associated with a named entity (see the `entity` parameter below), or `screensaver` (to show an [embedded image](https://github.com/dermotduffy/advanced-camera-card/blob/main/src/images/iris-screensaver.jpg)). If `auto`, the mode is chosen automatically based on whether `url` or `entity` parameters have been specified. |
|
| `mode` | `auto` | Value must be one of `url` (to fetch an arbitrary image URL), `camera` (to show a still of the currently selected camera entity using either `camera_entity` or `webrtc_card.entity` in that order of precedence), `entity` to show an image associated with a named entity (see the `entity` parameter below), or `screensaver` (to show an [embedded image](https://github.com/dermotduffy/advanced-camera-card/blob/main/src/images/iris-screensaver.jpg)). If `auto`, the mode is chosen automatically based on whether `url` or `entity` parameters have been specified. |
|
||||||
| `refresh_seconds` | 1 | The image will be refreshed at least every `refresh_seconds` (it may refresh more frequently, e.g. whenever Home Assistant updates its camera security token). `0` implies no refreshing. |
|
| `refresh_seconds` | 1 | The image will be refreshed at least every `refresh_seconds` (it may refresh more frequently, e.g. whenever Home Assistant updates its camera security token). `0` implies no refreshing. |
|
||||||
| `url` | | A static image URL to be used when the `mode` is set to `url` or when a temporary image is required (e.g. may appear momentarily prior to load of a camera snapshot in the `camera` mode). Note that a `_t=[timestsamp]` query parameter will be automatically added to all URLs such that the image will not be cached by the browser. |
|
| `url` | | A static image URL to be used when the `mode` is set to `url` or when a temporary image is required (e.g. may appear momentarily prior to load of a camera snapshot in the `camera` mode). Note that a `_t=[timestamp]` cache-busting value will be added automatically. |
|
||||||
|
|
||||||
|
[](../common/proxy-warning.md ':include')
|
||||||
|
|
||||||
## `jsmpeg`
|
## `jsmpeg`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
There are [security and performance
|
||||||
|
implications](https://github.com/dermotduffy/hass-web-proxy-integration?tab=readme-ov-file#considerations)
|
||||||
|
to consider before installing
|
||||||
|
[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration)
|
||||||
|
and using this functionality.
|
||||||
|
|
||||||
|
If the
|
||||||
|
[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration)
|
||||||
|
is not detected, the card will fall back to the original URL unless proxying was
|
||||||
|
explicitly enabled (i.e. set to `true`), in which case an error will be shown.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> When proxying, the card's ability to ensure caching doesn't interfere with
|
||||||
|
> refresh accuracy depends on what is being proxied. Specifically, `image`
|
||||||
|
> based proxying will have less reliable behavior because signed URLs cannot
|
||||||
|
> have additional query parameters added without invalidating the signature. The
|
||||||
|
> card uses a URL fragment (`#_t=...`) for cache-busting instead, which does not
|
||||||
|
> affect the signature but is less reliable: the fragment is not sent to the
|
||||||
|
> server, so the browser may serve a cached response. Reliable refreshing will
|
||||||
|
> depend on the source server providing appropriate `Cache-Control` headers. The
|
||||||
|
> Home Assistant companion app's WebView does not re-fetch images when only the
|
||||||
|
> fragment changes, so proxied image refreshing will not work in the companion
|
||||||
|
> app.
|
||||||
@@ -13,12 +13,37 @@ image:
|
|||||||
| `entity` | | The entity to use when `mode` is set to `entity`. This entity is expected to have an `entity_picture` attribute that specifies the image URL. |
|
| `entity` | | The entity to use when `mode` is set to `entity`. This entity is expected to have an `entity_picture` attribute that specifies the image URL. |
|
||||||
| `entity_parameters` | | Optional URL parameters to add to the URL generated for entity-based modes (i.e. when `mode` is `camera` or `entity`). |
|
| `entity_parameters` | | Optional URL parameters to add to the URL generated for entity-based modes (i.e. when `mode` is `camera` or `entity`). |
|
||||||
| `mode` | `auto` | Value must be one of `url` (to fetch an arbitrary image URL), `camera` (to show a still of the currently selected camera entity using either `camera_entity` or `webrtc_card.entity` in that order of precedence), `entity` to show an image associated with a named entity (see the `entity` parameter below), or `screensaver` (to show an [embedded image](https://github.com/dermotduffy/advanced-camera-card/blob/main/src/images/iris-screensaver.jpg)). If `auto`, the mode is chosen automatically based on whether `url` or `entity` parameters have been specified. |
|
| `mode` | `auto` | Value must be one of `url` (to fetch an arbitrary image URL), `camera` (to show a still of the currently selected camera entity using either `camera_entity` or `webrtc_card.entity` in that order of precedence), `entity` to show an image associated with a named entity (see the `entity` parameter below), or `screensaver` (to show an [embedded image](https://github.com/dermotduffy/advanced-camera-card/blob/main/src/images/iris-screensaver.jpg)). If `auto`, the mode is chosen automatically based on whether `url` or `entity` parameters have been specified. |
|
||||||
|
| `proxy` | | Proxy configuration for `url` mode images. See [proxy](#proxy) below. |
|
||||||
| `refresh_seconds` | 1 | The image will be refreshed at least every `refresh_seconds` (it may refresh more frequently, e.g. whenever Home Assistant updates its camera security token). `0` implies no refreshing. |
|
| `refresh_seconds` | 1 | The image will be refreshed at least every `refresh_seconds` (it may refresh more frequently, e.g. whenever Home Assistant updates its camera security token). `0` implies no refreshing. |
|
||||||
| `url` | | A static image URL to be used when the `mode` is set to `url` or when a temporary image is required (e.g. may appear momentarily prior to load of a camera snapshot in the `camera` mode). Note that a `_t=[timestsamp]` query parameter will be automatically added to all URLs such that the image will not be cached by the browser. |
|
| `url` | | A static image URL to be used when the `mode` is set to `url` or when a temporary image is required (e.g. may appear momentarily prior to load of a camera snapshot in the `camera` mode). Note that a `_t=[timestamp]` cache-busting value will be added automatically. |
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> When `mode` is set to `camera` this is effectively providing the same image as the `image` [live provider](cameras/live-provider.md) would show in the live camera carousel.
|
> When `mode` is set to `camera` this is effectively providing the same image as the `image` [live provider](cameras/live-provider.md) would show in the live camera carousel.
|
||||||
|
|
||||||
|
## `proxy`
|
||||||
|
|
||||||
|
Configures whether and how the image URL is proxied via
|
||||||
|
[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration)
|
||||||
|
(this must be installed separately). This is useful when the image URL uses HTTP
|
||||||
|
but Home Assistant is served over HTTPS, which would otherwise be blocked by the
|
||||||
|
browser as [mixed
|
||||||
|
content](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Mixed_content).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
image:
|
||||||
|
proxy:
|
||||||
|
# [...]
|
||||||
|
```
|
||||||
|
|
||||||
|
[](common/proxy-warning.md ':include')
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
| ------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `enabled` | `false` | Whether or not to proxy image URLs when in `url` mode. `true` to proxy (will show an error if the proxy integration is unavailable), `false` to not proxy. |
|
||||||
|
| `dynamic` | `true` | Whether to dynamically (at the time) request proxying of the required URL, or rely on statically user-configured pre-existing proxying. See the [hass-web-proxy-integration documentation](https://github.com/dermotduffy/hass-web-proxy-integration). |
|
||||||
|
| `ssl_verification` | `auto` | Whether to verify the validity of SSL certificates. If `true` always verifies, if `false` never verifies and if `auto` defaults to `true`. |
|
||||||
|
| `ssl_ciphers` | `auto` | Whether to use `default`, `intermediate`, `insecure` or `modern` SSL ciphers. See the [Home Assistant code](https://github.com/home-assistant/core/blob/dev/homeassistant/util/ssl.py) for the precise list of SSL ciphers each implies. If `auto` defaults to `default`. |
|
||||||
|
|
||||||
## Fully expanded reference
|
## Fully expanded reference
|
||||||
|
|
||||||
[](common/expanded-warning.md ':include')
|
[](common/expanded-warning.md ':include')
|
||||||
@@ -30,6 +55,11 @@ image:
|
|||||||
url: 'https://path/to/image.png'
|
url: 'https://path/to/image.png'
|
||||||
entity: image.office_person
|
entity: image.office_person
|
||||||
entity_parameters: 'width=400&height=200'
|
entity_parameters: 'width=400&height=200'
|
||||||
|
proxy:
|
||||||
|
enabled: false
|
||||||
|
dynamic: true
|
||||||
|
ssl_verification: auto
|
||||||
|
ssl_ciphers: auto
|
||||||
actions:
|
actions:
|
||||||
entity: light.office_main_lights
|
entity: light.office_main_lights
|
||||||
tap_action:
|
tap_action:
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 105 KiB |
+1
-1
@@ -185,7 +185,7 @@
|
|||||||
"docs-check-links": "docker run --init --rm -v \"$(pwd):/input\" -w /input lycheeverse/lychee --config lychee.toml \"./docs/\"",
|
"docs-check-links": "docker run --init --rm -v \"$(pwd):/input\" -w /input lycheeverse/lychee --config lychee.toml \"./docs/\"",
|
||||||
"docs-update-images": "./scripts/docs-update-images.sh",
|
"docs-update-images": "./scripts/docs-update-images.sh",
|
||||||
"docs-update-dependencies": "./scripts/docs-update-dependencies.sh",
|
"docs-update-dependencies": "./scripts/docs-update-dependencies.sh",
|
||||||
"lint": "eslint 'src/**/*.ts'",
|
"lint": "eslint '{src,tests}/**/*.ts'",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format-check": "prettier --check .",
|
"format-check": "prettier --check .",
|
||||||
"rollup": "rollup -c",
|
"rollup": "rollup -c",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ActionsExecutor } from '../card-controller/actions/types';
|
|||||||
import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher';
|
import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher';
|
||||||
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
|
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
|
||||||
import { CameraConfig } from '../config/schema/cameras';
|
import { CameraConfig } from '../config/schema/cameras';
|
||||||
|
import { EnabledProxyConfig, resolveProxyConfig } from '../config/schema/common/proxy';
|
||||||
import { isTriggeredState } from '../ha/is-triggered-state';
|
import { isTriggeredState } from '../ha/is-triggered-state';
|
||||||
import { HassStateDifference, HomeAssistant } from '../ha/types';
|
import { HassStateDifference, HomeAssistant } from '../ha/types';
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
@@ -100,6 +101,8 @@ export class Camera {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected async _has2WayAudioCapability(hass: HomeAssistant): Promise<boolean> {
|
protected async _has2WayAudioCapability(hass: HomeAssistant): Promise<boolean> {
|
||||||
|
// Check disable/disableExcept/force early to short-circuit the expensive
|
||||||
|
// network call to fetch go2rtc metadata.
|
||||||
if (this._config.capabilities?.disable?.includes('2-way-audio')) {
|
if (this._config.capabilities?.disable?.includes('2-way-audio')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -115,7 +118,7 @@ export class Camera {
|
|||||||
this.getConfig(),
|
this.getConfig(),
|
||||||
this.getConfig().go2rtc.metadata_fetch_timeout_seconds,
|
this.getConfig().go2rtc.metadata_fetch_timeout_seconds,
|
||||||
this._getGo2RTCMetadataEndpoint(),
|
this._getGo2RTCMetadataEndpoint(),
|
||||||
this.getProxyConfig(),
|
this.getLiveProxyConfig(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +140,7 @@ export class Camera {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async destroy(): Promise<void> {
|
public async destroy(): Promise<void> {
|
||||||
this._destroyCallbacks.forEach((callback) => callback());
|
await Promise.all(this._destroyCallbacks.map((callback) => callback()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public getConfig(): CameraConfig {
|
public getConfig(): CameraConfig {
|
||||||
@@ -204,6 +207,7 @@ export class Camera {
|
|||||||
|
|
||||||
public getProxyConfig(): CameraProxyConfig {
|
public getProxyConfig(): CameraProxyConfig {
|
||||||
return {
|
return {
|
||||||
|
...resolveProxyConfig(this._config.proxy),
|
||||||
live:
|
live:
|
||||||
this._config.proxy.live === 'auto'
|
this._config.proxy.live === 'auto'
|
||||||
? // Live is proxied if the live provider is go2rtc and if a go2rtc
|
? // Live is proxied if the live provider is go2rtc and if a go2rtc
|
||||||
@@ -211,13 +215,32 @@ export class Camera {
|
|||||||
this._config.live_provider === 'go2rtc' && !!this._config.go2rtc?.url
|
this._config.live_provider === 'go2rtc' && !!this._config.go2rtc?.url
|
||||||
: this._config.proxy.live,
|
: this._config.proxy.live,
|
||||||
media: this._config.proxy.media === 'auto' ? false : this._config.proxy.media,
|
media: this._config.proxy.media === 'auto' ? false : this._config.proxy.media,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
dynamic: this._config.proxy.dynamic,
|
public getLiveProxyConfig(): EnabledProxyConfig {
|
||||||
ssl_verification: this._config.proxy.ssl_verification !== false,
|
const config = this.getProxyConfig();
|
||||||
ssl_ciphers:
|
return {
|
||||||
this._config.proxy.ssl_ciphers === 'auto'
|
...config,
|
||||||
? 'default'
|
|
||||||
: this._config.proxy.ssl_ciphers,
|
// `enabled` uses the resolved engine decision (so `auto` may become
|
||||||
|
// true), whereas `enforce` uses the raw user setting so only an explicit
|
||||||
|
// `true` means "fail instead of falling back" if the proxy is unavailable.
|
||||||
|
enabled: config.live,
|
||||||
|
enforce: this._config.proxy.live === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMediaProxyConfig(): EnabledProxyConfig {
|
||||||
|
const config = this.getProxyConfig();
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
|
||||||
|
// `enabled` uses the resolved engine decision (so `auto` may become
|
||||||
|
// true), whereas `enforce` uses the raw user setting so only an explicit
|
||||||
|
// `true` means "fail instead of falling back" if the proxy is unavailable.
|
||||||
|
enabled: config.media,
|
||||||
|
enforce: this._config.proxy.media === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,11 +273,10 @@ export class Camera {
|
|||||||
this._destroyCallbacks.push(callback);
|
this._destroyCallbacks.push(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _subscribeBasedOnCapabilities(
|
private _subscribeBasedOnCapabilities(
|
||||||
stateWatcher: StateWatcherSubscriptionInterface,
|
stateWatcher: StateWatcherSubscriptionInterface,
|
||||||
): void {
|
): void {
|
||||||
if (this._capabilities?.has('trigger')) {
|
if (this._capabilities?.has('trigger')) {
|
||||||
stateWatcher.unsubscribe(this._stateChangeHandler);
|
|
||||||
stateWatcher.subscribe(this._stateChangeHandler, this._config.triggers.entities);
|
stateWatcher.subscribe(this._stateChangeHandler, this._config.triggers.entities);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ExpiringEqualityCache } from '../cache/expiring-cache';
|
import { ExpiringEqualityCache } from '../cache/expiring-cache';
|
||||||
import { SSLCiphers } from '../config/schema/cameras';
|
|
||||||
import { AdvancedCameraCardView } from '../config/schema/common/const';
|
import { AdvancedCameraCardView } from '../config/schema/common/const';
|
||||||
import { InternalIcon } from '../config/schema/common/icon';
|
import { InternalIcon } from '../config/schema/common/icon';
|
||||||
|
import { ResolvedProxyConfig } from '../config/schema/common/proxy';
|
||||||
import { BaseQuery, QueryFilters, QuerySource } from '../query-source';
|
import { BaseQuery, QueryFilters, QuerySource } from '../query-source';
|
||||||
import { CapabilityKey, Endpoint } from '../types';
|
import { CapabilityKey, Endpoint } from '../types';
|
||||||
import { ViewMedia } from '../view/item';
|
import { ViewMedia } from '../view/item';
|
||||||
@@ -137,12 +137,9 @@ export interface CameraEndpoints {
|
|||||||
webrtcCard?: Endpoint;
|
webrtcCard?: Endpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CameraProxyConfig {
|
export interface CameraProxyConfig extends ResolvedProxyConfig {
|
||||||
dynamic: boolean;
|
|
||||||
live: boolean;
|
live: boolean;
|
||||||
media: boolean;
|
media: boolean;
|
||||||
ssl_verification: boolean;
|
|
||||||
ssl_ciphers: SSLCiphers;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EngineOptions {
|
export interface EngineOptions {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import { EnabledProxyConfig } from '../../../config/schema/common/proxy';
|
||||||
import { homeAssistantSignAndFetch } from '../../../ha/fetch';
|
import { homeAssistantSignAndFetch } from '../../../ha/fetch';
|
||||||
import { HomeAssistant } from '../../../ha/types';
|
import { HomeAssistant } from '../../../ha/types';
|
||||||
import { createProxiedEndpointIfNecessary } from '../../../ha/web-proxy';
|
import { createProxiedEndpointIfNecessary } from '../../../ha/web-proxy';
|
||||||
import { Endpoint } from '../../../types';
|
import { Endpoint } from '../../../types';
|
||||||
import { errorToConsole } from '../../../utils/basic';
|
import { errorToConsole } from '../../../utils/basic';
|
||||||
import { CameraProxyConfig } from '../../types';
|
|
||||||
import { Go2RTCStreamInfo, go2RTCStreamInfoSchema } from './types';
|
import { Go2RTCStreamInfo, go2RTCStreamInfoSchema } from './types';
|
||||||
|
|
||||||
const getGo2RTCStreamMetadata = async (
|
const getGo2RTCStreamMetadata = async (
|
||||||
@@ -44,14 +44,14 @@ const streamSupports2WayAudio = (streamInfo: Go2RTCStreamInfo | null): boolean =
|
|||||||
*
|
*
|
||||||
* @param hass Home Assistant instance.
|
* @param hass Home Assistant instance.
|
||||||
* @param go2rtcMetadataEndpoint The go2rtc metadata endpoint.
|
* @param go2rtcMetadataEndpoint The go2rtc metadata endpoint.
|
||||||
* @param proxyConfig The camera's proxy configuration for live streams.
|
* @param proxyConfig The resolved proxy configuration for live streams.
|
||||||
* @returns True if supports 2-way audio, false otherwise.
|
* @returns True if supports 2-way audio, false otherwise.
|
||||||
*/
|
*/
|
||||||
export const supports2WayAudio = async (
|
export const supports2WayAudio = async (
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
metadataFetchTimeoutSeconds: number,
|
metadataFetchTimeoutSeconds: number,
|
||||||
go2rtcMetadataEndpoint?: Endpoint | null,
|
go2rtcMetadataEndpoint?: Endpoint | null,
|
||||||
proxyConfig?: CameraProxyConfig,
|
proxyConfig?: EnabledProxyConfig,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
if (!go2rtcMetadataEndpoint) {
|
if (!go2rtcMetadataEndpoint) {
|
||||||
return false;
|
return false;
|
||||||
@@ -61,8 +61,11 @@ export const supports2WayAudio = async (
|
|||||||
hass,
|
hass,
|
||||||
go2rtcMetadataEndpoint,
|
go2rtcMetadataEndpoint,
|
||||||
proxyConfig,
|
proxyConfig,
|
||||||
{ context: 'live', openLimit: 1 },
|
{ openLimit: 1 },
|
||||||
);
|
);
|
||||||
|
if (!endpoint) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const streamInfo = await getGo2RTCStreamMetadata(
|
const streamInfo = await getGo2RTCStreamMetadata(
|
||||||
hass,
|
hass,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { homeAssistantGetSignedURLIfNecessary } from '../../ha/sign-path';
|
||||||
import { localize } from '../../localize/localize';
|
import { localize } from '../../localize/localize';
|
||||||
import { AdvancedCameraCardError } from '../../types';
|
import { AdvancedCameraCardError } from '../../types';
|
||||||
import { errorToConsole } from '../../utils/basic';
|
import { errorToConsole } from '../../utils/basic';
|
||||||
import { downloadURL } from '../../utils/download';
|
import { downloadURL } from '../../utils/download';
|
||||||
import { homeAssistantSignPath } from '../../ha/sign-path';
|
|
||||||
import { ViewItem } from '../../view/item';
|
import { ViewItem } from '../../view/item';
|
||||||
import { ViewItemClassifier } from '../../view/item-classifier';
|
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||||
import { ViewItemCapabilities } from '../../view/types';
|
import { ViewItemCapabilities } from '../../view/types';
|
||||||
@@ -90,22 +90,19 @@ export class ViewItemManager {
|
|||||||
throw new AdvancedCameraCardError(localize('error.download_no_media'));
|
throw new AdvancedCameraCardError(localize('error.download_no_media'));
|
||||||
}
|
}
|
||||||
|
|
||||||
let finalURL = endpoint.endpoint;
|
let url: string | null;
|
||||||
if (endpoint.sign) {
|
|
||||||
let response: string | null | undefined;
|
|
||||||
try {
|
try {
|
||||||
response = await homeAssistantSignPath(hass, endpoint.endpoint);
|
url = await homeAssistantGetSignedURLIfNecessary(hass, endpoint);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToConsole(e as Error);
|
errorToConsole(e as Error);
|
||||||
|
url = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response) {
|
if (!url) {
|
||||||
throw new AdvancedCameraCardError(localize('error.download_sign_failed'));
|
throw new AdvancedCameraCardError(localize('error.download_sign_failed'));
|
||||||
}
|
}
|
||||||
finalURL = response;
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadURL(finalURL, this._generateDownloadFilename(item));
|
downloadURL(url, this._generateDownloadFilename(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
private _generateDownloadFilename(item: ViewItem): string {
|
private _generateDownloadFilename(item: ViewItem): string {
|
||||||
|
|||||||
@@ -2,40 +2,42 @@ import { ReactiveController, ReactiveControllerHost } from 'lit';
|
|||||||
import { Timer } from '../utils/timer';
|
import { Timer } from '../utils/timer';
|
||||||
|
|
||||||
export class CachedValueController<T> implements ReactiveController {
|
export class CachedValueController<T> implements ReactiveController {
|
||||||
private _value?: T;
|
private _host: ReactiveControllerHost & HTMLElement;
|
||||||
private _host: ReactiveControllerHost;
|
|
||||||
private _timerSeconds: number;
|
private _value: T | null = null;
|
||||||
|
|
||||||
|
private _timerSeconds: number | null = null;
|
||||||
|
|
||||||
private _callback: () => T;
|
private _callback: () => T;
|
||||||
|
|
||||||
|
private _getTimerSecondsCallback: () => number | null;
|
||||||
|
|
||||||
private _timerStartCallback?: () => void;
|
private _timerStartCallback?: () => void;
|
||||||
private _timerStopCallback?: () => void;
|
private _timerStopCallback?: () => void;
|
||||||
|
private _timerTickCallback?: () => void;
|
||||||
private _timer = new Timer();
|
private _timer = new Timer();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
host: ReactiveControllerHost,
|
host: ReactiveControllerHost & HTMLElement,
|
||||||
timerSeconds: number,
|
getTimerSecondsCallback: () => number | null,
|
||||||
callback: () => T,
|
callback: () => T,
|
||||||
timerStartCallback?: () => void,
|
timerStartCallback?: () => void,
|
||||||
timerStopCallback?: () => void,
|
timerStopCallback?: () => void,
|
||||||
|
timerTickCallback?: () => void,
|
||||||
) {
|
) {
|
||||||
this._timerSeconds = timerSeconds;
|
this._getTimerSecondsCallback = getTimerSecondsCallback;
|
||||||
|
this._timerSeconds = getTimerSecondsCallback();
|
||||||
this._callback = callback;
|
this._callback = callback;
|
||||||
this._timerStartCallback = timerStartCallback;
|
this._timerStartCallback = timerStartCallback;
|
||||||
this._timerStopCallback = timerStopCallback;
|
this._timerStopCallback = timerStopCallback;
|
||||||
|
this._timerTickCallback = timerTickCallback;
|
||||||
(this._host = host).addController(this);
|
(this._host = host).addController(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove the controller for the host.
|
|
||||||
*/
|
|
||||||
public removeController(): void {
|
|
||||||
this.stopTimer();
|
|
||||||
this._host.removeController(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the value.
|
* Get the value.
|
||||||
*/
|
*/
|
||||||
get value(): T | undefined {
|
public getValue(): T | null {
|
||||||
return this._value;
|
return this._value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,13 +46,14 @@ export class CachedValueController<T> implements ReactiveController {
|
|||||||
*/
|
*/
|
||||||
public updateValue(): void {
|
public updateValue(): void {
|
||||||
this._value = this._callback();
|
this._value = this._callback();
|
||||||
|
this._host.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear the cached value.
|
* Clear the cached value.
|
||||||
*/
|
*/
|
||||||
public clearValue(): void {
|
public clearValue(): void {
|
||||||
this._value = undefined;
|
this._value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,10 +72,14 @@ export class CachedValueController<T> implements ReactiveController {
|
|||||||
public startTimer(): void {
|
public startTimer(): void {
|
||||||
this.stopTimer();
|
this.stopTimer();
|
||||||
|
|
||||||
|
if (!this._timerSeconds || this._timerSeconds <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this._timerStartCallback?.();
|
this._timerStartCallback?.();
|
||||||
this._timer.startRepeated(this._timerSeconds, () => {
|
this._timer.startRepeated(this._timerSeconds, () => {
|
||||||
|
this._timerTickCallback?.();
|
||||||
this.updateValue();
|
this.updateValue();
|
||||||
this._host.requestUpdate();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,13 +87,22 @@ export class CachedValueController<T> implements ReactiveController {
|
|||||||
return this._timer.isRunning();
|
return this._timer.isRunning();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public hostUpdate(): void {
|
||||||
|
const newTimerSeconds = this._getTimerSecondsCallback();
|
||||||
|
if (newTimerSeconds !== this._timerSeconds) {
|
||||||
|
this._timerSeconds = newTimerSeconds;
|
||||||
|
if (this._host.isConnected) {
|
||||||
|
this.startTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Host has connected to the cache.
|
* Host has connected to the cache.
|
||||||
*/
|
*/
|
||||||
hostConnected(): void {
|
hostConnected(): void {
|
||||||
this.updateValue();
|
this.updateValue();
|
||||||
this.startTimer();
|
this.startTimer();
|
||||||
this._host.requestUpdate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export class UpdatingImageMediaPlayerController implements MediaPlayerController
|
|||||||
|
|
||||||
public async getScreenshotURL(): Promise<string | null> {
|
public async getScreenshotURL(): Promise<string | null> {
|
||||||
await this._host.updateComplete;
|
await this._host.updateComplete;
|
||||||
return this._getCachedValueController()?.value ?? null;
|
return this._getCachedValueController()?.getValue() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getFullscreenElement(): FullscreenElement | null {
|
public getFullscreenElement(): FullscreenElement | null {
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||||
|
import { isEqual } from 'lodash-es';
|
||||||
|
import { EnabledProxyConfig } from '../config/schema/common/proxy.js';
|
||||||
|
import { homeAssistantGetSignedURLIfNecessary } from '../ha/sign-path.js';
|
||||||
|
import { HomeAssistant } from '../ha/types.js';
|
||||||
|
import {
|
||||||
|
CreateProxiedEndpointOptions,
|
||||||
|
createProxiedEndpointIfNecessary,
|
||||||
|
} from '../ha/web-proxy.js';
|
||||||
|
import { Endpoint } from '../types.js';
|
||||||
|
import { errorToConsole } from '../utils/basic.js';
|
||||||
|
|
||||||
|
const PROXY_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
|
||||||
|
|
||||||
|
// Re-register and re-sign well before the signed URL expires.
|
||||||
|
const PROXY_CACHE_TTL_SECONDS = PROXY_URL_SIGN_EXPIRY_SECONDS / 2;
|
||||||
|
|
||||||
|
interface SignedURLControllerOptions {
|
||||||
|
// The endpoint to resolve. The `sign` flag on the endpoint controls whether
|
||||||
|
// the URL requires HA authentication even when proxying is disabled (e.g.
|
||||||
|
// HA-relative API paths like go2rtc streams served through Frigate).
|
||||||
|
endpoint?: Endpoint;
|
||||||
|
|
||||||
|
hass?: HomeAssistant;
|
||||||
|
proxyConfig?: EnabledProxyConfig | null;
|
||||||
|
proxyEndpointOptions?: CreateProxiedEndpointOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignedURLErrorType = 'sign' | 'proxy';
|
||||||
|
|
||||||
|
export class SignedURLController implements ReactiveController {
|
||||||
|
private _host: ReactiveControllerHost;
|
||||||
|
private _getOptionsCallback: () => SignedURLControllerOptions;
|
||||||
|
private _valueChangeCallback: (() => void) | undefined;
|
||||||
|
|
||||||
|
private _value: string | null = null;
|
||||||
|
private _error: SignedURLErrorType | null = null;
|
||||||
|
private _cachedAt: Date | null = null;
|
||||||
|
|
||||||
|
// Caching and race-condition state.
|
||||||
|
// The targetURL and proxy config are tracked to detect when inputs change and
|
||||||
|
// invalidate the cache. The requestID tracks the most recent valid fetch, to
|
||||||
|
// ensure that older, slower in-flight requests do not overwrite newer ones.
|
||||||
|
private _targetURL: string | null = null;
|
||||||
|
private _targetProxyConfig: EnabledProxyConfig | null = null;
|
||||||
|
private _requestID = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
host: ReactiveControllerHost,
|
||||||
|
getOptionsCallback: () => SignedURLControllerOptions,
|
||||||
|
valueChangeCallback?: () => void,
|
||||||
|
) {
|
||||||
|
(this._host = host).addController(this);
|
||||||
|
this._getOptionsCallback = getOptionsCallback;
|
||||||
|
this._valueChangeCallback = valueChangeCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getError(): SignedURLErrorType | null {
|
||||||
|
return this._error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getValue(): string | null {
|
||||||
|
const options = this._getOptionsCallback();
|
||||||
|
|
||||||
|
// When the endpoint requires signing or proxying, the URL must go through
|
||||||
|
// the async resolution path. For proxied URLs, under no circumstances
|
||||||
|
// should we fall back to returning the unproxied URL — doing so risks
|
||||||
|
// leaking traffic or causing mixed-content errors.
|
||||||
|
if (options.proxyConfig?.enabled || options.endpoint?.sign) {
|
||||||
|
return this._value;
|
||||||
|
}
|
||||||
|
return options.endpoint?.endpoint ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public hostDisconnected(): void {
|
||||||
|
++this._requestID;
|
||||||
|
this._value = null;
|
||||||
|
this._error = null;
|
||||||
|
this._cachedAt = null;
|
||||||
|
this._targetURL = null;
|
||||||
|
this._targetProxyConfig = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async hostUpdate(): Promise<void> {
|
||||||
|
const { hass, endpoint, proxyConfig, proxyEndpointOptions } =
|
||||||
|
this._getOptionsCallback();
|
||||||
|
if (!hass || !endpoint || (!proxyConfig?.enabled && !endpoint.sign)) {
|
||||||
|
// Invalidate any in-flight async work so a stale proxy/sign result cannot
|
||||||
|
// repopulate the controller after inputs have been cleared or disabled.
|
||||||
|
++this._requestID;
|
||||||
|
this._value = null;
|
||||||
|
this._error = null;
|
||||||
|
this._targetURL = null;
|
||||||
|
this._targetProxyConfig = null;
|
||||||
|
this._cachedAt = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetURL = new URL(endpoint.endpoint, document.baseURI).toString();
|
||||||
|
|
||||||
|
// Pick only the EnabledProxyConfig fields so that extraneous properties
|
||||||
|
// (e.g. `live`/`media` from CameraProxyConfig spreads) don't cause
|
||||||
|
// spurious cache invalidations. When only signing (no proxy), the config
|
||||||
|
// is null.
|
||||||
|
const comparableConfig: EnabledProxyConfig | null = proxyConfig?.enabled
|
||||||
|
? {
|
||||||
|
dynamic: proxyConfig.dynamic,
|
||||||
|
ssl_verification: proxyConfig.ssl_verification,
|
||||||
|
ssl_ciphers: proxyConfig.ssl_ciphers,
|
||||||
|
enabled: proxyConfig.enabled,
|
||||||
|
enforce: proxyConfig.enforce,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
targetURL !== this._targetURL ||
|
||||||
|
!isEqual(comparableConfig, this._targetProxyConfig)
|
||||||
|
) {
|
||||||
|
this._targetURL = targetURL;
|
||||||
|
this._targetProxyConfig = comparableConfig;
|
||||||
|
this._cachedAt = null;
|
||||||
|
this._error = null;
|
||||||
|
this._value = null;
|
||||||
|
} else if (
|
||||||
|
this._cachedAt &&
|
||||||
|
new Date().getTime() - this._cachedAt.getTime() < PROXY_CACHE_TTL_SECONDS * 1000
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
} else if (!this._cachedAt) {
|
||||||
|
// Either async work for these exact inputs is already in flight, or
|
||||||
|
// we already failed for these exact inputs. Either way, don't
|
||||||
|
// restart: inputs must change before we retry.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as in-flight so the `!this._cachedAt` guard above prevents
|
||||||
|
// subsequent hostUpdate() calls from restarting the async work.
|
||||||
|
this._cachedAt = null;
|
||||||
|
const requestID = ++this._requestID;
|
||||||
|
|
||||||
|
const resolvedEndpoint = await this._proxy(
|
||||||
|
hass,
|
||||||
|
targetURL,
|
||||||
|
endpoint,
|
||||||
|
proxyConfig,
|
||||||
|
proxyEndpointOptions,
|
||||||
|
);
|
||||||
|
if (this._isStale(requestID)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!resolvedEndpoint) {
|
||||||
|
this._applyError('proxy');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedURL = await this._sign(hass, resolvedEndpoint);
|
||||||
|
if (this._isStale(requestID)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!signedURL) {
|
||||||
|
this._applyError('sign');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._applySuccess(signedURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy the endpoint if proxying is enabled, otherwise return it as-is.
|
||||||
|
*/
|
||||||
|
private async _proxy(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
targetURL: string,
|
||||||
|
endpoint: Endpoint,
|
||||||
|
proxyConfig: EnabledProxyConfig | null | undefined,
|
||||||
|
proxyEndpointOptions: CreateProxiedEndpointOptions | undefined,
|
||||||
|
): Promise<Endpoint | null> {
|
||||||
|
if (!proxyConfig?.enabled) {
|
||||||
|
return { endpoint: targetURL, sign: endpoint.sign };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await createProxiedEndpointIfNecessary(
|
||||||
|
hass,
|
||||||
|
{ endpoint: targetURL, sign: false },
|
||||||
|
proxyConfig,
|
||||||
|
{
|
||||||
|
ttl: PROXY_URL_SIGN_EXPIRY_SECONDS,
|
||||||
|
openLimit: 0,
|
||||||
|
...proxyEndpointOptions,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
errorToConsole(e as Error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign the endpoint if it requires signing, otherwise return the URL as-is.
|
||||||
|
*/
|
||||||
|
private async _sign(hass: HomeAssistant, endpoint: Endpoint): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return await homeAssistantGetSignedURLIfNecessary(
|
||||||
|
hass,
|
||||||
|
endpoint,
|
||||||
|
PROXY_URL_SIGN_EXPIRY_SECONDS,
|
||||||
|
);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
errorToConsole(e as Error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _isStale(requestID: number): boolean {
|
||||||
|
return this._requestID !== requestID;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _applySuccess(url: string): void {
|
||||||
|
this._value = url;
|
||||||
|
this._error = null;
|
||||||
|
this._valueChangeCallback?.();
|
||||||
|
this._cachedAt = new Date();
|
||||||
|
this._host.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _applyError(error: SignedURLErrorType): void {
|
||||||
|
this._value = null;
|
||||||
|
this._error = error;
|
||||||
|
this._host.requestUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,12 +11,13 @@ import { customElement, property, state } from 'lit/decorators.js';
|
|||||||
import { live } from 'lit/directives/live.js';
|
import { live } from 'lit/directives/live.js';
|
||||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||||
import { isEqual } from 'lodash-es';
|
import { isEqual } from 'lodash-es';
|
||||||
import { CameraManager } from '../camera-manager/manager.js';
|
|
||||||
import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity-from-config.js';
|
import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity-from-config.js';
|
||||||
import { CachedValueController } from '../components-lib/cached-value-controller.js';
|
import { CachedValueController } from '../components-lib/cached-value-controller.js';
|
||||||
import { UpdatingImageMediaPlayerController } from '../components-lib/media-player/updating-image.js';
|
import { UpdatingImageMediaPlayerController } from '../components-lib/media-player/updating-image.js';
|
||||||
|
import { SignedURLController } from '../components-lib/signed-url-controller.js';
|
||||||
import { CameraConfig } from '../config/schema/cameras.js';
|
import { CameraConfig } from '../config/schema/cameras.js';
|
||||||
import { type ImageBaseConfig, ImageMode } from '../config/schema/common/image.js';
|
import { type ImageBaseConfig, ImageMode } from '../config/schema/common/image.js';
|
||||||
|
import { EnabledProxyConfig } from '../config/schema/common/proxy.js';
|
||||||
import { isHassDifferent } from '../ha/is-hass-different.js';
|
import { isHassDifferent } from '../ha/is-hass-different.js';
|
||||||
import { HomeAssistant } from '../ha/types.js';
|
import { HomeAssistant } from '../ha/types.js';
|
||||||
import defaultImage from '../images/iris-screensaver.jpg';
|
import defaultImage from '../images/iris-screensaver.jpg';
|
||||||
@@ -79,8 +80,8 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraConfig?: CameraConfig;
|
public cameraConfig?: CameraConfig;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
public cameraManager?: CameraManager;
|
public proxyConfig?: EnabledProxyConfig;
|
||||||
|
|
||||||
// Using contentsChanged to ensure overridden configs (e.g. when the
|
// Using contentsChanged to ensure overridden configs (e.g. when the
|
||||||
// 'show_image_during_load' option is true for live views, an overridden
|
// 'show_image_during_load' option is true for live views, an overridden
|
||||||
@@ -89,11 +90,36 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
public imageConfig?: ImageBaseConfig;
|
public imageConfig?: ImageBaseConfig;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private _message: Message | null = null;
|
private _imageLoadError = false;
|
||||||
|
|
||||||
private _refImage: Ref<HTMLImageElement> = createRef();
|
private _refImage: Ref<HTMLImageElement> = createRef();
|
||||||
|
|
||||||
private _cachedValueController?: CachedValueController<string>;
|
private _cachedValueController = new CachedValueController(
|
||||||
|
this,
|
||||||
|
() => this.imageConfig?.refresh_seconds ?? null,
|
||||||
|
() => this._getImageSource(),
|
||||||
|
() => dispatchMediaPlayEvent(this),
|
||||||
|
() => dispatchMediaPauseEvent(this),
|
||||||
|
// Clear image load errors on each timer tick so the next render retries the
|
||||||
|
// <img>. Retries are bounded by refresh_seconds, not a tight loop.
|
||||||
|
() => {
|
||||||
|
this._imageLoadError = false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
private _signedURLController = new SignedURLController(
|
||||||
|
this,
|
||||||
|
() => ({
|
||||||
|
hass: this.hass,
|
||||||
|
endpoint: this.imageConfig?.url ? { endpoint: this.imageConfig.url } : undefined,
|
||||||
|
proxyConfig: this.proxyConfig,
|
||||||
|
}),
|
||||||
|
() => {
|
||||||
|
this._cachedValueController.clearValue();
|
||||||
|
this._imageLoadError = false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
private _boundVisibilityHandler = this._visibilityHandler.bind(this);
|
private _boundVisibilityHandler = this._visibilityHandler.bind(this);
|
||||||
|
|
||||||
private _mediaLoadedInfo: MediaLoadedInfo | null = null;
|
private _mediaLoadedInfo: MediaLoadedInfo | null = null;
|
||||||
@@ -101,7 +127,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
private _mediaPlayerController = new UpdatingImageMediaPlayerController(
|
private _mediaPlayerController = new UpdatingImageMediaPlayerController(
|
||||||
this,
|
this,
|
||||||
() => this._refImage.value ?? null,
|
() => this._refImage.value ?? null,
|
||||||
() => this._cachedValueController ?? null,
|
() => this._cachedValueController,
|
||||||
);
|
);
|
||||||
|
|
||||||
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
||||||
@@ -143,21 +169,6 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
* @param _changedProps The changed properties
|
* @param _changedProps The changed properties
|
||||||
*/
|
*/
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
if (changedProps.has('imageConfig')) {
|
|
||||||
if (this._cachedValueController) {
|
|
||||||
this._cachedValueController.removeController();
|
|
||||||
}
|
|
||||||
if (this.imageConfig) {
|
|
||||||
this._cachedValueController = new CachedValueController(
|
|
||||||
this,
|
|
||||||
this.imageConfig.refresh_seconds,
|
|
||||||
this._getImageSource.bind(this),
|
|
||||||
() => dispatchMediaPlayEvent(this),
|
|
||||||
() => dispatchMediaPauseEvent(this),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const relevantEntity = this._getRelevantEntityForMode(
|
const relevantEntity = this._getRelevantEntityForMode(
|
||||||
resolveImageMode({
|
resolveImageMode({
|
||||||
imageConfig: this.imageConfig,
|
imageConfig: this.imageConfig,
|
||||||
@@ -170,20 +181,19 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
// the state is not acceptable, discard the old value (to allow a stock or
|
// the state is not acceptable, discard the old value (to allow a stock or
|
||||||
// backup image to be displayed).
|
// backup image to be displayed).
|
||||||
if (
|
if (
|
||||||
|
changedProps.has('imageConfig') ||
|
||||||
changedProps.has('cameraConfig') ||
|
changedProps.has('cameraConfig') ||
|
||||||
|
changedProps.has('proxyConfig') ||
|
||||||
changedProps.has('view') ||
|
changedProps.has('view') ||
|
||||||
(relevantEntity && !this._getAcceptableState(relevantEntity))
|
(relevantEntity && !this._getAcceptableState(relevantEntity))
|
||||||
) {
|
) {
|
||||||
this._cachedValueController?.clearValue();
|
this._cachedValueController?.clearValue();
|
||||||
|
this._imageLoadError = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this._cachedValueController?.value) {
|
if (!this._cachedValueController?.getValue()) {
|
||||||
this._cachedValueController?.updateValue();
|
this._cachedValueController?.updateValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (['imageConfig', 'view'].some((prop) => changedProps.has(prop))) {
|
|
||||||
this._message = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -220,7 +230,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
*/
|
*/
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
this._cachedValueController?.stopTimer();
|
this._cachedValueController?.stopTimer();
|
||||||
this._message = null;
|
this._imageLoadError = false;
|
||||||
document.removeEventListener('visibilitychange', this._boundVisibilityHandler);
|
document.removeEventListener('visibilitychange', this._boundVisibilityHandler);
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
@@ -254,12 +264,23 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a working absolute image URL that the browser will not cache.
|
* Build an image URL that the browser will not cache. Supports two modes:
|
||||||
* @param url An input URL (may be relative to document origin)
|
* - 'query-string': Appends a `_t` parameter. This is the most robust way to
|
||||||
* @returns A new URL as a string (absolute, will not be browser cached).
|
* defeat caching (it bypasses HTTP caches) but it changes the path sent to
|
||||||
|
* the server and so can invalidate signed URLs.
|
||||||
|
* - 'fragment': Appends a `_t` fragment. This is less robust (the browser
|
||||||
|
* might still serve from its HTTP cache) but it does not change the URL
|
||||||
|
* sent to the server so it is safe for signed URLs.
|
||||||
|
* @param url The input URL.
|
||||||
|
* @param mode The cache-busting mode.
|
||||||
|
* @returns The cache-busted URL string.
|
||||||
*/
|
*/
|
||||||
private _buildImageURL(url: URL): string {
|
private _buildCacheBustURL(url: URL, mode: 'query-string' | 'fragment'): string {
|
||||||
|
if (mode === 'query-string') {
|
||||||
url.searchParams.append('_t', String(Date.now()));
|
url.searchParams.append('_t', String(Date.now()));
|
||||||
|
} else {
|
||||||
|
url.hash = `_t=${Date.now()}`;
|
||||||
|
}
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +315,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
if (state?.attributes.entity_picture) {
|
if (state?.attributes.entity_picture) {
|
||||||
const urlObj = new URL(state.attributes.entity_picture, document.baseURI);
|
const urlObj = new URL(state.attributes.entity_picture, document.baseURI);
|
||||||
this._addQueryParametersToURL(urlObj, this.imageConfig?.entity_parameters);
|
this._addQueryParametersToURL(urlObj, this.imageConfig?.entity_parameters);
|
||||||
return this._buildImageURL(urlObj);
|
return this._buildCacheBustURL(urlObj, 'query-string');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,12 +324,21 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
if (state?.attributes.entity_picture) {
|
if (state?.attributes.entity_picture) {
|
||||||
const urlObj = new URL(state.attributes.entity_picture, document.baseURI);
|
const urlObj = new URL(state.attributes.entity_picture, document.baseURI);
|
||||||
this._addQueryParametersToURL(urlObj, this.imageConfig?.entity_parameters);
|
this._addQueryParametersToURL(urlObj, this.imageConfig?.entity_parameters);
|
||||||
return this._buildImageURL(urlObj);
|
return this._buildCacheBustURL(urlObj, 'query-string');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'url' && this.imageConfig?.url) {
|
if (mode === 'url' && this.imageConfig?.url) {
|
||||||
return this._buildImageURL(new URL(this.imageConfig.url, document.baseURI));
|
const url = this._signedURLController.getValue();
|
||||||
|
if (url) {
|
||||||
|
const urlObj = new URL(url, document.baseURI);
|
||||||
|
if (this.proxyConfig?.enabled) {
|
||||||
|
// Use a fragment for cache-busting proxied URLs, as this does not
|
||||||
|
// change the path and thus preserves the validity of the signed URL.
|
||||||
|
return this._buildCacheBustURL(urlObj, 'fragment');
|
||||||
|
}
|
||||||
|
return this._buildCacheBustURL(urlObj, 'query-string');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return defaultImage;
|
return defaultImage;
|
||||||
@@ -319,17 +349,43 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
*/
|
*/
|
||||||
private _forceSafeImage(stockOnly?: boolean): void {
|
private _forceSafeImage(stockOnly?: boolean): void {
|
||||||
if (this._refImage.value) {
|
if (this._refImage.value) {
|
||||||
this._refImage.value.src =
|
// Avoid restoring the raw configured URL when proxying is enabled, since
|
||||||
!stockOnly && this.imageConfig?.url ? this.imageConfig.url : defaultImage;
|
// that would bypass the proxied/signed URL path on visibility changes.
|
||||||
|
const configuredURL =
|
||||||
|
!stockOnly && !this.proxyConfig?.enabled ? this.imageConfig?.url ?? null : null;
|
||||||
|
this._refImage.value.src = configuredURL ?? defaultImage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _getDisplayMessage(): Message | null {
|
||||||
|
const error = this._signedURLController.getError();
|
||||||
|
if (error) {
|
||||||
|
return {
|
||||||
|
type: 'error',
|
||||||
|
message: localize(
|
||||||
|
error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign',
|
||||||
|
),
|
||||||
|
context: this.proxyConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (this._imageLoadError) {
|
||||||
|
return {
|
||||||
|
type: 'error',
|
||||||
|
message: localize('error.image_load_error'),
|
||||||
|
context: this.imageConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (this._message) {
|
const message = this._getDisplayMessage();
|
||||||
return renderMessage(this._message);
|
if (message) {
|
||||||
|
return renderMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const src = this._cachedValueController?.value;
|
const src = this._cachedValueController?.getValue();
|
||||||
|
|
||||||
// Note the use of live() below to ensure the update will restore the image
|
// Note the use of live() below to ensure the update will restore the image
|
||||||
// src if it's been changed via _forceSafeImage().
|
// src if it's been changed via _forceSafeImage().
|
||||||
return src
|
return src
|
||||||
@@ -364,13 +420,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
// failed to load.
|
// failed to load.
|
||||||
this._forceSafeImage(true);
|
this._forceSafeImage(true);
|
||||||
} else if (mode === 'url') {
|
} else if (mode === 'url') {
|
||||||
// In url mode, the user likely specified a URL that cannot be
|
this._imageLoadError = true;
|
||||||
// resolved. Show an error message.
|
|
||||||
this._message = {
|
|
||||||
type: 'error',
|
|
||||||
message: localize('error.image_load_error'),
|
|
||||||
context: this.imageConfig,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+16
-1
@@ -7,7 +7,11 @@ import { ViewManagerEpoch } from '../card-controller/view/types';
|
|||||||
import { ZoomSettingsObserved } from '../components-lib/zoom/types';
|
import { ZoomSettingsObserved } from '../components-lib/zoom/types';
|
||||||
import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context';
|
import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context';
|
||||||
import { CameraConfig } from '../config/schema/cameras';
|
import { CameraConfig } from '../config/schema/cameras';
|
||||||
import { ImageViewConfig } from '../config/schema/image';
|
import {
|
||||||
|
type EnabledProxyConfig,
|
||||||
|
resolveProxyConfig,
|
||||||
|
} from '../config/schema/common/proxy';
|
||||||
|
import { ImageViewConfig, type ImageViewProxyConfig } from '../config/schema/image';
|
||||||
import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const';
|
import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const';
|
||||||
import { HomeAssistant } from '../ha/types';
|
import { HomeAssistant } from '../ha/types';
|
||||||
import { localize } from '../localize/localize.js';
|
import { localize } from '../localize/localize.js';
|
||||||
@@ -82,6 +86,16 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
|
|||||||
: intermediateTemplate}`;
|
: intermediateTemplate}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _resolveProxyConfig(proxy?: ImageViewProxyConfig): EnabledProxyConfig | null {
|
||||||
|
return proxy
|
||||||
|
? {
|
||||||
|
...resolveProxyConfig(proxy),
|
||||||
|
enabled: proxy.enabled,
|
||||||
|
enforce: proxy.enabled,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.hass) {
|
if (!this.hass) {
|
||||||
return;
|
return;
|
||||||
@@ -108,6 +122,7 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
|
|||||||
.view=${this.viewManagerEpoch?.manager.getView()}
|
.view=${this.viewManagerEpoch?.manager.getView()}
|
||||||
.imageConfig=${this.imageConfig}
|
.imageConfig=${this.imageConfig}
|
||||||
.cameraConfig=${this.cameraConfig}
|
.cameraConfig=${this.cameraConfig}
|
||||||
|
.proxyConfig=${this._resolveProxyConfig(this.imageConfig?.proxy) ?? undefined}
|
||||||
>
|
>
|
||||||
</advanced-camera-card-image-updating-player>
|
</advanced-camera-card-image-updating-player>
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
${ref(this._refProvider)}
|
${ref(this._refProvider)}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.cameraConfig=${cameraConfig}
|
.cameraConfig=${cameraConfig}
|
||||||
|
.proxyConfig=${this.camera.getLiveProxyConfig()}
|
||||||
class=${classMap({
|
class=${classMap({
|
||||||
...classes,
|
...classes,
|
||||||
// The image provider is providing the temporary loading image,
|
// The image provider is providing the temporary loading image,
|
||||||
|
|||||||
@@ -6,35 +6,26 @@ import {
|
|||||||
TemplateResult,
|
TemplateResult,
|
||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { Camera } from '../../../../camera-manager/camera.js';
|
import { Camera } from '../../../../camera-manager/camera.js';
|
||||||
import { CameraEndpoints } from '../../../../camera-manager/types.js';
|
import { CameraEndpoints } from '../../../../camera-manager/types.js';
|
||||||
import { MicrophoneState } from '../../../../card-controller/types.js';
|
import { MicrophoneState } from '../../../../card-controller/types.js';
|
||||||
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
|
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
|
||||||
import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js';
|
import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js';
|
||||||
|
import { SignedURLController } from '../../../../components-lib/signed-url-controller.js';
|
||||||
import { MicrophoneConfig } from '../../../../config/schema/live.js';
|
import { MicrophoneConfig } from '../../../../config/schema/live.js';
|
||||||
import { homeAssistantSignPath } from '../../../../ha/sign-path.js';
|
|
||||||
import { HomeAssistant } from '../../../../ha/types.js';
|
import { HomeAssistant } from '../../../../ha/types.js';
|
||||||
import { createProxiedEndpointIfNecessary } from '../../../../ha/web-proxy.js';
|
|
||||||
import { localize } from '../../../../localize/localize.js';
|
import { localize } from '../../../../localize/localize.js';
|
||||||
import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss';
|
import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss';
|
||||||
import { MediaPlayer, MediaPlayerController, Message } from '../../../../types.js';
|
import { MediaPlayer, MediaPlayerController } from '../../../../types.js';
|
||||||
import { errorToConsole } from '../../../../utils/basic.js';
|
|
||||||
import { renderMessage } from '../../../message.js';
|
import { renderMessage } from '../../../message.js';
|
||||||
import { VideoRTC } from './video-rtc.js';
|
import { VideoRTC } from './video-rtc.js';
|
||||||
|
|
||||||
customElements.define('advanced-camera-card-live-go2rtc-player', VideoRTC);
|
customElements.define('advanced-camera-card-live-go2rtc-player', VideoRTC);
|
||||||
|
|
||||||
// Note (2023-02-18): Depending on the behavior of the player / browser is
|
|
||||||
// possible this URL will need to be re-signed in order to avoid HA spamming
|
|
||||||
// logs after the expiry time, but this complexity is not added for now until
|
|
||||||
// there are verified cases of this being an issue (see equivalent in the JSMPEG
|
|
||||||
// provider).
|
|
||||||
const GO2RTC_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
|
|
||||||
|
|
||||||
@customElement('advanced-camera-card-live-go2rtc')
|
@customElement('advanced-camera-card-live-go2rtc')
|
||||||
export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer {
|
export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer {
|
||||||
// Not an reactive property to avoid resetting the video.
|
// Not a reactive property to avoid resetting the video.
|
||||||
public hass?: HomeAssistant;
|
public hass?: HomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
@@ -52,10 +43,8 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
|||||||
@property({ attribute: true, type: Boolean })
|
@property({ attribute: true, type: Boolean })
|
||||||
public controls = false;
|
public controls = false;
|
||||||
|
|
||||||
@state()
|
|
||||||
private _message: Message | null = null;
|
|
||||||
|
|
||||||
private _player?: VideoRTC;
|
private _player?: VideoRTC;
|
||||||
|
private _hasLiveError = false;
|
||||||
|
|
||||||
private _mediaPlayerController = new VideoMediaPlayerController(
|
private _mediaPlayerController = new VideoMediaPlayerController(
|
||||||
this,
|
this,
|
||||||
@@ -63,13 +52,29 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
|||||||
() => this.controls,
|
() => this.controls,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private _signedURLController = new SignedURLController(
|
||||||
|
this,
|
||||||
|
() => {
|
||||||
|
const endpoint = this.cameraEndpoints?.go2rtc;
|
||||||
|
if (!this.hass || !endpoint) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
hass: this.hass,
|
||||||
|
endpoint,
|
||||||
|
proxyConfig: this.camera?.getLiveProxyConfig(),
|
||||||
|
proxyEndpointOptions: { websocket: true },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
() => this._createPlayer(),
|
||||||
|
);
|
||||||
|
|
||||||
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
||||||
return this._mediaPlayerController;
|
return this._mediaPlayerController;
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
this._player = undefined;
|
this._player = undefined;
|
||||||
this._message = null;
|
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,82 +86,8 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
|||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _handleError(message: Message, e?: Error): void {
|
private _createPlayer(): void {
|
||||||
if (e) {
|
const src = this._signedURLController.getValue();
|
||||||
errorToConsole(e as Error);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._message = {
|
|
||||||
type: 'error',
|
|
||||||
...message,
|
|
||||||
};
|
|
||||||
dispatchLiveErrorEvent(this);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _getPlayerSource(): Promise<string | null> {
|
|
||||||
const cameraConfig = this.camera?.getConfig();
|
|
||||||
const proxyConfig = this.camera?.getProxyConfig();
|
|
||||||
if (!this.hass || !cameraConfig) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamEndpoint = this.cameraEndpoints?.go2rtc;
|
|
||||||
if (!streamEndpoint) {
|
|
||||||
this._handleError({
|
|
||||||
message: localize('error.live_camera_no_endpoint'),
|
|
||||||
context: cameraConfig,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let result: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const endpoint = await createProxiedEndpointIfNecessary(
|
|
||||||
this.hass,
|
|
||||||
streamEndpoint,
|
|
||||||
proxyConfig,
|
|
||||||
{
|
|
||||||
context: 'live',
|
|
||||||
ttl: GO2RTC_URL_SIGN_EXPIRY_SECONDS,
|
|
||||||
websocket: true,
|
|
||||||
|
|
||||||
// The link may need to be opened multiple times.
|
|
||||||
openLimit: 0,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (endpoint.sign) {
|
|
||||||
result = await homeAssistantSignPath(
|
|
||||||
this.hass,
|
|
||||||
endpoint.endpoint,
|
|
||||||
GO2RTC_URL_SIGN_EXPIRY_SECONDS,
|
|
||||||
);
|
|
||||||
if (!result) {
|
|
||||||
this._handleError({
|
|
||||||
message: localize('error.failed_sign'),
|
|
||||||
context: cameraConfig,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result = endpoint.endpoint;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
this._handleError(
|
|
||||||
{
|
|
||||||
message: localize('error.failed_proxy'),
|
|
||||||
context: cameraConfig,
|
|
||||||
},
|
|
||||||
e as Error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _createPlayer(): Promise<void> {
|
|
||||||
const src = await this._getPlayerSource();
|
|
||||||
if (!src) {
|
if (!src) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -178,12 +109,20 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
|||||||
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
if (changedProps.has('cameraEndpoints')) {
|
if (changedProps.has('cameraEndpoints')) {
|
||||||
this._message = null;
|
// Clear old player; the new one is created by the
|
||||||
|
// SignedURLController's valueChangeCallback once the URL resolves.
|
||||||
|
this._player = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this._message && (!this._player || changedProps.has('cameraEndpoints'))) {
|
// Only treat a missing go2rtc endpoint as an error after cameraEndpoints
|
||||||
this._createPlayer();
|
// has been explicitly set (not undefined / still loading).
|
||||||
|
const hasError =
|
||||||
|
!!this._signedURLController.getError() ||
|
||||||
|
(!!this.cameraEndpoints && !this.cameraEndpoints.go2rtc);
|
||||||
|
if (hasError && !this._hasLiveError) {
|
||||||
|
dispatchLiveErrorEvent(this);
|
||||||
}
|
}
|
||||||
|
this._hasLiveError = hasError;
|
||||||
|
|
||||||
if (changedProps.has('controls') && this._player) {
|
if (changedProps.has('controls') && this._player) {
|
||||||
this._player.setControls(this.controls);
|
this._player.setControls(this.controls);
|
||||||
@@ -203,8 +142,22 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (this._message) {
|
const error = this._signedURLController.getError();
|
||||||
return renderMessage(this._message);
|
if (error) {
|
||||||
|
return renderMessage({
|
||||||
|
type: 'error',
|
||||||
|
message: localize(
|
||||||
|
error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign',
|
||||||
|
),
|
||||||
|
context: this.camera?.getConfig(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!this.cameraEndpoints?.go2rtc) {
|
||||||
|
return renderMessage({
|
||||||
|
type: 'error',
|
||||||
|
message: localize('error.live_camera_no_endpoint'),
|
||||||
|
context: this.camera?.getConfig(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return html`${this._player}`;
|
return html`${this._player}`;
|
||||||
}
|
}
|
||||||
@@ -216,6 +169,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
|
'advanced-camera-card-live-go2rtc-player': VideoRTC;
|
||||||
'advanced-camera-card-live-go2rtc': AdvancedCameraCardGo2RTC;
|
'advanced-camera-card-live-go2rtc': AdvancedCameraCardGo2RTC;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
|
|||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||||
import { CameraConfig } from '../../../config/schema/cameras';
|
import { CameraConfig } from '../../../config/schema/cameras';
|
||||||
|
import { EnabledProxyConfig } from '../../../config/schema/common/proxy';
|
||||||
import { HomeAssistant } from '../../../ha/types';
|
import { HomeAssistant } from '../../../ha/types';
|
||||||
import basicBlockStyle from '../../../scss/basic-block.scss';
|
import basicBlockStyle from '../../../scss/basic-block.scss';
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +20,9 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraConfig?: CameraConfig;
|
public cameraConfig?: CameraConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public proxyConfig?: EnabledProxyConfig;
|
||||||
|
|
||||||
private _refImage: Ref<MediaPlayerElement> = createRef();
|
private _refImage: Ref<MediaPlayerElement> = createRef();
|
||||||
|
|
||||||
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
||||||
@@ -37,6 +41,7 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.imageConfig=${this.cameraConfig.image}
|
.imageConfig=${this.cameraConfig.image}
|
||||||
.cameraConfig=${this.cameraConfig}
|
.cameraConfig=${this.cameraConfig}
|
||||||
|
.proxyConfig=${this.proxyConfig}
|
||||||
>
|
>
|
||||||
</advanced-camera-card-image-updating-player>
|
</advanced-camera-card-image-updating-player>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispa
|
|||||||
import { JSMPEGMediaPlayerController } from '../../../components-lib/media-player/jsmpeg.js';
|
import { JSMPEGMediaPlayerController } from '../../../components-lib/media-player/jsmpeg.js';
|
||||||
import { CameraConfig } from '../../../config/schema/cameras.js';
|
import { CameraConfig } from '../../../config/schema/cameras.js';
|
||||||
import { CardWideConfig } from '../../../config/schema/types.js';
|
import { CardWideConfig } from '../../../config/schema/types.js';
|
||||||
import { homeAssistantSignPath } from '../../../ha/sign-path.js';
|
import { homeAssistantGetSignedURLIfNecessary } from '../../../ha/sign-path.js';
|
||||||
import { HomeAssistant } from '../../../ha/types.js';
|
import { HomeAssistant } from '../../../ha/types.js';
|
||||||
import { localize } from '../../../localize/localize.js';
|
import { localize } from '../../../localize/localize.js';
|
||||||
import liveJSMPEGStyle from '../../../scss/live-jsmpeg.scss';
|
import liveJSMPEGStyle from '../../../scss/live-jsmpeg.scss';
|
||||||
@@ -186,9 +186,9 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
|
|||||||
|
|
||||||
let response: string | null | undefined;
|
let response: string | null | undefined;
|
||||||
try {
|
try {
|
||||||
response = await homeAssistantSignPath(
|
response = await homeAssistantGetSignedURLIfNecessary(
|
||||||
this.hass,
|
this.hass,
|
||||||
endpoint.endpoint,
|
endpoint,
|
||||||
JSMPEG_URL_SIGN_EXPIRY_SECONDS,
|
JSMPEG_URL_SIGN_EXPIRY_SECONDS,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import {
|
|||||||
TemplateResult,
|
TemplateResult,
|
||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { guard } from 'lit/directives/guard.js';
|
import { guard } from 'lit/directives/guard.js';
|
||||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||||
import { CameraManager } from '../../camera-manager/manager.js';
|
import { CameraManager } from '../../camera-manager/manager.js';
|
||||||
import { QueryType } from '../../camera-manager/types.js';
|
import { QueryType } from '../../camera-manager/types.js';
|
||||||
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||||
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
||||||
|
import { SignedURLController } from '../../components-lib/signed-url-controller.js';
|
||||||
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
|
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
|
||||||
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
|
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
|
||||||
import { CameraConfig } from '../../config/schema/cameras.js';
|
import { CameraConfig } from '../../config/schema/cameras.js';
|
||||||
@@ -21,17 +22,16 @@ import { ViewerConfig } from '../../config/schema/viewer.js';
|
|||||||
import { canonicalizeHAURL } from '../../ha/canonical-url.js';
|
import { canonicalizeHAURL } from '../../ha/canonical-url.js';
|
||||||
import { isHARelativeURL } from '../../ha/is-ha-relative-url.js';
|
import { isHARelativeURL } from '../../ha/is-ha-relative-url.js';
|
||||||
import { ResolvedMediaCache, resolveMedia } from '../../ha/resolved-media.js';
|
import { ResolvedMediaCache, resolveMedia } from '../../ha/resolved-media.js';
|
||||||
import { homeAssistantSignPath } from '../../ha/sign-path.js';
|
import { HomeAssistant } from '../../ha/types.js';
|
||||||
import { HomeAssistant, ResolvedMedia } from '../../ha/types.js';
|
import { localize } from '../../localize/localize.js';
|
||||||
import { createProxiedEndpointIfNecessary } from '../../ha/web-proxy.js';
|
|
||||||
import '../../patches/ha-hls-player.js';
|
import '../../patches/ha-hls-player.js';
|
||||||
import viewerProviderStyle from '../../scss/viewer-provider.scss';
|
import viewerProviderStyle from '../../scss/viewer-provider.scss';
|
||||||
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../types.js';
|
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../types.js';
|
||||||
import { errorToConsole } from '../../utils/basic.js';
|
|
||||||
import { ViewItemClassifier } from '../../view/item-classifier.js';
|
import { ViewItemClassifier } from '../../view/item-classifier.js';
|
||||||
import { VideoContentType, ViewMedia } from '../../view/item.js';
|
import { VideoContentType, ViewMedia } from '../../view/item.js';
|
||||||
import { UnifiedQueryTransformer } from '../../view/unified-query-transformer.js';
|
import { UnifiedQueryTransformer } from '../../view/unified-query-transformer.js';
|
||||||
import '../image-player.js';
|
import '../image-player.js';
|
||||||
|
import { renderMessage } from '../message.js';
|
||||||
import { renderProgressIndicator } from '../progress-indicator.js';
|
import { renderProgressIndicator } from '../progress-indicator.js';
|
||||||
import '../video-player.js';
|
import '../video-player.js';
|
||||||
import './../media-dimensions-container';
|
import './../media-dimensions-container';
|
||||||
@@ -60,15 +60,32 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
private _refProvider: Ref<MediaPlayerElement> = createRef();
|
private _refProvider: Ref<MediaPlayerElement> = createRef();
|
||||||
private _refContainer: Ref<HTMLElement> = createRef();
|
|
||||||
private _lazyLoadController: LazyLoadController = new LazyLoadController(this);
|
private _lazyLoadController: LazyLoadController = new LazyLoadController(this);
|
||||||
|
|
||||||
@state()
|
private _resolvedMediaURL: string | null = null;
|
||||||
private _url: string | null = null;
|
|
||||||
|
private _signedURLController = new SignedURLController(this, () => {
|
||||||
|
if (!this.hass || !this._resolvedMediaURL) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
// HA-relative URLs need no proxying or signing.
|
||||||
|
if (isHARelativeURL(this._resolvedMediaURL)) {
|
||||||
|
return {
|
||||||
|
endpoint: { endpoint: canonicalizeHAURL(this.hass, this._resolvedMediaURL) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const cameraID = this.media?.getCameraID();
|
||||||
|
const camera = cameraID ? this.cameraManager?.getStore().getCamera(cameraID) : null;
|
||||||
|
return {
|
||||||
|
hass: this.hass,
|
||||||
|
endpoint: { endpoint: this._resolvedMediaURL },
|
||||||
|
proxyConfig: camera?.getMediaProxyConfig(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this._lazyLoadController.addListener((loaded) => loaded && this._setURL());
|
this._lazyLoadController.addListener((loaded) => loaded && this._resolveURL());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
||||||
@@ -108,69 +125,23 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _setURL(): Promise<void> {
|
private async _resolveURL(): Promise<void> {
|
||||||
const mediaContentID = this.media?.getContentID();
|
const contentID = this.media?.getContentID();
|
||||||
if (
|
if (!contentID || !this.hass || !this._lazyLoadController?.isLoaded()) {
|
||||||
!this.media ||
|
this._resolvedMediaURL = null;
|
||||||
!mediaContentID ||
|
|
||||||
!this.hass ||
|
|
||||||
!this._lazyLoadController?.isLoaded()
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolvedMedia: ResolvedMedia | null =
|
// Clear immediately so the SignedURLController doesn't see a stale URL
|
||||||
this.resolvedMediaCache?.get(mediaContentID) ?? null;
|
// from the previous media item during the async gap.
|
||||||
if (!resolvedMedia) {
|
this._resolvedMediaURL = null;
|
||||||
resolvedMedia = await resolveMedia(
|
|
||||||
this.hass,
|
|
||||||
mediaContentID,
|
|
||||||
this.resolvedMediaCache,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resolvedMedia) {
|
const resolved =
|
||||||
return;
|
this.resolvedMediaCache?.get(contentID) ??
|
||||||
}
|
(await resolveMedia(this.hass, contentID, this.resolvedMediaCache));
|
||||||
|
|
||||||
const unsignedURL = resolvedMedia.url;
|
this._resolvedMediaURL = resolved?.url ?? null;
|
||||||
if (isHARelativeURL(unsignedURL)) {
|
this.requestUpdate();
|
||||||
// No need to proxy or sign local resolved URLs.
|
|
||||||
this._url = canonicalizeHAURL(this.hass, unsignedURL);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cameraID = this.media.getCameraID();
|
|
||||||
const camera = cameraID ? this.cameraManager?.getStore().getCamera(cameraID) : null;
|
|
||||||
const proxyConfig = camera?.getProxyConfig();
|
|
||||||
|
|
||||||
if (!proxyConfig) {
|
|
||||||
this._url = unsignedURL;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create endpoint from unsigned URL - it doesn't need signing initially
|
|
||||||
const unsignedEndpoint = { endpoint: unsignedURL, sign: false };
|
|
||||||
const proxiedEndpoint = await createProxiedEndpointIfNecessary(
|
|
||||||
this.hass,
|
|
||||||
unsignedEndpoint,
|
|
||||||
proxyConfig,
|
|
||||||
{
|
|
||||||
context: 'media',
|
|
||||||
// The link may need to be opened multiple times.
|
|
||||||
openLimit: 0,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (proxiedEndpoint.sign) {
|
|
||||||
this._url = await homeAssistantSignPath(this.hass, proxiedEndpoint.endpoint);
|
|
||||||
} else {
|
|
||||||
this._url = proxiedEndpoint.endpoint;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
errorToConsole(e as Error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
@@ -187,7 +158,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
changedProps.has('resolvedMediaCache') ||
|
changedProps.has('resolvedMediaCache') ||
|
||||||
changedProps.has('hass')
|
changedProps.has('hass')
|
||||||
) {
|
) {
|
||||||
this._setURL();
|
this._resolveURL();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) {
|
if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) {
|
||||||
@@ -260,7 +231,19 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this._url) {
|
const error = this._signedURLController.getError();
|
||||||
|
if (error) {
|
||||||
|
return renderMessage({
|
||||||
|
type: 'error',
|
||||||
|
message: localize(
|
||||||
|
error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign',
|
||||||
|
),
|
||||||
|
context: this.media?.getContentID(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = this._signedURLController.getValue();
|
||||||
|
if (!url) {
|
||||||
return renderProgressIndicator({
|
return renderProgressIndicator({
|
||||||
cardWideConfig: this.cardWideConfig,
|
cardWideConfig: this.cardWideConfig,
|
||||||
});
|
});
|
||||||
@@ -280,7 +263,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
muted
|
muted
|
||||||
playsinline
|
playsinline
|
||||||
title="${this.media.getTitle() ?? ''}"
|
title="${this.media.getTitle() ?? ''}"
|
||||||
url=${this._url}
|
url=${url}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
?controls=${this.viewerConfig.controls.builtin}
|
?controls=${this.viewerConfig.controls.builtin}
|
||||||
>
|
>
|
||||||
@@ -288,7 +271,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
: html`
|
: html`
|
||||||
<advanced-camera-card-video-player
|
<advanced-camera-card-video-player
|
||||||
${ref(this._refProvider)}
|
${ref(this._refProvider)}
|
||||||
url=${this._url}
|
url=${url}
|
||||||
aria-label="${this.media.getTitle() ?? ''}"
|
aria-label="${this.media.getTitle() ?? ''}"
|
||||||
title="${this.media.getTitle() ?? ''}"
|
title="${this.media.getTitle() ?? ''}"
|
||||||
?controls=${this.viewerConfig.controls.builtin}
|
?controls=${this.viewerConfig.controls.builtin}
|
||||||
@@ -297,7 +280,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
`
|
`
|
||||||
: html`<advanced-camera-card-image-player
|
: html`<advanced-camera-card-image-player
|
||||||
${ref(this._refProvider)}
|
${ref(this._refProvider)}
|
||||||
url="${this._url}"
|
url="${url}"
|
||||||
aria-label="${this.media.getTitle() ?? ''}"
|
aria-label="${this.media.getTitle() ?? ''}"
|
||||||
title="${this.media.getTitle() ?? ''}"
|
title="${this.media.getTitle() ?? ''}"
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ptzCameraConfigDefaults, ptzCameraConfigSchema } from './camera/ptz';
|
|||||||
import { aspectRatioSchema } from './common/aspect-ratio';
|
import { aspectRatioSchema } from './common/aspect-ratio';
|
||||||
import { eventsMediaTypeSchema } from './common/events-media';
|
import { eventsMediaTypeSchema } from './common/events-media';
|
||||||
import { imageBaseConfigDefault, imageBaseConfigSchema } from './common/image';
|
import { imageBaseConfigDefault, imageBaseConfigSchema } from './common/image';
|
||||||
|
import { proxyBaseConfigDefault, proxyBaseConfigSchema } from './common/proxy';
|
||||||
import { severitySchema } from './common/severity';
|
import { severitySchema } from './common/severity';
|
||||||
|
|
||||||
const CAMERA_TRIGGER_EVENT_TYPES = [
|
const CAMERA_TRIGGER_EVENT_TYPES = [
|
||||||
@@ -143,32 +144,18 @@ export const cameraConfigDefault = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
dynamic: true,
|
...proxyBaseConfigDefault,
|
||||||
live: 'auto' as const,
|
live: 'auto' as const,
|
||||||
media: 'auto' as const,
|
media: 'auto' as const,
|
||||||
ssl_ciphers: 'auto' as const,
|
|
||||||
ssl_verification: 'auto' as const,
|
|
||||||
},
|
},
|
||||||
go2rtc: go2rtcConfigDefault,
|
go2rtc: go2rtcConfigDefault,
|
||||||
image: imageBaseConfigDefault,
|
image: imageBaseConfigDefault,
|
||||||
always_error_if_entity_unavailable: false,
|
always_error_if_entity_unavailable: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SSL_CIPHERS = ['default', 'insecure', 'intermediate', 'modern'] as const;
|
const proxyConfigSchema = proxyBaseConfigSchema.extend({
|
||||||
export type SSLCiphers = (typeof SSL_CIPHERS)[number];
|
|
||||||
|
|
||||||
const proxyConfigSchema = z.object({
|
|
||||||
live: z.boolean().or(z.literal('auto')).default(cameraConfigDefault.proxy.live),
|
live: z.boolean().or(z.literal('auto')).default(cameraConfigDefault.proxy.live),
|
||||||
media: z.boolean().or(z.literal('auto')).default(cameraConfigDefault.proxy.media),
|
media: z.boolean().or(z.literal('auto')).default(cameraConfigDefault.proxy.media),
|
||||||
dynamic: z.boolean().default(cameraConfigDefault.proxy.dynamic),
|
|
||||||
ssl_verification: z
|
|
||||||
.boolean()
|
|
||||||
.or(z.literal('auto'))
|
|
||||||
.default(cameraConfigDefault.proxy.ssl_verification),
|
|
||||||
ssl_ciphers: z
|
|
||||||
.enum(SSL_CIPHERS)
|
|
||||||
.or(z.literal('auto'))
|
|
||||||
.default(cameraConfigDefault.proxy.ssl_ciphers),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rotationSchema = z
|
const rotationSchema = z
|
||||||
|
|||||||
@@ -5,11 +5,6 @@ export const imageBaseConfigDefault = {
|
|||||||
refresh_seconds: 1,
|
refresh_seconds: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const imageConfigDefault = {
|
|
||||||
...imageBaseConfigDefault,
|
|
||||||
zoomable: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const IMAGE_MODES = ['auto', 'camera', 'entity', 'screensaver', 'url'] as const;
|
const IMAGE_MODES = ['auto', 'camera', 'entity', 'screensaver', 'url'] as const;
|
||||||
export type ImageMode = (typeof IMAGE_MODES)[number];
|
export type ImageMode = (typeof IMAGE_MODES)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const SSL_CIPHERS = ['default', 'insecure', 'intermediate', 'modern'] as const;
|
||||||
|
type SSLCiphers = (typeof SSL_CIPHERS)[number];
|
||||||
|
|
||||||
|
export const proxyBaseConfigDefault = {
|
||||||
|
dynamic: true,
|
||||||
|
ssl_ciphers: 'auto' as const,
|
||||||
|
ssl_verification: 'auto' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const proxyBaseConfigSchema = z.object({
|
||||||
|
dynamic: z.boolean().default(proxyBaseConfigDefault.dynamic),
|
||||||
|
ssl_verification: z
|
||||||
|
.boolean()
|
||||||
|
.or(z.literal('auto'))
|
||||||
|
.default(proxyBaseConfigDefault.ssl_verification),
|
||||||
|
ssl_ciphers: z
|
||||||
|
.enum(SSL_CIPHERS)
|
||||||
|
.or(z.literal('auto'))
|
||||||
|
.default(proxyBaseConfigDefault.ssl_ciphers),
|
||||||
|
});
|
||||||
|
type UnresolvedProxyConfig = z.output<typeof proxyBaseConfigSchema>;
|
||||||
|
|
||||||
|
export interface ResolvedProxyConfig {
|
||||||
|
dynamic: boolean;
|
||||||
|
ssl_verification: boolean;
|
||||||
|
ssl_ciphers: SSLCiphers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnabledProxyConfig extends ResolvedProxyConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
|
// Whether proxying is a strict requirement. When false, callers may fall
|
||||||
|
// back to the original URL if the proxy integration is unavailable.
|
||||||
|
enforce?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveProxyConfig = (
|
||||||
|
config: UnresolvedProxyConfig,
|
||||||
|
): ResolvedProxyConfig => ({
|
||||||
|
dynamic: config.dynamic,
|
||||||
|
ssl_verification: config.ssl_verification === 'auto' ? true : config.ssl_verification,
|
||||||
|
ssl_ciphers: config.ssl_ciphers === 'auto' ? 'default' : config.ssl_ciphers,
|
||||||
|
});
|
||||||
@@ -1,9 +1,25 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { actionsSchema } from './actions/types';
|
import { actionsSchema } from './actions/types';
|
||||||
import { imageBaseConfigSchema, imageConfigDefault } from './common/image';
|
import { imageBaseConfigDefault, imageBaseConfigSchema } from './common/image';
|
||||||
|
import { proxyBaseConfigDefault, proxyBaseConfigSchema } from './common/proxy';
|
||||||
|
|
||||||
|
export const imageConfigDefault = {
|
||||||
|
...imageBaseConfigDefault,
|
||||||
|
proxy: {
|
||||||
|
...proxyBaseConfigDefault,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
zoomable: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageProxyConfigSchema = proxyBaseConfigSchema.extend({
|
||||||
|
enabled: z.boolean().default(imageConfigDefault.proxy.enabled),
|
||||||
|
});
|
||||||
|
export type ImageViewProxyConfig = z.infer<typeof imageProxyConfigSchema>;
|
||||||
|
|
||||||
export const imageConfigSchema = imageBaseConfigSchema
|
export const imageConfigSchema = imageBaseConfigSchema
|
||||||
.extend({
|
.extend({
|
||||||
|
proxy: imageProxyConfigSchema.optional(),
|
||||||
zoomable: z.boolean().default(imageConfigDefault.zoomable),
|
zoomable: z.boolean().default(imageConfigDefault.zoomable),
|
||||||
})
|
})
|
||||||
.extend(actionsSchema.shape)
|
.extend(actionsSchema.shape)
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import { deepRemoveDefaults } from '../../utils/zod/deep-remove-defaults';
|
|||||||
import { automationsSchema } from './automations';
|
import { automationsSchema } from './automations';
|
||||||
import { cameraConfigDefault, cameraConfigSchema, camerasConfigSchema } from './cameras';
|
import { cameraConfigDefault, cameraConfigSchema, camerasConfigSchema } from './cameras';
|
||||||
import { cardIDRegex } from './common/const';
|
import { cardIDRegex } from './common/const';
|
||||||
import { imageConfigDefault } from './common/image';
|
|
||||||
import { DebugConfig, debugConfigDefault, debugConfigSchema } from './debug';
|
import { DebugConfig, debugConfigDefault, debugConfigSchema } from './debug';
|
||||||
import { dimensionsConfigSchema } from './dimensions';
|
import { dimensionsConfigSchema } from './dimensions';
|
||||||
import { pictureElementsSchema } from './elements/types';
|
import { pictureElementsSchema } from './elements/types';
|
||||||
import { foldersConfigSchema } from './folders';
|
import { foldersConfigSchema } from './folders';
|
||||||
import { imageConfigSchema } from './image';
|
import { imageConfigDefault, imageConfigSchema } from './image';
|
||||||
import { liveConfigDefault, liveConfigSchema } from './live';
|
import { liveConfigDefault, liveConfigSchema } from './live';
|
||||||
import { mediaGalleryConfigDefault, mediaGalleryConfigSchema } from './media-gallery';
|
import { mediaGalleryConfigDefault, mediaGalleryConfigSchema } from './media-gallery';
|
||||||
import { menuConfigDefault, menuConfigSchema } from './menu';
|
import { menuConfigDefault, menuConfigSchema } from './menu';
|
||||||
|
|||||||
@@ -364,6 +364,11 @@ const CONF_IMAGE = 'image' as const;
|
|||||||
export const CONF_IMAGE_ENTITY = `${CONF_IMAGE}.entity` as const;
|
export const CONF_IMAGE_ENTITY = `${CONF_IMAGE}.entity` as const;
|
||||||
export const CONF_IMAGE_ENTITY_PARAMETERS = `${CONF_IMAGE}.entity_parameters` as const;
|
export const CONF_IMAGE_ENTITY_PARAMETERS = `${CONF_IMAGE}.entity_parameters` as const;
|
||||||
export const CONF_IMAGE_MODE = `${CONF_IMAGE}.mode` as const;
|
export const CONF_IMAGE_MODE = `${CONF_IMAGE}.mode` as const;
|
||||||
|
export const CONF_IMAGE_PROXY_DYNAMIC = `${CONF_IMAGE}.proxy.dynamic` as const;
|
||||||
|
export const CONF_IMAGE_PROXY_ENABLED = `${CONF_IMAGE}.proxy.enabled` as const;
|
||||||
|
export const CONF_IMAGE_PROXY_SSL_CIPHERS = `${CONF_IMAGE}.proxy.ssl_ciphers` as const;
|
||||||
|
export const CONF_IMAGE_PROXY_SSL_VERIFICATION =
|
||||||
|
`${CONF_IMAGE}.proxy.ssl_verification` as const;
|
||||||
export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const;
|
export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const;
|
||||||
export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const;
|
export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const;
|
||||||
|
|
||||||
|
|||||||
+97
-44
@@ -110,6 +110,10 @@ import {
|
|||||||
CONF_IMAGE_ENTITY,
|
CONF_IMAGE_ENTITY,
|
||||||
CONF_IMAGE_ENTITY_PARAMETERS,
|
CONF_IMAGE_ENTITY_PARAMETERS,
|
||||||
CONF_IMAGE_MODE,
|
CONF_IMAGE_MODE,
|
||||||
|
CONF_IMAGE_PROXY_DYNAMIC,
|
||||||
|
CONF_IMAGE_PROXY_ENABLED,
|
||||||
|
CONF_IMAGE_PROXY_SSL_CIPHERS,
|
||||||
|
CONF_IMAGE_PROXY_SSL_VERIFICATION,
|
||||||
CONF_IMAGE_REFRESH_SECONDS,
|
CONF_IMAGE_REFRESH_SECONDS,
|
||||||
CONF_IMAGE_URL,
|
CONF_IMAGE_URL,
|
||||||
CONF_LIVE_AUTO_MUTE,
|
CONF_LIVE_AUTO_MUTE,
|
||||||
@@ -315,6 +319,7 @@ const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE = 'media_viewer.controls.timeline';
|
|||||||
const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT =
|
const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT =
|
||||||
'media_viewer.controls.timeline.format';
|
'media_viewer.controls.timeline.format';
|
||||||
const MENU_MEDIA_VIEWER_DISPLAY = 'media_viewer.display';
|
const MENU_MEDIA_VIEWER_DISPLAY = 'media_viewer.display';
|
||||||
|
const MENU_IMAGE_PROXY = 'image.proxy';
|
||||||
const MENU_MENU_BUTTONS = 'menu.buttons';
|
const MENU_MENU_BUTTONS = 'menu.buttons';
|
||||||
const MENU_OPTIONS = 'options';
|
const MENU_OPTIONS = 'options';
|
||||||
const MENU_PERFORMANCE_FEATURES = 'performance.features';
|
const MENU_PERFORMANCE_FEATURES = 'performance.features';
|
||||||
@@ -388,6 +393,7 @@ const SUBMENU_DOC_LINKS: Record<string, string> = {
|
|||||||
[MENU_MEDIA_VIEWER_CONTROLS_TIMELINE]: 'configuration/media-viewer?id=timeline',
|
[MENU_MEDIA_VIEWER_CONTROLS_TIMELINE]: 'configuration/media-viewer?id=timeline',
|
||||||
[MENU_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT]: 'configuration/media-viewer?id=format',
|
[MENU_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT]: 'configuration/media-viewer?id=format',
|
||||||
[MENU_MEDIA_VIEWER_DISPLAY]: 'configuration/media-viewer?id=display',
|
[MENU_MEDIA_VIEWER_DISPLAY]: 'configuration/media-viewer?id=display',
|
||||||
|
[MENU_IMAGE_PROXY]: 'configuration/image?id=proxy',
|
||||||
[MENU_MENU_BUTTONS]: 'configuration/menu?id=buttons',
|
[MENU_MENU_BUTTONS]: 'configuration/menu?id=buttons',
|
||||||
[MENU_OPTIONS]: 'configuration/README',
|
[MENU_OPTIONS]: 'configuration/README',
|
||||||
[MENU_PERFORMANCE_FEATURES]: 'configuration/performance?id=features',
|
[MENU_PERFORMANCE_FEATURES]: 'configuration/performance?id=features',
|
||||||
@@ -1053,15 +1059,15 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
{ value: '', label: '' },
|
{ value: '', label: '' },
|
||||||
{
|
{
|
||||||
value: 'auto',
|
value: 'auto',
|
||||||
label: localize('config.cameras.proxy.modes.auto'),
|
label: localize('config.common.proxy.modes.auto'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: true,
|
value: true,
|
||||||
label: localize('config.cameras.proxy.modes.true'),
|
label: localize('config.common.proxy.modes.true'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: false,
|
value: false,
|
||||||
label: localize('config.cameras.proxy.modes.false'),
|
label: localize('config.common.proxy.modes.false'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1069,23 +1075,23 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
{ value: '', label: '' },
|
{ value: '', label: '' },
|
||||||
{
|
{
|
||||||
value: 'auto',
|
value: 'auto',
|
||||||
label: localize('config.cameras.proxy.ssl_ciphers.auto'),
|
label: localize('config.common.proxy.ssl_ciphers.auto'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'default',
|
value: 'default',
|
||||||
label: localize('config.cameras.proxy.ssl_ciphers.default'),
|
label: localize('config.common.proxy.ssl_ciphers.default'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'insecure',
|
value: 'insecure',
|
||||||
label: localize('config.cameras.proxy.ssl_ciphers.insecure'),
|
label: localize('config.common.proxy.ssl_ciphers.insecure'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'intermediate',
|
value: 'intermediate',
|
||||||
label: localize('config.cameras.proxy.ssl_ciphers.intermediate'),
|
label: localize('config.common.proxy.ssl_ciphers.intermediate'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'modern',
|
value: 'modern',
|
||||||
label: localize('config.cameras.proxy.ssl_ciphers.modern'),
|
label: localize('config.common.proxy.ssl_ciphers.modern'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1093,15 +1099,15 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
{ value: '', label: '' },
|
{ value: '', label: '' },
|
||||||
{
|
{
|
||||||
value: 'auto',
|
value: 'auto',
|
||||||
label: localize('config.cameras.proxy.ssl_verification.auto'),
|
label: localize('config.common.proxy.ssl_verification.auto'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: true,
|
value: true,
|
||||||
label: localize('config.cameras.proxy.ssl_verification.true'),
|
label: localize('config.common.proxy.ssl_verification.true'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: false,
|
value: false,
|
||||||
label: localize('config.cameras.proxy.ssl_verification.false'),
|
label: localize('config.common.proxy.ssl_verification.false'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -2102,6 +2108,9 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
configPathEntity: string,
|
configPathEntity: string,
|
||||||
configPathEntityParameters: string,
|
configPathEntityParameters: string,
|
||||||
configPathRefreshSeconds: string,
|
configPathRefreshSeconds: string,
|
||||||
|
options?: {
|
||||||
|
proxyMenu?: TemplateResult;
|
||||||
|
},
|
||||||
): TemplateResult {
|
): TemplateResult {
|
||||||
return html`
|
return html`
|
||||||
${this._renderOptionSelector(configPathMode, this._imageModes, {
|
${this._renderOptionSelector(configPathMode, this._imageModes, {
|
||||||
@@ -2123,9 +2132,64 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
${this._renderNumberInput(configPathRefreshSeconds, {
|
${this._renderNumberInput(configPathRefreshSeconds, {
|
||||||
label: localize('config.common.image.refresh_seconds'),
|
label: localize('config.common.image.refresh_seconds'),
|
||||||
})}
|
})}
|
||||||
|
${options?.proxyMenu ?? html``}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _renderProxySubmenu(
|
||||||
|
domain: string,
|
||||||
|
key: unknown,
|
||||||
|
labelPath: string,
|
||||||
|
configPathDynamic: string,
|
||||||
|
dynamicDefault: boolean,
|
||||||
|
configPathSSLCiphers: string,
|
||||||
|
configPathSSLVerification: string,
|
||||||
|
options?: {
|
||||||
|
configPathEnabled?: string;
|
||||||
|
configPathLive?: string;
|
||||||
|
configPathMedia?: string;
|
||||||
|
enabledDefault?: boolean;
|
||||||
|
},
|
||||||
|
): TemplateResult {
|
||||||
|
return this._putInSubmenu(
|
||||||
|
domain,
|
||||||
|
key,
|
||||||
|
labelPath,
|
||||||
|
'mdi:arrow-decision',
|
||||||
|
html`
|
||||||
|
${options?.configPathEnabled !== undefined &&
|
||||||
|
options.enabledDefault !== undefined
|
||||||
|
? this._renderSwitch(options.configPathEnabled, options.enabledDefault, {
|
||||||
|
label: localize('config.common.proxy.modes.true'),
|
||||||
|
})
|
||||||
|
: html``}
|
||||||
|
${options?.configPathLive
|
||||||
|
? this._renderOptionSelector(options.configPathLive, this._proxyModes, {
|
||||||
|
label: localize('config.cameras.proxy.live'),
|
||||||
|
})
|
||||||
|
: html``}
|
||||||
|
${options?.configPathMedia
|
||||||
|
? this._renderOptionSelector(options.configPathMedia, this._proxyModes, {
|
||||||
|
label: localize('config.cameras.proxy.media'),
|
||||||
|
})
|
||||||
|
: html``}
|
||||||
|
${this._renderSwitch(configPathDynamic, dynamicDefault, {
|
||||||
|
label: localize('config.common.proxy.dynamic'),
|
||||||
|
})}
|
||||||
|
${this._renderOptionSelector(
|
||||||
|
configPathSSLVerification,
|
||||||
|
this._proxySSLVerification,
|
||||||
|
{
|
||||||
|
label: localize('config.common.proxy.ssl_verification.editor_label'),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
${this._renderOptionSelector(configPathSSLCiphers, this._proxySSLCiphers, {
|
||||||
|
label: localize('config.common.proxy.ssl_ciphers.editor_label'),
|
||||||
|
})}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private _modifyConfig(func: (config: RawAdvancedCameraCardConfig) => boolean): void {
|
private _modifyConfig(func: (config: RawAdvancedCameraCardConfig) => boolean): void {
|
||||||
if (this._config) {
|
if (this._config) {
|
||||||
const newConfig = copyConfig(this._config);
|
const newConfig = copyConfig(this._config);
|
||||||
@@ -2835,54 +2899,28 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
)}
|
)}
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
${this._putInSubmenu(
|
${this._renderProxySubmenu(
|
||||||
MENU_CAMERAS_PROXY,
|
MENU_CAMERAS_PROXY,
|
||||||
cameraIndex,
|
cameraIndex,
|
||||||
'config.cameras.proxy.editor_label',
|
'config.cameras.proxy.editor_label',
|
||||||
'mdi:arrow-decision',
|
|
||||||
html`
|
|
||||||
${this._renderOptionSelector(
|
|
||||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_LIVE, cameraIndex),
|
|
||||||
this._proxyModes,
|
|
||||||
{
|
|
||||||
label: localize('config.cameras.proxy.live'),
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
${this._renderOptionSelector(
|
|
||||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_MEDIA, cameraIndex),
|
|
||||||
this._proxyModes,
|
|
||||||
{
|
|
||||||
label: localize('config.cameras.proxy.media'),
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
${this._renderSwitch(
|
|
||||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_DYNAMIC, cameraIndex),
|
getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_DYNAMIC, cameraIndex),
|
||||||
this._defaults.cameras.proxy.dynamic,
|
this._defaults.cameras.proxy.dynamic,
|
||||||
)}
|
getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS, cameraIndex),
|
||||||
${this._renderOptionSelector(
|
|
||||||
getArrayConfigPath(
|
getArrayConfigPath(
|
||||||
CONF_CAMERAS_ARRAY_PROXY_SSL_VERIFICATION,
|
CONF_CAMERAS_ARRAY_PROXY_SSL_VERIFICATION,
|
||||||
cameraIndex,
|
cameraIndex,
|
||||||
),
|
),
|
||||||
this._proxySSLVerification,
|
|
||||||
{
|
{
|
||||||
label: localize(
|
configPathLive: getArrayConfigPath(
|
||||||
'config.cameras.proxy.ssl_verification.editor_label',
|
CONF_CAMERAS_ARRAY_PROXY_LIVE,
|
||||||
|
cameraIndex,
|
||||||
),
|
),
|
||||||
},
|
configPathMedia: getArrayConfigPath(
|
||||||
)}
|
CONF_CAMERAS_ARRAY_PROXY_MEDIA,
|
||||||
${this._renderOptionSelector(
|
|
||||||
getArrayConfigPath(
|
|
||||||
CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS,
|
|
||||||
cameraIndex,
|
cameraIndex,
|
||||||
),
|
),
|
||||||
this._proxySSLCiphers,
|
|
||||||
{
|
|
||||||
label: localize('config.cameras.proxy.ssl_ciphers.editor_label'),
|
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
`,
|
|
||||||
)}
|
|
||||||
</div>`
|
</div>`
|
||||||
: ``}
|
: ``}
|
||||||
</div>
|
</div>
|
||||||
@@ -3470,6 +3508,21 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
CONF_IMAGE_ENTITY,
|
CONF_IMAGE_ENTITY,
|
||||||
CONF_IMAGE_ENTITY_PARAMETERS,
|
CONF_IMAGE_ENTITY_PARAMETERS,
|
||||||
CONF_IMAGE_REFRESH_SECONDS,
|
CONF_IMAGE_REFRESH_SECONDS,
|
||||||
|
{
|
||||||
|
proxyMenu: this._renderProxySubmenu(
|
||||||
|
MENU_IMAGE_PROXY,
|
||||||
|
true,
|
||||||
|
'config.common.image.proxy.editor_label',
|
||||||
|
CONF_IMAGE_PROXY_DYNAMIC,
|
||||||
|
this._defaults.image.proxy.dynamic,
|
||||||
|
CONF_IMAGE_PROXY_SSL_CIPHERS,
|
||||||
|
CONF_IMAGE_PROXY_SSL_VERIFICATION,
|
||||||
|
{
|
||||||
|
configPathEnabled: CONF_IMAGE_PROXY_ENABLED,
|
||||||
|
enabledDefault: this._defaults.image.proxy.enabled,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
)}
|
)}
|
||||||
</div>`
|
</div>`
|
||||||
: ''}
|
: ''}
|
||||||
|
|||||||
+3
-8
@@ -1,7 +1,7 @@
|
|||||||
import { ZodSchema } from 'zod';
|
import { ZodSchema } from 'zod';
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
import { AdvancedCameraCardError, Endpoint } from '../types';
|
import { AdvancedCameraCardError, Endpoint } from '../types';
|
||||||
import { homeAssistantSignPath } from './sign-path';
|
import { homeAssistantGetSignedURLIfNecessary } from './sign-path';
|
||||||
import { HomeAssistant } from './types';
|
import { HomeAssistant } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,13 +22,9 @@ export const homeAssistantSignAndFetch = async <T>(
|
|||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
},
|
},
|
||||||
): Promise<T> => {
|
): Promise<T> => {
|
||||||
let url: string | null = endpoint.endpoint;
|
let url: string | null;
|
||||||
const sign = endpoint.sign;
|
|
||||||
|
|
||||||
// Sign the path if needed
|
|
||||||
if (sign) {
|
|
||||||
try {
|
try {
|
||||||
url = await homeAssistantSignPath(hass, url);
|
url = await homeAssistantGetSignedURLIfNecessary(hass, endpoint);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new AdvancedCameraCardError(localize('error.failed_sign'), {
|
throw new AdvancedCameraCardError(localize('error.failed_sign'), {
|
||||||
endpoint,
|
endpoint,
|
||||||
@@ -41,7 +37,6 @@ export const homeAssistantSignAndFetch = async <T>(
|
|||||||
endpoint,
|
endpoint,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
|
|||||||
+20
-2
@@ -1,6 +1,6 @@
|
|||||||
import { SignedPath, signedPathSchema } from '../types';
|
import { type Endpoint, SignedPath, signedPathSchema } from '../types';
|
||||||
import { homeAssistantWSRequest } from './ws-request';
|
|
||||||
import { HomeAssistant } from './types';
|
import { HomeAssistant } from './types';
|
||||||
|
import { homeAssistantWSRequest } from './ws-request';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request that HA sign a path. May throw.
|
* Request that HA sign a path. May throw.
|
||||||
@@ -30,3 +30,21 @@ export async function homeAssistantSignPath(
|
|||||||
}
|
}
|
||||||
return hass.hassUrl(response.path);
|
return hass.hassUrl(response.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign an endpoint's path if the endpoint requires signing.
|
||||||
|
* @param hass The HomeAssistant object used to request the signature.
|
||||||
|
* @param endpoint The endpoint to potentially sign.
|
||||||
|
* @param expires An optional number of seconds to sign the path for.
|
||||||
|
* @returns The signed or unsigned URL, or null if signing failed.
|
||||||
|
*/
|
||||||
|
export async function homeAssistantGetSignedURLIfNecessary(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
endpoint: Endpoint,
|
||||||
|
expires?: number,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!endpoint.sign) {
|
||||||
|
return endpoint.endpoint;
|
||||||
|
}
|
||||||
|
return await homeAssistantSignPath(hass, endpoint.endpoint, expires);
|
||||||
|
}
|
||||||
|
|||||||
+21
-27
@@ -1,4 +1,4 @@
|
|||||||
import { CameraProxyConfig } from '../camera-manager/types';
|
import { EnabledProxyConfig, ResolvedProxyConfig } from '../config/schema/common/proxy';
|
||||||
import { Endpoint } from '../types';
|
import { Endpoint } from '../types';
|
||||||
import { HomeAssistant } from './types';
|
import { HomeAssistant } from './types';
|
||||||
|
|
||||||
@@ -26,22 +26,12 @@ export const getWebProxiedURL = (url: string, options?: ProxiedURLOptions): stri
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shouldUseWebProxy = (
|
|
||||||
hass: HomeAssistant,
|
|
||||||
proxyConfig: CameraProxyConfig,
|
|
||||||
context: 'media' | 'live' = 'media',
|
|
||||||
): boolean => {
|
|
||||||
return hasWebProxyAvailable(hass) && !!proxyConfig[context];
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function addDynamicProxyURL(
|
export async function addDynamicProxyURL(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
url_pattern: string,
|
url_pattern: string,
|
||||||
options?: {
|
options?: {
|
||||||
proxyConfig?: CameraProxyConfig;
|
proxyConfig?: ResolvedProxyConfig;
|
||||||
urlID?: string;
|
urlID?: string;
|
||||||
sslVerification?: boolean;
|
|
||||||
sslCiphers?: string;
|
|
||||||
openLimit?: number;
|
openLimit?: number;
|
||||||
ttl?: number;
|
ttl?: number;
|
||||||
allowUnauthenticated?: boolean;
|
allowUnauthenticated?: boolean;
|
||||||
@@ -51,9 +41,8 @@ export async function addDynamicProxyURL(
|
|||||||
url_pattern: url_pattern,
|
url_pattern: url_pattern,
|
||||||
...(options && {
|
...(options && {
|
||||||
url_id: options.urlID,
|
url_id: options.urlID,
|
||||||
ssl_verification:
|
ssl_verification: options.proxyConfig?.ssl_verification,
|
||||||
options.sslVerification ?? options?.proxyConfig?.ssl_verification,
|
ssl_ciphers: options.proxyConfig?.ssl_ciphers,
|
||||||
ssl_ciphers: options.sslCiphers ?? options?.proxyConfig?.ssl_ciphers,
|
|
||||||
open_limit: options.openLimit,
|
open_limit: options.openLimit,
|
||||||
ttl: options.ttl,
|
ttl: options.ttl,
|
||||||
allow_unauthenticated: options.allowUnauthenticated,
|
allow_unauthenticated: options.allowUnauthenticated,
|
||||||
@@ -61,8 +50,7 @@ export async function addDynamicProxyURL(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateProxiedEndpointOptions {
|
export interface CreateProxiedEndpointOptions {
|
||||||
context?: 'live' | 'media';
|
|
||||||
ttl?: number;
|
ttl?: number;
|
||||||
websocket?: boolean;
|
websocket?: boolean;
|
||||||
openLimit?: number;
|
openLimit?: number;
|
||||||
@@ -73,25 +61,31 @@ interface CreateProxiedEndpointOptions {
|
|||||||
* Handles dynamic proxy registration and returns a proxied Endpoint.
|
* Handles dynamic proxy registration and returns a proxied Endpoint.
|
||||||
* @param hass Home Assistant instance.
|
* @param hass Home Assistant instance.
|
||||||
* @param endpoint The endpoint to potentially proxy.
|
* @param endpoint The endpoint to potentially proxy.
|
||||||
* @param proxyConfig The camera proxy configuration. If undefined, returns original endpoint.
|
* @param proxyConfig The proxy configuration. If undefined or not enabled,
|
||||||
|
* returns the original endpoint.
|
||||||
* @param options Additional options for proxy registration.
|
* @param options Additional options for proxy registration.
|
||||||
* @returns Proxied Endpoint if proxying needed, original endpoint otherwise.
|
* @returns Proxied Endpoint if proxying needed, original endpoint if proxying
|
||||||
|
* is not enabled, or null if proxying is required but unavailable.
|
||||||
*/
|
*/
|
||||||
export const createProxiedEndpointIfNecessary = async (
|
export const createProxiedEndpointIfNecessary = async (
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
endpoint: Endpoint,
|
endpoint: Endpoint,
|
||||||
proxyConfig?: CameraProxyConfig,
|
proxyConfig?: EnabledProxyConfig,
|
||||||
options?: CreateProxiedEndpointOptions,
|
options?: CreateProxiedEndpointOptions,
|
||||||
): Promise<Endpoint> => {
|
): Promise<Endpoint | null> => {
|
||||||
const context = options?.context ?? 'media';
|
if (!proxyConfig || !proxyConfig.enabled) {
|
||||||
if (!proxyConfig || !shouldUseWebProxy(hass, proxyConfig, context)) {
|
|
||||||
return endpoint;
|
return endpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasWebProxyAvailable(hass)) {
|
||||||
|
return proxyConfig.enforce === true ? null : endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
if (proxyConfig.dynamic) {
|
if (proxyConfig.dynamic) {
|
||||||
// Strip hash fragment for registration - it's client-side only and
|
// Strip hash fragment — it's client-side only and not relevant for
|
||||||
// not relevant for proxy pattern matching.
|
// proxy pattern matching.
|
||||||
const registrationUrl = endpoint.endpoint.split(/#/)[0];
|
const url = endpoint.endpoint.split(/#/)[0];
|
||||||
await addDynamicProxyURL(hass, registrationUrl, {
|
await addDynamicProxyURL(hass, url, {
|
||||||
proxyConfig,
|
proxyConfig,
|
||||||
ttl: options?.ttl,
|
ttl: options?.ttl,
|
||||||
openLimit: options?.openLimit ?? 0,
|
openLimit: options?.openLimit ?? 0,
|
||||||
|
|||||||
@@ -64,38 +64,18 @@
|
|||||||
"rotation": "Rotation",
|
"rotation": "Rotation",
|
||||||
"rotations": {
|
"rotations": {
|
||||||
"0": "Keine Rotation",
|
"0": "Keine Rotation",
|
||||||
"90": "90 Grad im Uhrzeigersinn",
|
|
||||||
"180": "180 Grad im Uhrzeigersinn",
|
"180": "180 Grad im Uhrzeigersinn",
|
||||||
"270": "270 Grad im Uhrzeigersinn"
|
"270": "270 Grad im Uhrzeigersinn",
|
||||||
|
"90": "90 Grad im Uhrzeigersinn"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"go2rtc": {
|
"go2rtc": {
|
||||||
"url": "go2rtc URL"
|
"url": "go2rtc URL"
|
||||||
},
|
},
|
||||||
"proxy": {
|
"proxy": {
|
||||||
"dynamic": "Dynamischer Proxy",
|
|
||||||
"editor_label": "Kamera Proxy",
|
"editor_label": "Kamera Proxy",
|
||||||
"live": "Live Proxy",
|
"live": "Live Proxy",
|
||||||
"media": "Medien Proxy",
|
"media": "Medien Proxy"
|
||||||
"modes": {
|
|
||||||
"auto": "Proxy automatisch konfigurieren",
|
|
||||||
"false": "Proxy deaktiviert",
|
|
||||||
"true": "Proxy aktiviert"
|
|
||||||
},
|
|
||||||
"ssl_ciphers": {
|
|
||||||
"auto": "SSL ciphers automatisch konfiguriert",
|
|
||||||
"default": "Standard SSL ciphers",
|
|
||||||
"editor_label": "SSL ciphers",
|
|
||||||
"insecure": "Unsichere SSL ciphers",
|
|
||||||
"intermediate": "Zwischen SSL ciphers",
|
|
||||||
"modern": "Moderne SSL ciphers"
|
|
||||||
},
|
|
||||||
"ssl_verification": {
|
|
||||||
"auto": "SSL Überprüfung automatisch konfiguriert",
|
|
||||||
"editor_label": "SSL Überprüfung",
|
|
||||||
"false": "SSL Überprüfung deaktiviert",
|
|
||||||
"true": "SSL Überprüfung aktiviert"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"reolink": {
|
"reolink": {
|
||||||
"editor_label": "Reolink Optionen",
|
"editor_label": "Reolink Optionen",
|
||||||
@@ -116,10 +96,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"media_types": {
|
|
||||||
"events": "Ereignisse",
|
|
||||||
"recordings": "Aufzeichnungen"
|
|
||||||
},
|
|
||||||
"controls": {
|
"controls": {
|
||||||
"builtin": "Eingebettete Video Bedienelemente",
|
"builtin": "Eingebettete Video Bedienelemente",
|
||||||
"thumbnails": {
|
"thumbnails": {
|
||||||
@@ -165,6 +141,32 @@
|
|||||||
"microphone_mute": "Bei Mikrofon Stummschaltung",
|
"microphone_mute": "Bei Mikrofon Stummschaltung",
|
||||||
"microphone_unmute": "Bei Mikrofon Reaktivierung"
|
"microphone_unmute": "Bei Mikrofon Reaktivierung"
|
||||||
},
|
},
|
||||||
|
"media_types": {
|
||||||
|
"events": "Ereignisse",
|
||||||
|
"recordings": "Aufzeichnungen"
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"dynamic": "Dynamischer Proxy",
|
||||||
|
"modes": {
|
||||||
|
"auto": "Proxy automatisch konfigurieren",
|
||||||
|
"false": "Proxy deaktiviert",
|
||||||
|
"true": "Proxy aktiviert"
|
||||||
|
},
|
||||||
|
"ssl_ciphers": {
|
||||||
|
"auto": "SSL ciphers automatisch konfiguriert",
|
||||||
|
"default": "Standard SSL ciphers",
|
||||||
|
"editor_label": "SSL ciphers",
|
||||||
|
"insecure": "Unsichere SSL ciphers",
|
||||||
|
"intermediate": "Zwischen SSL ciphers",
|
||||||
|
"modern": "Moderne SSL ciphers"
|
||||||
|
},
|
||||||
|
"ssl_verification": {
|
||||||
|
"auto": "SSL Überprüfung automatisch konfiguriert",
|
||||||
|
"editor_label": "SSL Überprüfung",
|
||||||
|
"false": "SSL Überprüfung deaktiviert",
|
||||||
|
"true": "SSL Überprüfung aktiviert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"timeline": {
|
"timeline": {
|
||||||
"style": "Zeitleisten Stil",
|
"style": "Zeitleisten Stil",
|
||||||
"styles": {
|
"styles": {
|
||||||
|
|||||||
@@ -173,29 +173,9 @@
|
|||||||
"url": "MotionEye UI URL"
|
"url": "MotionEye UI URL"
|
||||||
},
|
},
|
||||||
"proxy": {
|
"proxy": {
|
||||||
"dynamic": "Dynamic proxying",
|
|
||||||
"editor_label": "Camera proxying",
|
"editor_label": "Camera proxying",
|
||||||
"live": "Live proxying",
|
"live": "Live proxying",
|
||||||
"media": "Media proxying",
|
"media": "Media proxying"
|
||||||
"modes": {
|
|
||||||
"auto": "Proxying automatically configured",
|
|
||||||
"false": "Proxying disabled",
|
|
||||||
"true": "Proxying enabled"
|
|
||||||
},
|
|
||||||
"ssl_ciphers": {
|
|
||||||
"auto": "SSL ciphers automatically configured",
|
|
||||||
"default": "Default SSL ciphers",
|
|
||||||
"editor_label": "SSL ciphers",
|
|
||||||
"insecure": "Insecure SSL ciphers",
|
|
||||||
"intermediate": "Intermediate SSL ciphers",
|
|
||||||
"modern": "Modern SSL ciphers"
|
|
||||||
},
|
|
||||||
"ssl_verification": {
|
|
||||||
"auto": "SSL verification automatically configured",
|
|
||||||
"editor_label": "SSL verification",
|
|
||||||
"false": "SSL verification disabled",
|
|
||||||
"true": "SSL verification enabled"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"reolink": {
|
"reolink": {
|
||||||
"editor_label": "Reolink options",
|
"editor_label": "Reolink options",
|
||||||
@@ -322,6 +302,9 @@
|
|||||||
"screensaver": "Embedded screensaver image",
|
"screensaver": "Embedded screensaver image",
|
||||||
"url": "Arbitrary image specified by URL"
|
"url": "Arbitrary image specified by URL"
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"editor_label": "Image proxying"
|
||||||
|
},
|
||||||
"refresh_seconds": "Number of seconds after which to refresh (0=never)",
|
"refresh_seconds": "Number of seconds after which to refresh (0=never)",
|
||||||
"url": "Static image URL"
|
"url": "Static image URL"
|
||||||
},
|
},
|
||||||
@@ -342,6 +325,28 @@
|
|||||||
"recordings": "Recordings",
|
"recordings": "Recordings",
|
||||||
"reviews": "Reviews"
|
"reviews": "Reviews"
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"dynamic": "Dynamic proxying",
|
||||||
|
"modes": {
|
||||||
|
"auto": "Proxying automatically configured",
|
||||||
|
"false": "Proxying disabled",
|
||||||
|
"true": "Proxying enabled"
|
||||||
|
},
|
||||||
|
"ssl_ciphers": {
|
||||||
|
"auto": "SSL ciphers automatically configured",
|
||||||
|
"default": "Default SSL ciphers",
|
||||||
|
"editor_label": "SSL ciphers",
|
||||||
|
"insecure": "Insecure SSL ciphers",
|
||||||
|
"intermediate": "Intermediate SSL ciphers",
|
||||||
|
"modern": "Modern SSL ciphers"
|
||||||
|
},
|
||||||
|
"ssl_verification": {
|
||||||
|
"auto": "SSL verification automatically configured",
|
||||||
|
"editor_label": "SSL verification",
|
||||||
|
"false": "SSL verification disabled",
|
||||||
|
"true": "SSL verification enabled"
|
||||||
|
}
|
||||||
|
},
|
||||||
"timeline": {
|
"timeline": {
|
||||||
"clustering_threshold": "The count of events at which they are clustered (0=no clustering)",
|
"clustering_threshold": "The count of events at which they are clustered (0=no clustering)",
|
||||||
"events_media_type": "The events media the timeline displays",
|
"events_media_type": "The events media the timeline displays",
|
||||||
|
|||||||
@@ -127,27 +127,7 @@
|
|||||||
"url": "URL de l’interface MotionEye"
|
"url": "URL de l’interface MotionEye"
|
||||||
},
|
},
|
||||||
"proxy": {
|
"proxy": {
|
||||||
"dynamic": "Proxy dynamique",
|
"editor_label": "Proxy de la caméra"
|
||||||
"editor_label": "Proxy de la caméra",
|
|
||||||
"modes": {
|
|
||||||
"auto": "Proxy automatique",
|
|
||||||
"false": "Proxy désactivé",
|
|
||||||
"true": "Proxy activé"
|
|
||||||
},
|
|
||||||
"ssl_ciphers": {
|
|
||||||
"auto": "Chiffrement SSL automatique",
|
|
||||||
"default": "Chiffrement SSL par défaut",
|
|
||||||
"editor_label": "Chiffrement SSL",
|
|
||||||
"insecure": "Chiffrement SSL non sécurisé",
|
|
||||||
"intermediate": "Chiffrement SSL intermédiaire",
|
|
||||||
"modern": "Chiffrement SSL moderne"
|
|
||||||
},
|
|
||||||
"ssl_verification": {
|
|
||||||
"auto": "Vérification SSL automatique",
|
|
||||||
"editor_label": "Vérification SSL",
|
|
||||||
"false": "Vérification SSL désactivée",
|
|
||||||
"true": "Vérification SSL activée"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"reolink": {
|
"reolink": {
|
||||||
"editor_label": "Option Reolink",
|
"editor_label": "Option Reolink",
|
||||||
@@ -178,15 +158,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"media_types": {
|
|
||||||
"events": "Événements",
|
|
||||||
"recordings": "Enregistrements"
|
|
||||||
},
|
|
||||||
"events_media_types": {
|
|
||||||
"all": "Tous types de médias",
|
|
||||||
"clips": "Extraits",
|
|
||||||
"snapshots": "Instantanés"
|
|
||||||
},
|
|
||||||
"controls": {
|
"controls": {
|
||||||
"builtin": "Commandes vidéo intégrées",
|
"builtin": "Commandes vidéo intégrées",
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -257,6 +228,11 @@
|
|||||||
"grid_selected_width_factor": "Augmenter la largeur du média sélectionnée par ce facteur",
|
"grid_selected_width_factor": "Augmenter la largeur du média sélectionnée par ce facteur",
|
||||||
"mode": "Mode"
|
"mode": "Mode"
|
||||||
},
|
},
|
||||||
|
"events_media_types": {
|
||||||
|
"all": "Tous types de médias",
|
||||||
|
"clips": "Extraits",
|
||||||
|
"snapshots": "Instantanés"
|
||||||
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"entity": "Entité à utiliser avec le mode entité",
|
"entity": "Entité à utiliser avec le mode entité",
|
||||||
"entity_parameters": "Paramètres de requête ajoutés aux URL des images basées sur l'entité (par exemple, width=1920&height=1080)",
|
"entity_parameters": "Paramètres de requête ajoutés aux URL des images basées sur l'entité (par exemple, width=1920&height=1080)",
|
||||||
@@ -279,6 +255,32 @@
|
|||||||
"unselected": "Lors de la désélection",
|
"unselected": "Lors de la désélection",
|
||||||
"visible": "Sur la visibilité du navigateur/onglet"
|
"visible": "Sur la visibilité du navigateur/onglet"
|
||||||
},
|
},
|
||||||
|
"media_types": {
|
||||||
|
"events": "Événements",
|
||||||
|
"recordings": "Enregistrements"
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"dynamic": "Proxy dynamique",
|
||||||
|
"modes": {
|
||||||
|
"auto": "Proxy automatique",
|
||||||
|
"false": "Proxy désactivé",
|
||||||
|
"true": "Proxy activé"
|
||||||
|
},
|
||||||
|
"ssl_ciphers": {
|
||||||
|
"auto": "Chiffrement SSL automatique",
|
||||||
|
"default": "Chiffrement SSL par défaut",
|
||||||
|
"editor_label": "Chiffrement SSL",
|
||||||
|
"insecure": "Chiffrement SSL non sécurisé",
|
||||||
|
"intermediate": "Chiffrement SSL intermédiaire",
|
||||||
|
"modern": "Chiffrement SSL moderne"
|
||||||
|
},
|
||||||
|
"ssl_verification": {
|
||||||
|
"auto": "Vérification SSL automatique",
|
||||||
|
"editor_label": "Vérification SSL",
|
||||||
|
"false": "Vérification SSL désactivée",
|
||||||
|
"true": "Vérification SSL activée"
|
||||||
|
}
|
||||||
|
},
|
||||||
"timeline": {
|
"timeline": {
|
||||||
"clustering_threshold": "Nombre d'événements pour lesquels ils sont regroupés (0 = pas de clustering)",
|
"clustering_threshold": "Nombre d'événements pour lesquels ils sont regroupés (0 = pas de clustering)",
|
||||||
"events_media_type": "Médias affichés par la chronologie",
|
"events_media_type": "Médias affichés par la chronologie",
|
||||||
|
|||||||
@@ -83,9 +83,9 @@
|
|||||||
"rotation": "Obrót",
|
"rotation": "Obrót",
|
||||||
"rotations": {
|
"rotations": {
|
||||||
"0": "Brak obrotu",
|
"0": "Brak obrotu",
|
||||||
"90": "90 stopni zgodnie z zegarem",
|
|
||||||
"180": "180 stopni zgodnie z zegarem",
|
"180": "180 stopni zgodnie z zegarem",
|
||||||
"270": "270 stopni zgodnie z zegarem"
|
"270": "270 stopni zgodnie z zegarem",
|
||||||
|
"90": "90 stopni zgodnie z zegarem"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -143,29 +143,9 @@
|
|||||||
"url": "URL interfejsu MotionEye"
|
"url": "URL interfejsu MotionEye"
|
||||||
},
|
},
|
||||||
"proxy": {
|
"proxy": {
|
||||||
"dynamic": "Proxy dynamiczne",
|
|
||||||
"editor_label": "Proxy kamery",
|
"editor_label": "Proxy kamery",
|
||||||
"live": "Proxy na żywo",
|
"live": "Proxy na żywo",
|
||||||
"media": "Proxy mediów",
|
"media": "Proxy mediów"
|
||||||
"modes": {
|
|
||||||
"auto": "Proxy skonfigurowane automatycznie",
|
|
||||||
"false": "Proxy wyłączone",
|
|
||||||
"true": "Proxy włączone"
|
|
||||||
},
|
|
||||||
"ssl_ciphers": {
|
|
||||||
"auto": "Szyfry SSL skonfigurowane automatycznie",
|
|
||||||
"default": "Domyślne szyfry SSL",
|
|
||||||
"editor_label": "Szyfry SSL",
|
|
||||||
"insecure": "Niezabezpieczone szyfry SSL",
|
|
||||||
"intermediate": "Pośrednie szyfry SSL",
|
|
||||||
"modern": "Nowoczesne szyfry SSL"
|
|
||||||
},
|
|
||||||
"ssl_verification": {
|
|
||||||
"auto": "Weryfikacja SSL skonfigurowana automatycznie",
|
|
||||||
"editor_label": "Weryfikacja SSL",
|
|
||||||
"false": "Weryfikacja SSL wyłączona",
|
|
||||||
"true": "Weryfikacja SSL włączona"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"reolink": {
|
"reolink": {
|
||||||
"editor_label": "Opcje Reolink",
|
"editor_label": "Opcje Reolink",
|
||||||
@@ -302,6 +282,28 @@
|
|||||||
"unselected": "Po odznaczeniu",
|
"unselected": "Po odznaczeniu",
|
||||||
"visible": "Gdy przeglądarka/karta jest widoczna"
|
"visible": "Gdy przeglądarka/karta jest widoczna"
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"dynamic": "Proxy dynamiczne",
|
||||||
|
"modes": {
|
||||||
|
"auto": "Proxy skonfigurowane automatycznie",
|
||||||
|
"false": "Proxy wyłączone",
|
||||||
|
"true": "Proxy włączone"
|
||||||
|
},
|
||||||
|
"ssl_ciphers": {
|
||||||
|
"auto": "Szyfry SSL skonfigurowane automatycznie",
|
||||||
|
"default": "Domyślne szyfry SSL",
|
||||||
|
"editor_label": "Szyfry SSL",
|
||||||
|
"insecure": "Niezabezpieczone szyfry SSL",
|
||||||
|
"intermediate": "Pośrednie szyfry SSL",
|
||||||
|
"modern": "Nowoczesne szyfry SSL"
|
||||||
|
},
|
||||||
|
"ssl_verification": {
|
||||||
|
"auto": "Weryfikacja SSL skonfigurowana automatycznie",
|
||||||
|
"editor_label": "Weryfikacja SSL",
|
||||||
|
"false": "Weryfikacja SSL wyłączona",
|
||||||
|
"true": "Weryfikacja SSL włączona"
|
||||||
|
}
|
||||||
|
},
|
||||||
"timeline": {
|
"timeline": {
|
||||||
"clustering_threshold": "Liczba zdarzeń, przy której są grupowane (0=brak)",
|
"clustering_threshold": "Liczba zdarzeń, przy której są grupowane (0=brak)",
|
||||||
"events_media_type": "Typ mediów wyświetlany na osi czasu",
|
"events_media_type": "Typ mediów wyświetlany na osi czasu",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { CameraProxyConfig } from '../camera-manager/types';
|
|
||||||
import { supports2WayAudio as gortcSupports2WayAudio } from '../camera-manager/utils/go2rtc/audio';
|
import { supports2WayAudio as gortcSupports2WayAudio } from '../camera-manager/utils/go2rtc/audio';
|
||||||
import { CameraConfig } from '../config/schema/cameras';
|
import { CameraConfig } from '../config/schema/cameras';
|
||||||
import { LiveProvider } from '../config/schema/cameras.js';
|
import { LiveProvider } from '../config/schema/cameras.js';
|
||||||
|
import { EnabledProxyConfig } from '../config/schema/common/proxy';
|
||||||
import { HomeAssistant } from '../ha/types';
|
import { HomeAssistant } from '../ha/types';
|
||||||
import { Endpoint } from '../types';
|
import { Endpoint } from '../types';
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ export const liveProviderSupports2WayAudio = async (
|
|||||||
config: CameraConfig,
|
config: CameraConfig,
|
||||||
metadataFetchTimeoutSeconds: number,
|
metadataFetchTimeoutSeconds: number,
|
||||||
go2rtcMetadataEndpoint?: Endpoint | null,
|
go2rtcMetadataEndpoint?: Endpoint | null,
|
||||||
proxyConfig?: CameraProxyConfig,
|
proxyConfig?: EnabledProxyConfig,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
if (getResolvedLiveProvider(config) !== 'go2rtc') {
|
if (getResolvedLiveProvider(config) !== 'go2rtc') {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -133,7 +133,12 @@ describe('Camera', () => {
|
|||||||
'http://go2rtc/api/streams?src=stream&video=all&audio=allµphone',
|
'http://go2rtc/api/streams?src=stream&video=all&audio=allµphone',
|
||||||
sign: false,
|
sign: false,
|
||||||
},
|
},
|
||||||
expect.anything(),
|
expect.objectContaining({
|
||||||
|
dynamic: true,
|
||||||
|
ssl_verification: true,
|
||||||
|
ssl_ciphers: 'default',
|
||||||
|
enabled: false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(true);
|
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(true);
|
||||||
@@ -184,7 +189,99 @@ describe('Camera', () => {
|
|||||||
expect.anything(),
|
expect.anything(),
|
||||||
20,
|
20,
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
|
expect.objectContaining({ enabled: false }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass proxy config when web proxy is available', async () => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
go2rtc: {
|
||||||
|
url: 'http://go2rtc',
|
||||||
|
stream: 'stream',
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
live: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
|
||||||
|
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.config.components = ['hass_web_proxy'];
|
||||||
|
|
||||||
|
await camera.initialize({
|
||||||
|
hass,
|
||||||
|
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
2,
|
||||||
|
expect.anything(),
|
||||||
|
{
|
||||||
|
dynamic: true,
|
||||||
|
ssl_verification: true,
|
||||||
|
ssl_ciphers: 'default',
|
||||||
|
live: true,
|
||||||
|
media: false,
|
||||||
|
enabled: true,
|
||||||
|
enforce: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return live proxy config', () => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
proxy: { live: true },
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
expect(camera.getLiveProxyConfig()).toEqual(
|
||||||
|
expect.objectContaining({ enabled: true, enforce: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return media proxy config', () => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
proxy: { media: true },
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
expect(camera.getMediaProxyConfig()).toEqual(
|
||||||
|
expect.objectContaining({ enabled: true, enforce: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not enforce live proxy config when live proxying is auto', () => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
live_provider: 'go2rtc',
|
||||||
|
go2rtc: { url: 'http://go2rtc' },
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
expect(camera.getLiveProxyConfig()).toEqual(
|
||||||
|
expect.objectContaining({ enabled: true, enforce: false }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not enforce media proxy config when media proxying is auto', () => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
proxy: { media: 'auto' },
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
expect(camera.getMediaProxyConfig()).toEqual(
|
||||||
|
expect.objectContaining({ enabled: false, enforce: false }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -119,4 +119,10 @@ describe('supports2WayAudio', () => {
|
|||||||
|
|
||||||
expect(await supports2WayAudio(hass, 2, endpoint)).toBe(false);
|
expect(await supports2WayAudio(hass, 2, endpoint)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return false if proxied endpoint is null', async () => {
|
||||||
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(null);
|
||||||
|
|
||||||
|
expect(await supports2WayAudio(hass, 2, endpoint)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { createCardAPI } from '../../../test-utils';
|
|||||||
describe('ActionSet', () => {
|
describe('ActionSet', () => {
|
||||||
it('should execute single action', async () => {
|
it('should execute single action', async () => {
|
||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const set = new ActionSet({}, createLogAction('Hello, world!'));
|
const set = new ActionSet({}, createLogAction('Hello, world!'));
|
||||||
|
|
||||||
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
||||||
@@ -30,7 +30,7 @@ describe('ActionSet', () => {
|
|||||||
|
|
||||||
it('should stop execution', async () => {
|
it('should stop execution', async () => {
|
||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const set = new ActionSet({}, createLogAction('Hello, world!'));
|
const set = new ActionSet({}, createLogAction('Hello, world!'));
|
||||||
|
|
||||||
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { ViewItemManager } from '../../../src/card-controller/view/item-manager';
|
import { ViewItemManager } from '../../../src/card-controller/view/item-manager';
|
||||||
import { homeAssistantSignPath } from '../../../src/ha/sign-path.js';
|
import { homeAssistantGetSignedURLIfNecessary } from '../../../src/ha/sign-path.js';
|
||||||
import { downloadURL } from '../../../src/utils/download';
|
import { downloadURL } from '../../../src/utils/download';
|
||||||
import { ViewFolder, ViewMediaType } from '../../../src/view/item';
|
import { ViewFolder, ViewMediaType } from '../../../src/view/item';
|
||||||
import {
|
import {
|
||||||
@@ -57,6 +57,12 @@ describe('ViewItemManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('download', () => {
|
describe('download', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockImplementation(
|
||||||
|
async (_hass, endpoint) => endpoint.endpoint,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
@@ -85,7 +91,7 @@ describe('ViewItemManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const signError = new Error('sign-error');
|
const signError = new Error('sign-error');
|
||||||
vi.mocked(homeAssistantSignPath).mockRejectedValue(signError);
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockRejectedValue(signError);
|
||||||
|
|
||||||
expect(await manager.download(item)).toBe(false);
|
expect(await manager.download(item)).toBe(false);
|
||||||
expect(api.getMessageManager().setErrorIfHigherPriority).toHaveBeenCalledWith(
|
expect(api.getMessageManager().setErrorIfHigherPriority).toHaveBeenCalledWith(
|
||||||
@@ -108,7 +114,9 @@ describe('ViewItemManager', () => {
|
|||||||
endpoint: 'foo',
|
endpoint: 'foo',
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mocked(homeAssistantSignPath).mockResolvedValue('http://foo/signed-url');
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
||||||
|
'http://foo/signed-url',
|
||||||
|
);
|
||||||
|
|
||||||
expect(await manager.download(item)).toBe(true);
|
expect(await manager.download(item)).toBe(true);
|
||||||
expect(downloadURL).toBeCalledWith(
|
expect(downloadURL).toBeCalledWith(
|
||||||
@@ -129,7 +137,9 @@ describe('ViewItemManager', () => {
|
|||||||
endpoint: 'foo',
|
endpoint: 'foo',
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mocked(homeAssistantSignPath).mockResolvedValue('http://foo/signed-url');
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
||||||
|
'http://foo/signed-url',
|
||||||
|
);
|
||||||
|
|
||||||
expect(await manager.download(item)).toBe(true);
|
expect(await manager.download(item)).toBe(true);
|
||||||
expect(downloadURL).toBeCalledWith('http://foo/signed-url', 'media_id.mp4');
|
expect(downloadURL).toBeCalledWith('http://foo/signed-url', 'media_id.mp4');
|
||||||
@@ -147,8 +157,6 @@ describe('ViewItemManager', () => {
|
|||||||
endpoint: 'foo',
|
endpoint: 'foo',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(homeAssistantSignPath).not.toBeCalled();
|
|
||||||
|
|
||||||
expect(await manager.download(item)).toBe(true);
|
expect(await manager.download(item)).toBe(true);
|
||||||
expect(downloadURL).toBeCalledWith('foo', 'camera-office_id.mp4');
|
expect(downloadURL).toBeCalledWith('foo', 'camera-office_id.mp4');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,25 +9,50 @@ describe('CachedValueController', () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not restart timer on hostUpdate if not connected', () => {
|
||||||
|
const host = mock<ReactiveControllerHost & HTMLElement>();
|
||||||
|
const callback = vi.fn().mockReturnValue(42);
|
||||||
|
let refreshSeconds: number | null = 10;
|
||||||
|
|
||||||
|
const controller = new CachedValueController(host, () => refreshSeconds, callback);
|
||||||
|
|
||||||
|
Object.defineProperty(host, 'isConnected', { get: () => false });
|
||||||
|
|
||||||
|
refreshSeconds = 20;
|
||||||
|
controller.hostUpdate();
|
||||||
|
expect(controller.hasTimer()).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not restart timer if unchanged on hostUpdate', () => {
|
||||||
|
const host = mock<ReactiveControllerHost & HTMLElement>();
|
||||||
|
const callback = vi.fn().mockReturnValue(42);
|
||||||
|
const refreshSeconds: number | null = 10;
|
||||||
|
|
||||||
|
const controller = new CachedValueController(host, () => refreshSeconds, callback);
|
||||||
|
|
||||||
|
Object.defineProperty(host, 'isConnected', { get: () => true });
|
||||||
|
|
||||||
|
// This starts the timer as isConnected is true
|
||||||
|
controller.hostConnected();
|
||||||
|
|
||||||
|
const startTimerSpy = vi.spyOn(controller, 'startTimer');
|
||||||
|
|
||||||
|
controller.hostUpdate();
|
||||||
|
|
||||||
|
// Should not restart since refreshSeconds hasn't changed
|
||||||
|
expect(startTimerSpy).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should construct', () => {
|
it('should construct', () => {
|
||||||
const host = mock<ReactiveControllerHost>();
|
const host = mock<ReactiveControllerHost & HTMLElement>();
|
||||||
const callback = vi.fn();
|
const callback = vi.fn();
|
||||||
const controller = new CachedValueController(host, 10, callback);
|
const controller = new CachedValueController(host, () => 10, callback);
|
||||||
|
|
||||||
expect(controller).toBeTruthy();
|
expect(controller).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should remove host', () => {
|
|
||||||
const host = mock<ReactiveControllerHost>();
|
|
||||||
const callback = vi.fn();
|
|
||||||
const controller = new CachedValueController(host, 10, callback);
|
|
||||||
|
|
||||||
controller.removeController();
|
|
||||||
expect(host.removeController).toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have timer', () => {
|
it('should have timer', () => {
|
||||||
const host = mock<ReactiveControllerHost>();
|
const host = mock<ReactiveControllerHost & HTMLElement>();
|
||||||
const callback = vi.fn();
|
const callback = vi.fn();
|
||||||
const startCallback = vi.fn();
|
const startCallback = vi.fn();
|
||||||
const stopCallback = vi.fn();
|
const stopCallback = vi.fn();
|
||||||
@@ -36,7 +61,7 @@ describe('CachedValueController', () => {
|
|||||||
|
|
||||||
const controller = new CachedValueController(
|
const controller = new CachedValueController(
|
||||||
host,
|
host,
|
||||||
10,
|
() => 10,
|
||||||
callback,
|
callback,
|
||||||
startCallback,
|
startCallback,
|
||||||
stopCallback,
|
stopCallback,
|
||||||
@@ -49,13 +74,13 @@ describe('CachedValueController', () => {
|
|||||||
vi.runOnlyPendingTimers();
|
vi.runOnlyPendingTimers();
|
||||||
expect(callback).toBeCalled();
|
expect(callback).toBeCalled();
|
||||||
expect(host.requestUpdate).toBeCalled();
|
expect(host.requestUpdate).toBeCalled();
|
||||||
expect(controller.value).toBe(3);
|
expect(controller.getValue()).toBe(3);
|
||||||
|
|
||||||
callback.mockReturnValue(4);
|
callback.mockReturnValue(4);
|
||||||
vi.runOnlyPendingTimers();
|
vi.runOnlyPendingTimers();
|
||||||
expect(callback).toBeCalled();
|
expect(callback).toBeCalled();
|
||||||
expect(host.requestUpdate).toBeCalled();
|
expect(host.requestUpdate).toBeCalled();
|
||||||
expect(controller.value).toBe(4);
|
expect(controller.getValue()).toBe(4);
|
||||||
|
|
||||||
expect(controller.hasTimer()).toBeTruthy();
|
expect(controller.hasTimer()).toBeTruthy();
|
||||||
|
|
||||||
@@ -68,42 +93,129 @@ describe('CachedValueController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should clear value', () => {
|
it('should clear value', () => {
|
||||||
const host = mock<ReactiveControllerHost>();
|
const host = mock<ReactiveControllerHost & HTMLElement>();
|
||||||
const callback = vi.fn().mockReturnValue(42);
|
const callback = vi.fn().mockReturnValue(42);
|
||||||
|
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
const controller = new CachedValueController(host, 10, callback);
|
const controller = new CachedValueController(host, () => 10, callback);
|
||||||
controller.startTimer();
|
controller.startTimer();
|
||||||
|
|
||||||
vi.runOnlyPendingTimers();
|
vi.runOnlyPendingTimers();
|
||||||
expect(controller.value).equal(42);
|
expect(controller.getValue()).equal(42);
|
||||||
|
|
||||||
controller.clearValue();
|
controller.clearValue();
|
||||||
expect(controller.value).toBeUndefined();
|
expect(controller.getValue()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should connect and disconnect host', () => {
|
it('should connect and disconnect host', () => {
|
||||||
const host = mock<ReactiveControllerHost>();
|
const host = mock<ReactiveControllerHost & HTMLElement>();
|
||||||
const callback = vi.fn().mockReturnValue(43);
|
const callback = vi.fn().mockReturnValue(43);
|
||||||
const startCallback = vi.fn();
|
const startCallback = vi.fn();
|
||||||
const stopCallback = vi.fn();
|
const stopCallback = vi.fn();
|
||||||
|
|
||||||
const controller = new CachedValueController(
|
const controller = new CachedValueController(
|
||||||
host,
|
host,
|
||||||
10,
|
() => 10,
|
||||||
callback,
|
callback,
|
||||||
startCallback,
|
startCallback,
|
||||||
stopCallback,
|
stopCallback,
|
||||||
);
|
);
|
||||||
|
|
||||||
controller.hostConnected();
|
controller.hostConnected();
|
||||||
expect(controller.value).equal(43);
|
expect(controller.getValue()).equal(43);
|
||||||
expect(startCallback).toBeCalled();
|
expect(startCallback).toBeCalled();
|
||||||
expect(host.requestUpdate).toBeCalled();
|
expect(host.requestUpdate).toBeCalled();
|
||||||
|
|
||||||
controller.hostDisconnected();
|
controller.hostDisconnected();
|
||||||
expect(controller.value).toBeUndefined();
|
expect(controller.getValue()).toBeNull();
|
||||||
expect(stopCallback).toBeCalled();
|
expect(stopCallback).toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should call timer tick callback on each tick before updateValue', () => {
|
||||||
|
const host = mock<ReactiveControllerHost & HTMLElement>();
|
||||||
|
const callback = vi.fn().mockReturnValue('value');
|
||||||
|
const tickCallback = vi.fn();
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const controller = new CachedValueController(
|
||||||
|
host,
|
||||||
|
() => 5,
|
||||||
|
callback,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
tickCallback,
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.startTimer();
|
||||||
|
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
expect(tickCallback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
expect(tickCallback).toHaveBeenCalledTimes(2);
|
||||||
|
expect(callback).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not call timerTickCallback on manual updateValue', () => {
|
||||||
|
const host = mock<ReactiveControllerHost & HTMLElement>();
|
||||||
|
const callback = vi.fn().mockReturnValue('value');
|
||||||
|
const tickCallback = vi.fn();
|
||||||
|
|
||||||
|
const controller = new CachedValueController(
|
||||||
|
host,
|
||||||
|
() => 5,
|
||||||
|
callback,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
tickCallback,
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.updateValue();
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tickCallback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should restart timer with new interval on hostUpdate', () => {
|
||||||
|
const host = mock<ReactiveControllerHost & HTMLElement>();
|
||||||
|
const callback = vi.fn().mockReturnValue(42);
|
||||||
|
let refreshSeconds: number | null = null;
|
||||||
|
|
||||||
|
const controller = new CachedValueController(host, () => refreshSeconds, callback);
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
Object.defineProperty(host, 'isConnected', { get: () => true });
|
||||||
|
controller.hostConnected();
|
||||||
|
expect(controller.hasTimer()).toBeFalsy();
|
||||||
|
|
||||||
|
refreshSeconds = 20;
|
||||||
|
controller.hostUpdate();
|
||||||
|
|
||||||
|
// Timer should have been restarted. Fast forward 15 seconds. If it didn't
|
||||||
|
// restart, it would fire at 10 seconds. Since it restarted at 20 seconds,
|
||||||
|
// it shouldn't fire at 15 seconds.
|
||||||
|
callback.mockClear();
|
||||||
|
vi.advanceTimersByTime(15 * 1000);
|
||||||
|
expect(callback).not.toBeCalled();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5 * 1000);
|
||||||
|
expect(callback).toBeCalled();
|
||||||
|
|
||||||
|
// Now set it to null -> stops timer
|
||||||
|
refreshSeconds = null;
|
||||||
|
controller.hostUpdate();
|
||||||
|
expect(controller.hasTimer()).toBeFalsy();
|
||||||
|
|
||||||
|
// Now set it to 0 -> stops timer
|
||||||
|
refreshSeconds = 0;
|
||||||
|
controller.hostUpdate();
|
||||||
|
expect(controller.hasTimer()).toBeFalsy();
|
||||||
|
|
||||||
|
// Now set it to negative -> stops timer
|
||||||
|
refreshSeconds = -1;
|
||||||
|
controller.hostUpdate();
|
||||||
|
expect(controller.hasTimer()).toBeFalsy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ describe('UpdatingImageMediaPlayerController', () => {
|
|||||||
it('should return screenshot URL with cached value controller', async () => {
|
it('should return screenshot URL with cached value controller', async () => {
|
||||||
const url = 'data:image/png;base64,';
|
const url = 'data:image/png;base64,';
|
||||||
const cachedValueController = mock<CachedValueController<string>>();
|
const cachedValueController = mock<CachedValueController<string>>();
|
||||||
Object.defineProperty(cachedValueController, 'value', { value: url });
|
cachedValueController.getValue.mockReturnValue(url);
|
||||||
|
|
||||||
const controller = new UpdatingImageMediaPlayerController(
|
const controller = new UpdatingImageMediaPlayerController(
|
||||||
createLitElement(),
|
createLitElement(),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -77,6 +77,12 @@ describe('config defaults', () => {
|
|||||||
elements: [],
|
elements: [],
|
||||||
image: {
|
image: {
|
||||||
mode: 'auto',
|
mode: 'auto',
|
||||||
|
proxy: {
|
||||||
|
dynamic: true,
|
||||||
|
ssl_ciphers: 'auto',
|
||||||
|
ssl_verification: 'auto',
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
refresh_seconds: 1,
|
refresh_seconds: 1,
|
||||||
zoomable: true,
|
zoomable: true,
|
||||||
},
|
},
|
||||||
|
|||||||
+35
-17
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { homeAssistantSignAndFetch } from '../../src/ha/fetch';
|
import { homeAssistantSignAndFetch } from '../../src/ha/fetch';
|
||||||
import { homeAssistantSignPath } from '../../src/ha/sign-path';
|
import { homeAssistantGetSignedURLIfNecessary } from '../../src/ha/sign-path';
|
||||||
import { AdvancedCameraCardError, Endpoint } from '../../src/types';
|
import { AdvancedCameraCardError, Endpoint } from '../../src/types';
|
||||||
import { createHASS } from '../test-utils';
|
import { createHASS } from '../test-utils';
|
||||||
|
|
||||||
@@ -18,7 +18,6 @@ describe('homeAssistantSignAndFetch', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
vi.mocked(homeAssistantSignPath).mockResolvedValue('http://signed');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -27,26 +26,31 @@ describe('homeAssistantSignAndFetch', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return parsed data on successful call with endpoint', async () => {
|
it('should return parsed data on successful call with endpoint', async () => {
|
||||||
|
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
||||||
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
||||||
|
'http://example.com',
|
||||||
|
);
|
||||||
fetchMock.mockResolvedValueOnce({
|
fetchMock.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => response,
|
json: async () => response,
|
||||||
});
|
});
|
||||||
|
|
||||||
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
const hass = createHASS();
|
||||||
expect(await homeAssistantSignAndFetch(createHASS(), endpoint, schema)).toEqual(
|
expect(await homeAssistantSignAndFetch(hass, endpoint, schema)).toEqual(response);
|
||||||
response,
|
expect(homeAssistantGetSignedURLIfNecessary).toHaveBeenCalledWith(hass, endpoint);
|
||||||
);
|
|
||||||
expect(homeAssistantSignPath).not.toHaveBeenCalled();
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith('http://example.com', {});
|
expect(fetchMock).toHaveBeenCalledWith('http://example.com', {});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should pass timeout signal when timeoutSeconds is provided', async () => {
|
it('should pass timeout signal when timeoutSeconds is provided', async () => {
|
||||||
|
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
||||||
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
||||||
|
'http://example.com',
|
||||||
|
);
|
||||||
fetchMock.mockResolvedValueOnce({
|
fetchMock.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => response,
|
json: async () => response,
|
||||||
});
|
});
|
||||||
|
|
||||||
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
|
||||||
expect(
|
expect(
|
||||||
await homeAssistantSignAndFetch(createHASS(), endpoint, schema, {
|
await homeAssistantSignAndFetch(createHASS(), endpoint, schema, {
|
||||||
timeoutSeconds: 5,
|
timeoutSeconds: 5,
|
||||||
@@ -58,6 +62,7 @@ describe('homeAssistantSignAndFetch', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should sign path if requested', async () => {
|
it('should sign path if requested', async () => {
|
||||||
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue('http://signed');
|
||||||
fetchMock.mockResolvedValueOnce({
|
fetchMock.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => response,
|
json: async () => response,
|
||||||
@@ -69,12 +74,14 @@ describe('homeAssistantSignAndFetch', () => {
|
|||||||
};
|
};
|
||||||
const hass = createHASS();
|
const hass = createHASS();
|
||||||
expect(await homeAssistantSignAndFetch(hass, endpoint, schema)).toEqual(response);
|
expect(await homeAssistantSignAndFetch(hass, endpoint, schema)).toEqual(response);
|
||||||
expect(homeAssistantSignPath).toHaveBeenCalledWith(hass, 'http://example.com');
|
expect(homeAssistantGetSignedURLIfNecessary).toHaveBeenCalledWith(hass, endpoint);
|
||||||
expect(fetchMock).toHaveBeenCalledWith('http://signed', {});
|
expect(fetchMock).toHaveBeenCalledWith('http://signed', {});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw on sign failure', async () => {
|
it('should throw on sign failure', async () => {
|
||||||
vi.mocked(homeAssistantSignPath).mockRejectedValueOnce(new Error('Sign failed'));
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockRejectedValueOnce(
|
||||||
|
new Error('Sign failed'),
|
||||||
|
);
|
||||||
|
|
||||||
const endpoint: Endpoint = {
|
const endpoint: Endpoint = {
|
||||||
endpoint: 'http://example.com',
|
endpoint: 'http://example.com',
|
||||||
@@ -85,8 +92,8 @@ describe('homeAssistantSignAndFetch', () => {
|
|||||||
).rejects.toThrow(/Could not sign Home Assistant URL/);
|
).rejects.toThrow(/Could not sign Home Assistant URL/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw if sign path returns null', async () => {
|
it('should throw if sign endpoint returns null', async () => {
|
||||||
vi.mocked(homeAssistantSignPath).mockResolvedValue(null);
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(null);
|
||||||
|
|
||||||
const endpoint: Endpoint = {
|
const endpoint: Endpoint = {
|
||||||
endpoint: 'http://example.com',
|
endpoint: 'http://example.com',
|
||||||
@@ -98,9 +105,12 @@ describe('homeAssistantSignAndFetch', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw on fetch failure', async () => {
|
it('should throw on fetch failure', async () => {
|
||||||
|
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
||||||
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
||||||
|
'http://example.com',
|
||||||
|
);
|
||||||
fetchMock.mockRejectedValueOnce(new Error('Fetch failed'));
|
fetchMock.mockRejectedValueOnce(new Error('Fetch failed'));
|
||||||
|
|
||||||
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
|
||||||
try {
|
try {
|
||||||
await homeAssistantSignAndFetch(createHASS(), endpoint, schema);
|
await homeAssistantSignAndFetch(createHASS(), endpoint, schema);
|
||||||
expect.fail('Should have thrown');
|
expect.fail('Should have thrown');
|
||||||
@@ -117,6 +127,10 @@ describe('homeAssistantSignAndFetch', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw on non-ok response', async () => {
|
it('should throw on non-ok response', async () => {
|
||||||
|
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
||||||
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
||||||
|
'http://example.com',
|
||||||
|
);
|
||||||
const response = {
|
const response = {
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 404,
|
status: 404,
|
||||||
@@ -124,7 +138,6 @@ describe('homeAssistantSignAndFetch', () => {
|
|||||||
} as Response;
|
} as Response;
|
||||||
fetchMock.mockResolvedValueOnce(response);
|
fetchMock.mockResolvedValueOnce(response);
|
||||||
|
|
||||||
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
|
||||||
try {
|
try {
|
||||||
await homeAssistantSignAndFetch(createHASS(), endpoint, schema);
|
await homeAssistantSignAndFetch(createHASS(), endpoint, schema);
|
||||||
expect.fail('Should have thrown');
|
expect.fail('Should have thrown');
|
||||||
@@ -140,6 +153,10 @@ describe('homeAssistantSignAndFetch', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw on JSON parse failure', async () => {
|
it('should throw on JSON parse failure', async () => {
|
||||||
|
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
||||||
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
||||||
|
'http://example.com',
|
||||||
|
);
|
||||||
const response = {
|
const response = {
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => {
|
json: async () => {
|
||||||
@@ -148,7 +165,6 @@ describe('homeAssistantSignAndFetch', () => {
|
|||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
fetchMock.mockResolvedValueOnce(response);
|
fetchMock.mockResolvedValueOnce(response);
|
||||||
|
|
||||||
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
|
||||||
try {
|
try {
|
||||||
await homeAssistantSignAndFetch(createHASS(), endpoint, schema);
|
await homeAssistantSignAndFetch(createHASS(), endpoint, schema);
|
||||||
expect.fail('Should have thrown');
|
expect.fail('Should have thrown');
|
||||||
@@ -166,6 +182,10 @@ describe('homeAssistantSignAndFetch', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw on schema validation failure', async () => {
|
it('should throw on schema validation failure', async () => {
|
||||||
|
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
||||||
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
||||||
|
'http://example.com',
|
||||||
|
);
|
||||||
const data = { val: 'string' };
|
const data = { val: 'string' };
|
||||||
const response = {
|
const response = {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -173,8 +193,6 @@ describe('homeAssistantSignAndFetch', () => {
|
|||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
fetchMock.mockResolvedValueOnce(response);
|
fetchMock.mockResolvedValueOnce(response);
|
||||||
|
|
||||||
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await homeAssistantSignAndFetch(createHASS(), endpoint, schema);
|
await homeAssistantSignAndFetch(createHASS(), endpoint, schema);
|
||||||
expect.fail('Should have thrown');
|
expect.fail('Should have thrown');
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { homeAssistantSignPath } from '../../src/ha/sign-path';
|
import {
|
||||||
|
homeAssistantGetSignedURLIfNecessary,
|
||||||
|
homeAssistantSignPath,
|
||||||
|
} from '../../src/ha/sign-path';
|
||||||
import { homeAssistantWSRequest } from '../../src/ha/ws-request.js';
|
import { homeAssistantWSRequest } from '../../src/ha/ws-request.js';
|
||||||
import { signedPathSchema } from '../../src/types';
|
import { signedPathSchema } from '../../src/types';
|
||||||
import { createHASS } from '../test-utils';
|
import { createHASS } from '../test-utils';
|
||||||
@@ -36,3 +39,52 @@ describe('homeAssistantSignPath', () => {
|
|||||||
expect(await homeAssistantSignPath(createHASS(), 'unsigned/path', 42)).toBeNull();
|
expect(await homeAssistantSignPath(createHASS(), 'unsigned/path', 42)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('homeAssistantSignEndpoint', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return endpoint URL without signing when sign is false', async () => {
|
||||||
|
const endpoint = { endpoint: 'http://example.com', sign: false };
|
||||||
|
expect(await homeAssistantGetSignedURLIfNecessary(createHASS(), endpoint)).toBe(
|
||||||
|
'http://example.com',
|
||||||
|
);
|
||||||
|
expect(homeAssistantWSRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return endpoint URL without signing when sign is undefined', async () => {
|
||||||
|
const endpoint = { endpoint: 'http://example.com' };
|
||||||
|
expect(await homeAssistantGetSignedURLIfNecessary(createHASS(), endpoint)).toBe(
|
||||||
|
'http://example.com',
|
||||||
|
);
|
||||||
|
expect(homeAssistantWSRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sign endpoint when sign is true', async () => {
|
||||||
|
const hass = createHASS();
|
||||||
|
vi.mocked(homeAssistantWSRequest).mockResolvedValue({
|
||||||
|
path: 'signed/path',
|
||||||
|
});
|
||||||
|
vi.mocked(hass.hassUrl).mockImplementation((url) => 'hass:' + url);
|
||||||
|
|
||||||
|
const endpoint = { endpoint: 'http://example.com', sign: true };
|
||||||
|
expect(await homeAssistantGetSignedURLIfNecessary(hass, endpoint, 60)).toBe(
|
||||||
|
'hass:signed/path',
|
||||||
|
);
|
||||||
|
expect(homeAssistantWSRequest).toHaveBeenCalledWith(hass, signedPathSchema, {
|
||||||
|
type: 'auth/sign_path',
|
||||||
|
path: 'http://example.com',
|
||||||
|
expires: 60,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when signing fails', async () => {
|
||||||
|
vi.mocked(homeAssistantWSRequest).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const endpoint = { endpoint: 'http://example.com', sign: true };
|
||||||
|
expect(
|
||||||
|
await homeAssistantGetSignedURLIfNecessary(createHASS(), endpoint),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+56
-97
@@ -1,10 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { CameraProxyConfig } from '../../src/camera-manager/types.js';
|
import { EnabledProxyConfig } from '../../src/config/schema/common/proxy.js';
|
||||||
import {
|
import {
|
||||||
addDynamicProxyURL,
|
addDynamicProxyURL,
|
||||||
createProxiedEndpointIfNecessary,
|
createProxiedEndpointIfNecessary,
|
||||||
getWebProxiedURL,
|
getWebProxiedURL,
|
||||||
shouldUseWebProxy,
|
|
||||||
} from '../../src/ha/web-proxy.js';
|
} from '../../src/ha/web-proxy.js';
|
||||||
import { createHASS } from '../test-utils.js';
|
import { createHASS } from '../test-utils.js';
|
||||||
|
|
||||||
@@ -28,50 +27,19 @@ describe('getWebProxiedURL', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('shouldUseWebProxy', () => {
|
|
||||||
const createProxyConfig = (
|
|
||||||
config: Partial<CameraProxyConfig> = {},
|
|
||||||
): CameraProxyConfig => ({
|
|
||||||
media: true,
|
|
||||||
live: true,
|
|
||||||
ssl_verification: true,
|
|
||||||
ssl_ciphers: 'default',
|
|
||||||
dynamic: true,
|
|
||||||
...config,
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false without a the proxy installed', () => {
|
|
||||||
const hass = createHASS();
|
|
||||||
hass.config.components = [];
|
|
||||||
|
|
||||||
expect(shouldUseWebProxy(hass, createProxyConfig())).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return when proxy config does not want proxying', () => {
|
|
||||||
const hass = createHASS();
|
|
||||||
hass.config.components = ['hass_web_proxy'];
|
|
||||||
|
|
||||||
const proxyConfig = createProxyConfig({ media: false });
|
|
||||||
expect(shouldUseWebProxy(hass, proxyConfig, 'media')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return when proxy config does want proxying', () => {
|
|
||||||
const hass = createHASS();
|
|
||||||
hass.config.components = ['hass_web_proxy'];
|
|
||||||
|
|
||||||
const proxyConfig = createProxyConfig({ media: true });
|
|
||||||
expect(shouldUseWebProxy(hass, proxyConfig, 'media')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('addDynamicProxyURL', () => {
|
describe('addDynamicProxyURL', () => {
|
||||||
it('should add dynamic proxy URL', async () => {
|
it('should add dynamic proxy URL with proxy config', async () => {
|
||||||
const hass = createHASS();
|
const hass = createHASS();
|
||||||
|
const proxyConfig: EnabledProxyConfig = {
|
||||||
|
dynamic: true,
|
||||||
|
ssl_verification: true,
|
||||||
|
ssl_ciphers: 'modern',
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
await addDynamicProxyURL(hass, 'http://example.com', {
|
await addDynamicProxyURL(hass, 'http://example.com', {
|
||||||
|
proxyConfig,
|
||||||
urlID: 'id',
|
urlID: 'id',
|
||||||
sslVerification: true,
|
|
||||||
sslCiphers: 'modern',
|
|
||||||
openLimit: 5,
|
openLimit: 5,
|
||||||
ttl: 60,
|
ttl: 60,
|
||||||
allowUnauthenticated: false,
|
allowUnauthenticated: false,
|
||||||
@@ -92,40 +60,29 @@ describe('addDynamicProxyURL', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add dynamic proxy URL using config defaults', async () => {
|
it('should add dynamic proxy URL without options', async () => {
|
||||||
const hass = createHASS();
|
const hass = createHASS();
|
||||||
const proxyConfig: CameraProxyConfig = {
|
|
||||||
media: true,
|
|
||||||
live: true,
|
|
||||||
ssl_verification: false,
|
|
||||||
ssl_ciphers: 'insecure',
|
|
||||||
dynamic: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
await addDynamicProxyURL(hass, 'http://example.com', {
|
await addDynamicProxyURL(hass, 'http://example.com');
|
||||||
proxyConfig: proxyConfig,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(hass.callService).toHaveBeenCalledWith(
|
expect(hass.callService).toHaveBeenCalledWith(
|
||||||
'hass_web_proxy',
|
'hass_web_proxy',
|
||||||
'create_proxied_url',
|
'create_proxied_url',
|
||||||
expect.objectContaining({
|
{
|
||||||
ssl_verification: false,
|
url_pattern: 'http://example.com',
|
||||||
ssl_ciphers: 'insecure',
|
},
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createProxiedEndpointIfNecessary', () => {
|
describe('createProxiedEndpointIfNecessary', () => {
|
||||||
const createProxyConfig = (
|
const createEnabledProxyConfig = (
|
||||||
config: Partial<CameraProxyConfig> = {},
|
config: Partial<EnabledProxyConfig> = {},
|
||||||
): CameraProxyConfig => ({
|
): EnabledProxyConfig => ({
|
||||||
media: true,
|
|
||||||
live: true,
|
|
||||||
ssl_verification: true,
|
ssl_verification: true,
|
||||||
ssl_ciphers: 'default',
|
ssl_ciphers: 'default',
|
||||||
dynamic: true,
|
dynamic: true,
|
||||||
|
enabled: true,
|
||||||
...config,
|
...config,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,33 +90,41 @@ describe('createProxiedEndpointIfNecessary', () => {
|
|||||||
|
|
||||||
it('should return original endpoint when proxyConfig is undefined', async () => {
|
it('should return original endpoint when proxyConfig is undefined', async () => {
|
||||||
const hass = createHASS();
|
const hass = createHASS();
|
||||||
hass.config.components = ['hass_web_proxy'];
|
|
||||||
|
|
||||||
const result = await createProxiedEndpointIfNecessary(hass, testEndpoint);
|
const result = await createProxiedEndpointIfNecessary(hass, testEndpoint);
|
||||||
expect(result).toBe(testEndpoint);
|
expect(result).toBe(testEndpoint);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return original endpoint when proxy is not available', async () => {
|
it('should return original endpoint when enabled is false', async () => {
|
||||||
const hass = createHASS();
|
|
||||||
hass.config.components = [];
|
|
||||||
|
|
||||||
const result = await createProxiedEndpointIfNecessary(
|
|
||||||
hass,
|
|
||||||
testEndpoint,
|
|
||||||
createProxyConfig(),
|
|
||||||
);
|
|
||||||
expect(result).toBe(testEndpoint);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return original endpoint when context is not enabled', async () => {
|
|
||||||
const hass = createHASS();
|
const hass = createHASS();
|
||||||
hass.config.components = ['hass_web_proxy'];
|
hass.config.components = ['hass_web_proxy'];
|
||||||
|
|
||||||
const result = await createProxiedEndpointIfNecessary(
|
const result = await createProxiedEndpointIfNecessary(
|
||||||
hass,
|
hass,
|
||||||
testEndpoint,
|
testEndpoint,
|
||||||
createProxyConfig({ media: false }),
|
createEnabledProxyConfig({ enabled: false }),
|
||||||
{ context: 'media' },
|
);
|
||||||
|
expect(result).toBe(testEndpoint);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when web proxy is not available and enforced', async () => {
|
||||||
|
const hass = createHASS();
|
||||||
|
|
||||||
|
const result = await createProxiedEndpointIfNecessary(
|
||||||
|
hass,
|
||||||
|
testEndpoint,
|
||||||
|
createEnabledProxyConfig({ enforce: true }),
|
||||||
|
);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return original endpoint when web proxy is not available but not enforced', async () => {
|
||||||
|
const hass = createHASS();
|
||||||
|
|
||||||
|
const result = await createProxiedEndpointIfNecessary(
|
||||||
|
hass,
|
||||||
|
testEndpoint,
|
||||||
|
createEnabledProxyConfig(),
|
||||||
);
|
);
|
||||||
expect(result).toBe(testEndpoint);
|
expect(result).toBe(testEndpoint);
|
||||||
});
|
});
|
||||||
@@ -171,8 +136,8 @@ describe('createProxiedEndpointIfNecessary', () => {
|
|||||||
const result = await createProxiedEndpointIfNecessary(
|
const result = await createProxiedEndpointIfNecessary(
|
||||||
hass,
|
hass,
|
||||||
testEndpoint,
|
testEndpoint,
|
||||||
createProxyConfig(),
|
createEnabledProxyConfig(),
|
||||||
{ context: 'media', ttl: 300, openLimit: 5 },
|
{ ttl: 300, openLimit: 5 },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(hass.callService).toHaveBeenCalledWith(
|
expect(hass.callService).toHaveBeenCalledWith(
|
||||||
@@ -200,7 +165,11 @@ describe('createProxiedEndpointIfNecessary', () => {
|
|||||||
sign: false,
|
sign: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
await createProxiedEndpointIfNecessary(hass, endpointWithHash, createProxyConfig());
|
await createProxiedEndpointIfNecessary(
|
||||||
|
hass,
|
||||||
|
endpointWithHash,
|
||||||
|
createEnabledProxyConfig(),
|
||||||
|
);
|
||||||
|
|
||||||
expect(hass.callService).toHaveBeenCalledWith(
|
expect(hass.callService).toHaveBeenCalledWith(
|
||||||
'hass_web_proxy',
|
'hass_web_proxy',
|
||||||
@@ -218,7 +187,7 @@ describe('createProxiedEndpointIfNecessary', () => {
|
|||||||
const result = await createProxiedEndpointIfNecessary(
|
const result = await createProxiedEndpointIfNecessary(
|
||||||
hass,
|
hass,
|
||||||
testEndpoint,
|
testEndpoint,
|
||||||
createProxyConfig({ dynamic: false }),
|
createEnabledProxyConfig({ dynamic: false }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(hass.callService).not.toHaveBeenCalled();
|
expect(hass.callService).not.toHaveBeenCalled();
|
||||||
@@ -235,7 +204,7 @@ describe('createProxiedEndpointIfNecessary', () => {
|
|||||||
const result = await createProxiedEndpointIfNecessary(
|
const result = await createProxiedEndpointIfNecessary(
|
||||||
hass,
|
hass,
|
||||||
testEndpoint,
|
testEndpoint,
|
||||||
createProxyConfig({ dynamic: false }),
|
createEnabledProxyConfig({ dynamic: false }),
|
||||||
{ websocket: true },
|
{ websocket: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -245,25 +214,15 @@ describe('createProxiedEndpointIfNecessary', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use live context when specified', async () => {
|
|
||||||
const hass = createHASS();
|
|
||||||
hass.config.components = ['hass_web_proxy'];
|
|
||||||
|
|
||||||
const result = await createProxiedEndpointIfNecessary(
|
|
||||||
hass,
|
|
||||||
testEndpoint,
|
|
||||||
createProxyConfig({ media: false, live: true }),
|
|
||||||
{ context: 'live' },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.endpoint).toContain('/api/hass_web_proxy/');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should default openLimit to 0 when not specified', async () => {
|
it('should default openLimit to 0 when not specified', async () => {
|
||||||
const hass = createHASS();
|
const hass = createHASS();
|
||||||
hass.config.components = ['hass_web_proxy'];
|
hass.config.components = ['hass_web_proxy'];
|
||||||
|
|
||||||
await createProxiedEndpointIfNecessary(hass, testEndpoint, createProxyConfig());
|
await createProxiedEndpointIfNecessary(
|
||||||
|
hass,
|
||||||
|
testEndpoint,
|
||||||
|
createEnabledProxyConfig(),
|
||||||
|
);
|
||||||
|
|
||||||
expect(hass.callService).toHaveBeenCalledWith(
|
expect(hass.callService).toHaveBeenCalledWith(
|
||||||
'hass_web_proxy',
|
'hass_web_proxy',
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export const createHASS = (states?: HassEntities, user?: CurrentUser): HomeAssis
|
|||||||
if (user) {
|
if (user) {
|
||||||
hass.user = user;
|
hass.user = user;
|
||||||
}
|
}
|
||||||
|
hass.config.components = [];
|
||||||
hass.connection.subscribeMessage = vi.fn();
|
hass.connection.subscribeMessage = vi.fn();
|
||||||
|
|
||||||
// ha-nunjucks calls sendMessagePromise to fetch label registry; return empty array to prevent crash.
|
// ha-nunjucks calls sendMessagePromise to fetch label registry; return empty array to prevent crash.
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ describe('getParseErrorPaths', () => {
|
|||||||
const result = z
|
const result = z
|
||||||
.object({ a: z.string(), b: z.number() })
|
.object({ a: z.string(), b: z.number() })
|
||||||
.safeParse({ a: 1, b: 'a' });
|
.safeParse({ a: 1, b: 'a' });
|
||||||
if (result.success) return;
|
if (result.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
expect(getParseErrorPaths(result.error)).toEqual(new Set(['a', 'b']));
|
expect(getParseErrorPaths(result.error)).toEqual(new Set(['a', 'b']));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -15,13 +17,17 @@ describe('getParseErrorPaths', () => {
|
|||||||
const result = z
|
const result = z
|
||||||
.object({ a: z.object({ b: z.string() }) })
|
.object({ a: z.object({ b: z.string() }) })
|
||||||
.safeParse({ a: { b: 1 } });
|
.safeParse({ a: { b: 1 } });
|
||||||
if (result.success) return;
|
if (result.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
expect(getParseErrorPaths(result.error)).toEqual(new Set(['a.b']));
|
expect(getParseErrorPaths(result.error)).toEqual(new Set(['a.b']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get array error paths', () => {
|
it('should get array error paths', () => {
|
||||||
const result = z.array(z.string()).safeParse([1, 'a', 2]);
|
const result = z.array(z.string()).safeParse([1, 'a', 2]);
|
||||||
if (result.success) return;
|
if (result.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
expect(getParseErrorPaths(result.error)).toEqual(new Set(['[0]', '[2]']));
|
expect(getParseErrorPaths(result.error)).toEqual(new Set(['[0]', '[2]']));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,7 +35,9 @@ describe('getParseErrorPaths', () => {
|
|||||||
const result = z
|
const result = z
|
||||||
.object({ a: z.array(z.object({ b: z.string() })) })
|
.object({ a: z.array(z.object({ b: z.string() })) })
|
||||||
.safeParse({ a: [{ b: 1 }, { b: 'a' }, { b: 2 }] });
|
.safeParse({ a: [{ b: 1 }, { b: 'a' }, { b: 2 }] });
|
||||||
if (result.success) return;
|
if (result.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
expect(getParseErrorPaths(result.error)).toEqual(new Set(['a[0].b', 'a[2].b']));
|
expect(getParseErrorPaths(result.error)).toEqual(new Set(['a[0].b', 'a[2].b']));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user