Skip to content

Commit

Permalink
add optional port for camera and thumbnail (#387)
Browse files Browse the repository at this point in the history
* add optional port for camera and thumbnail

* tests

* update doc
  • Loading branch information
marcolivierarsenault authored Aug 14, 2024
1 parent 01bd67e commit 370d41f
Show file tree
Hide file tree
Showing 7 changed files with 139 additions and 6 deletions.
26 changes: 23 additions & 3 deletions custom_components/moonraker/camera.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Support for Moonraker camera."""

from __future__ import annotations

import logging
Expand All @@ -15,12 +16,15 @@
CONF_URL,
CONF_OPTION_CAMERA_STREAM,
CONF_OPTION_CAMERA_SNAPSHOT,
CONF_OPTION_CAMERA_PORT,
CONF_OPTION_THUMBNAIL_PORT,
DOMAIN,
METHODS,
PRINTSTATES,
)

_LOGGER = logging.getLogger(__name__)
DEFAULT_PORT = 80

hardcoded_camera = {
"name": "webcam",
Expand Down Expand Up @@ -97,10 +101,18 @@ def __init__(self, config_entry, coordinator, camera, camera_id) -> None:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, config_entry.entry_id)}
)
if (
config_entry.options.get(CONF_OPTION_CAMERA_PORT) is not None
and config_entry.options.get(CONF_OPTION_CAMERA_PORT) != ""
):
self.port = config_entry.options.get(CONF_OPTION_CAMERA_PORT)
else:
self.port = DEFAULT_PORT

if camera["stream_url"].startswith("http"):
self.url = ""
else:
self.url = f"http://{config_entry.data.get(CONF_URL)}"
self.url = f"http://{config_entry.data.get(CONF_URL)}:{self.port}"

_LOGGER.info(f"Connecting to camera: {self.url}{camera['stream_url']}")

Expand Down Expand Up @@ -133,6 +145,14 @@ def __init__(self, config_entry, coordinator, session) -> None:
self._current_pic = None
self._current_path = ""

if (
config_entry.options.get(CONF_OPTION_THUMBNAIL_PORT) is not None
and config_entry.options.get(CONF_OPTION_THUMBNAIL_PORT) != ""
):
self.port = config_entry.options.get(CONF_OPTION_THUMBNAIL_PORT)
else:
self.port = DEFAULT_PORT

async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
Expand Down Expand Up @@ -163,10 +183,10 @@ async def async_camera_image(
new_path = new_path.replace(" ", "%20")

_LOGGER.debug(
f"Fetching new thumbnail: http://{self.url}/server/files/gcodes/{new_path}"
f"Fetching new thumbnail: http://{self.url}:{self.port}/server/files/gcodes/{new_path}"
)
response = await self._session.get(
f"http://{self.url}/server/files/gcodes/{new_path}"
f"http://{self.url}:{self.port}/server/files/gcodes/{new_path}"
)

self._current_path = new_path
Expand Down
14 changes: 14 additions & 0 deletions custom_components/moonraker/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
CONF_OPTION_POLLING_RATE,
CONF_OPTION_CAMERA_STREAM,
CONF_OPTION_CAMERA_SNAPSHOT,
CONF_OPTION_CAMERA_PORT,
CONF_OPTION_THUMBNAIL_PORT,
DOMAIN,
TIMEOUT,
)
Expand Down Expand Up @@ -187,6 +189,18 @@ async def async_step_init(
CONF_OPTION_CAMERA_SNAPSHOT, ""
),
): str,
vol.Optional(
CONF_OPTION_CAMERA_PORT,
default=self.config_entry.options.get(
CONF_OPTION_CAMERA_PORT, ""
),
): str,
vol.Optional(
CONF_OPTION_THUMBNAIL_PORT,
default=self.config_entry.options.get(
CONF_OPTION_THUMBNAIL_PORT, ""
),
): str,
}
),
)
2 changes: 2 additions & 0 deletions custom_components/moonraker/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
CONF_OPTION_CAMERA_STREAM = "camera_stream_url"
CONF_OPTION_CAMERA_SNAPSHOT = "camera_snapshot_url"
CONF_OPTION_POLLING_RATE = "polling_rate"
CONF_OPTION_CAMERA_PORT = "camera_port"
CONF_OPTION_THUMBNAIL_PORT = "thumbnail_port"

# API dict keys
HOSTNAME = "hostname"
Expand Down
4 changes: 3 additions & 1 deletion custom_components/moonraker/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@
"data": {
"polling_rate": "Integration polling rate (s)",
"camera_stream_url": "Camera Stream URL",
"camera_snapshot_url": "Camera Snapshot URL"
"camera_snapshot_url": "Camera Snapshot URL",
"camera_port": "Camera Port",
"thumbnail_port": "Thumbnail Port"
},
"title": "Configuration"
}
Expand Down
6 changes: 6 additions & 0 deletions docs/entities/camera.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ Manual Configuration

It is possible to manually configure the Stream and Snapshot URL for the camera. This will bypass the automatic configuration.

Configuring your camera allows you to either configure a custom URL or use the default configuration but to enforce a different port.

If you configure a custom URL (for the Stream or the Snapshot), the integration will not attempt to connect to the Moonraker API to retrieve the camera URL, this will also ignore the custom port configuration. So use one or the other.

Similarly to the camera, the thumbnail port can be configured.

|config|

.. |cam_image| image:: https://raw.githubusercontent.com/marcolivierarsenault/moonraker-home-assistant/main/assets/camera.png
Expand Down
6 changes: 6 additions & 0 deletions tests/const.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Constants for integration_blueprint tests."""

from custom_components.moonraker.const import (
CONF_API_KEY,
CONF_PORT,
Expand All @@ -7,8 +8,11 @@
CONF_URL,
CONF_OPTION_CAMERA_STREAM,
CONF_OPTION_CAMERA_SNAPSHOT,
CONF_OPTION_CAMERA_PORT,
CONF_OPTION_THUMBNAIL_PORT,
)


# Mock config data to be used across multiple tests
MOCK_CONFIG = {
CONF_URL: "1.2.3.4",
Expand All @@ -21,6 +25,8 @@
MOCK_OPTIONS = {
CONF_OPTION_CAMERA_STREAM: "http://1.2.3.4/stream",
CONF_OPTION_CAMERA_SNAPSHOT: "http://1.2.3.4/snapshot",
CONF_OPTION_CAMERA_PORT: "1234",
CONF_OPTION_THUMBNAIL_PORT: "5678",
}

MOCK_CONFIG_WITH_NAME = {
Expand Down
87 changes: 85 additions & 2 deletions tests/test_camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
from custom_components.moonraker.const import DOMAIN, PRINTSTATES

from .const import MOCK_CONFIG, MOCK_OPTIONS
from custom_components.moonraker.const import (
CONF_OPTION_CAMERA_STREAM,
CONF_OPTION_CAMERA_SNAPSHOT,
CONF_OPTION_CAMERA_PORT,
CONF_OPTION_THUMBNAIL_PORT,
)


@pytest.fixture(name="bypass_connect_client", autouse=True)
Expand All @@ -36,7 +42,9 @@ async def test_camera_services(hass, caplog):
entry = entity_registry.async_get("camera.mainsail_webcam")

assert entry is not None
assert "Connecting to camera: http://1.2.3.4/webcam/?action=stream" in caplog.text
assert (
"Connecting to camera: http://1.2.3.4:80/webcam/?action=stream" in caplog.text
)


async def test_camera_services_full_path(hass, get_camera_info, caplog):
Expand Down Expand Up @@ -310,8 +318,60 @@ async def test_thumbnail_space_in_path(hass, get_data, aioclient_mock):
async def test_option_config_camera_services(hass, caplog):
"""Test camera services."""

custom_options = {
key: MOCK_OPTIONS[key]
for key in [CONF_OPTION_CAMERA_STREAM, CONF_OPTION_CAMERA_SNAPSHOT]
}

config_entry = MockConfigEntry(
domain=DOMAIN, data=MOCK_CONFIG, options=custom_options, entry_id="test"
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
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


async def test_option_config_camera_port(hass, caplog):
"""Test camera services."""

custom_options = {key: MOCK_OPTIONS[key] for key in [CONF_OPTION_CAMERA_PORT]}

config_entry = MockConfigEntry(
domain=DOMAIN, data=MOCK_CONFIG, options=custom_options, entry_id="test"
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
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:1234/webcam/?action=stream" in caplog.text
)


async def test_option_config_bypass_custom_port(hass, caplog):
"""Test camera services."""

custom_options = {
key: MOCK_OPTIONS[key]
for key in [
CONF_OPTION_CAMERA_PORT,
CONF_OPTION_CAMERA_STREAM,
CONF_OPTION_CAMERA_SNAPSHOT,
]
}

config_entry = MockConfigEntry(
domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_OPTIONS, entry_id="test"
domain=DOMAIN, data=MOCK_CONFIG, options=custom_options, entry_id="test"
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
Expand All @@ -322,3 +382,26 @@ async def test_option_config_camera_services(hass, caplog):

assert entry is not None
assert "Connecting to camera: http://1.2.3.4/stream" in caplog.text


async def test_option_config_thumbnail_port(hass, aioclient_mock, get_data):
"""Test camera services."""

custom_options = {key: MOCK_OPTIONS[key] for key in [CONF_OPTION_THUMBNAIL_PORT]}

config_entry = MockConfigEntry(
domain=DOMAIN, data=MOCK_CONFIG, options=custom_options, entry_id="test"
)

get_data["status"]["print_stats"]["filename"] = "CE3E3V2_picture_frame_holder.gcode"

config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

test_path = "http://1.2.3.4:5678/server/files/gcodes/.thumbs/CE3E3V2_picture_frame_holder.png"

aioclient_mock.get(test_path, content=Image.new("RGB", (30, 30)))

await camera.async_get_image(hass, "camera.mainsail_thumbnail")
await camera.async_get_image(hass, "camera.mainsail_thumbnail")

0 comments on commit 370d41f

Please sign in to comment.