diff --git a/.vscode/settings.json b/.vscode/settings.json index e0792a3..8f15493 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,8 @@ // Please keep this file in sync with settings in home-assistant/.devcontainer/devcontainer.json // Added --no-cov to work around TypeError: message must be set // https://github.com/microsoft/vscode-python/issues/14067 - "python.testing.pytestArgs": ["--no-cov"], + "python.testing.pytestArgs": ["."], // https://code.visualstudio.com/docs/python/testing#_pytest-configuration-settings - "python.testing.pytestEnabled": false + "python.testing.pytestEnabled": true, + "python.testing.unittestEnabled": false } diff --git a/configuration.yaml b/configuration.yaml index 991f73e..3225cce 100644 --- a/configuration.yaml +++ b/configuration.yaml @@ -16,4 +16,4 @@ tts: logger: default: warning logs: - custom_components.moonraker: debug + custom_components.moonraker: warning diff --git a/custom_components/moonraker/camera.py b/custom_components/moonraker/camera.py index e2a7b26..55990a7 100755 --- a/custom_components/moonraker/camera.py +++ b/custom_components/moonraker/camera.py @@ -11,7 +11,14 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_URL, DOMAIN, METHODS, PRINTSTATES +from .const import ( + CONF_URL, + CONF_OPTION_CAMERA_STREAM, + CONF_OPTION_CAMERA_SNAPSHOT, + DOMAIN, + METHODS, + PRINTSTATES, +) _LOGGER = logging.getLogger(__name__) @@ -40,12 +47,27 @@ async def async_setup_entry( camera_cnt = 0 try: - cameras = await coordinator.async_fetch_data(METHODS.SERVER_WEBCAMS_LIST) - for camera_id, camera in enumerate(cameras["webcams"]): + if ( + config_entry.options.get(CONF_OPTION_CAMERA_STREAM) is not None + and config_entry.options.get(CONF_OPTION_CAMERA_STREAM) != "" + ): + hardcoded_camera["stream_url"] = config_entry.options.get( + CONF_OPTION_CAMERA_STREAM + ) + hardcoded_camera["snapshot_url"] = config_entry.options.get( + CONF_OPTION_CAMERA_SNAPSHOT + ) async_add_entities( - [MoonrakerCamera(config_entry, coordinator, camera, camera_id)] + [MoonrakerCamera(config_entry, coordinator, hardcoded_camera, 100)] ) camera_cnt += 1 + else: + cameras = await coordinator.async_fetch_data(METHODS.SERVER_WEBCAMS_LIST) + for camera_id, camera in enumerate(cameras["webcams"]): + async_add_entities( + [MoonrakerCamera(config_entry, coordinator, camera, camera_id)] + ) + camera_cnt += 1 except Exception: _LOGGER.info("Could not add any cameras from the API list") diff --git a/custom_components/moonraker/config_flow.py b/custom_components/moonraker/config_flow.py index 18471e1..8c713b6 100755 --- a/custom_components/moonraker/config_flow.py +++ b/custom_components/moonraker/config_flow.py @@ -1,15 +1,28 @@ """Adds config flow for Moonraker.""" import logging +from typing import Any + import async_timeout import voluptuous as vol from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import network, slugify from .api import MoonrakerApiClient -from .const import (CONF_API_KEY, CONF_PORT, CONF_PRINTER_NAME, CONF_TLS, - CONF_URL, DOMAIN, TIMEOUT) +from .const import ( + CONF_API_KEY, + CONF_PORT, + CONF_PRINTER_NAME, + CONF_TLS, + CONF_URL, + CONF_OPTION_CAMERA_STREAM, + CONF_OPTION_CAMERA_SNAPSHOT, + DOMAIN, + TIMEOUT, +) _LOGGER = logging.getLogger(__name__) @@ -126,3 +139,46 @@ async def _test_connection(self, host, port, api_key, tls): return True except Exception: return False + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Create the options flow.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle options.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_OPTION_CAMERA_STREAM, + default=self.config_entry.options.get( + CONF_OPTION_CAMERA_STREAM, "" + ), + ): str, + vol.Optional( + CONF_OPTION_CAMERA_SNAPSHOT, + default=self.config_entry.options.get( + CONF_OPTION_CAMERA_SNAPSHOT, "" + ), + ): str, + } + ), + ) diff --git a/custom_components/moonraker/const.py b/custom_components/moonraker/const.py index 94e50c8..288f99e 100644 --- a/custom_components/moonraker/const.py +++ b/custom_components/moonraker/const.py @@ -24,6 +24,8 @@ CONF_PORT = "port" CONF_TLS = "tls" CONF_PRINTER_NAME = "printer_name" +CONF_OPTION_CAMERA_STREAM = "camera_stream_url" +CONF_OPTION_CAMERA_SNAPSHOT = "camera_snapshot_url" # API dict keys HOSTNAME = "hostname" diff --git a/custom_components/moonraker/translations/en.json b/custom_components/moonraker/translations/en.json index b6f31a5..15639f0 100644 --- a/custom_components/moonraker/translations/en.json +++ b/custom_components/moonraker/translations/en.json @@ -19,5 +19,16 @@ "printer_name_error": "Invalid printer name.", "printer_connection_error": "Failed to connect." } + }, + "options": { + "step": { + "init": { + "data": { + "camera_stream_url": "Camera Stream URL", + "camera_snapshot_url": "Camera Snapshot URL" + }, + "title": "Configuration Camera" + } + } } } diff --git a/docs/_static/config.png b/docs/_static/config.png new file mode 100644 index 0000000..a6859bb Binary files /dev/null and b/docs/_static/config.png differ diff --git a/docs/connection.rst b/docs/connection.rst index cc0fbfc..b41dcdc 100644 --- a/docs/connection.rst +++ b/docs/connection.rst @@ -32,4 +32,10 @@ Connection properties can be defined as follows: - Device name in Home Assistant Note: Encrypted connections must be configured in Moonraker API or by using a -reverse proxy to connect to Moonraker API. \ No newline at end of file +reverse proxy to connect to Moonraker API. + + +Camera Manual Configuration +------------------------------------- + +Camera URL can be manually defined more details in the :ref:`camera_config` diff --git a/docs/entities/camera.rst b/docs/entities/camera.rst index 52d5ae5..1cb523f 100644 --- a/docs/entities/camera.rst +++ b/docs/entities/camera.rst @@ -7,6 +7,15 @@ Camera * Thumbnail of the current print. (Your slicer needs to generate the thumbnail image) |thum_image| +.. _camera_config: + +Manual Configuration +------------------------------------- + +It is possible to manually configure the Stream and Snapshot URL for the camera. This will bypass the automatic configuration. + +|config| .. |cam_image| image:: https://raw.githubusercontent.com/marcolivierarsenault/moonraker-home-assistant/main/assets/camera.png -.. |thum_image| image:: https://raw.githubusercontent.com/marcolivierarsenault/moonraker-home-assistant/main/assets/thumbnail.png \ No newline at end of file +.. |thum_image| image:: https://raw.githubusercontent.com/marcolivierarsenault/moonraker-home-assistant/main/assets/thumbnail.png +.. |config| image:: /_static/config.png diff --git a/tests/const.py b/tests/const.py index 2280fc4..3599870 100644 --- a/tests/const.py +++ b/tests/const.py @@ -5,6 +5,8 @@ CONF_PRINTER_NAME, CONF_TLS, CONF_URL, + CONF_OPTION_CAMERA_STREAM, + CONF_OPTION_CAMERA_SNAPSHOT, ) # Mock config data to be used across multiple tests @@ -16,6 +18,11 @@ CONF_PRINTER_NAME: "", } +MOCK_OPTIONS = { + CONF_OPTION_CAMERA_STREAM: "http://1.2.3.4/stream", + CONF_OPTION_CAMERA_SNAPSHOT: "http://1.2.3.4/snapshot", +} + MOCK_CONFIG_WITH_NAME = { CONF_URL: "1.2.3.4", CONF_PORT: "1234", diff --git a/tests/test_camera.py b/tests/test_camera.py index 22cd44f..71f963a 100755 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -14,7 +14,7 @@ from custom_components.moonraker import async_setup_entry from custom_components.moonraker.const import DOMAIN, PRINTSTATES -from .const import MOCK_CONFIG +from .const import MOCK_CONFIG, MOCK_OPTIONS @pytest.fixture(name="bypass_connect_client", autouse=True) @@ -286,3 +286,20 @@ async def test_thumbnail_on_subfolder(hass, get_data, aioclient_mock): await camera.async_get_image(hass, "camera.mainsail_thumbnail") await camera.async_get_image(hass, "camera.mainsail_thumbnail") + + +async def test_option_config_camera_services(hass, caplog): + """Test camera services.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_OPTIONS, entry_id="test" + ) + config_entry.add_to_hass(hass) + assert await async_setup_entry(hass, config_entry) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("camera.mainsail_webcam") + + assert entry is not None + assert "Connecting to camera: http://1.2.3.4/stream" in caplog.text diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 69089cd..b8399d5 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -3,17 +3,21 @@ import pytest from homeassistant import config_entries, data_entry_flow +from pytest_homeassistant_custom_component.common import MockConfigEntry +from custom_components.moonraker import async_setup_entry from custom_components.moonraker.const import ( CONF_API_KEY, CONF_PORT, CONF_PRINTER_NAME, CONF_TLS, CONF_URL, + CONF_OPTION_CAMERA_SNAPSHOT, + CONF_OPTION_CAMERA_STREAM, DOMAIN, ) -from .const import MOCK_CONFIG +from .const import MOCK_CONFIG, MOCK_OPTIONS @pytest.fixture(name="bypass_connect_client") @@ -395,3 +399,28 @@ async def test_bad_connection_config_flow(hass): ) assert result["errors"] == {CONF_URL: "printer_connection_error"} + + +@pytest.mark.usefixtures("bypass_connect_client") +async def test_option_config_camera_services(hass): + """Test a config flow with camera services.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + config_entry.add_to_hass(hass) + assert await async_setup_entry(hass, config_entry) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init("test") + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_OPTION_CAMERA_STREAM: MOCK_OPTIONS[CONF_OPTION_CAMERA_STREAM], + CONF_OPTION_CAMERA_SNAPSHOT: MOCK_OPTIONS[CONF_OPTION_CAMERA_SNAPSHOT], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY