mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
Make homekit camera snapshots HAP spec compliant (#35299)
This commit is contained in:
parent
87e0f04515
commit
2e018ad841
62
homeassistant/components/homekit/img_util.py
Normal file
62
homeassistant/components/homekit/img_util.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"""Image processing for HomeKit component."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from turbojpeg import TurboJPEG
|
||||||
|
|
||||||
|
SUPPORTED_SCALING_FACTORS = [(7, 8), (3, 4), (5, 8), (1, 2), (3, 8), (1, 4), (1, 8)]
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def scale_jpeg_camera_image(cam_image, width, height):
|
||||||
|
"""Scale a camera image as close as possible to one of the supported scaling factors."""
|
||||||
|
turbo_jpeg = TurboJPEGSingleton.instance()
|
||||||
|
if not turbo_jpeg:
|
||||||
|
return cam_image.content
|
||||||
|
|
||||||
|
(current_width, current_height, _, _) = turbo_jpeg.decode_header(cam_image.content)
|
||||||
|
|
||||||
|
if current_width <= width or current_height <= height:
|
||||||
|
return cam_image.content
|
||||||
|
|
||||||
|
ratio = width / current_width
|
||||||
|
|
||||||
|
scaling_factor = SUPPORTED_SCALING_FACTORS[-1]
|
||||||
|
for supported_sf in SUPPORTED_SCALING_FACTORS:
|
||||||
|
if ratio >= (supported_sf[0] / supported_sf[1]):
|
||||||
|
scaling_factor = supported_sf
|
||||||
|
break
|
||||||
|
|
||||||
|
return turbo_jpeg.scale_with_quality(
|
||||||
|
cam_image.content, scaling_factor=scaling_factor, quality=75,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TurboJPEGSingleton:
|
||||||
|
"""
|
||||||
|
Load TurboJPEG only once.
|
||||||
|
|
||||||
|
Ensures we do not log load failures each snapshot
|
||||||
|
since camera image fetches happen every few
|
||||||
|
seconds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__instance = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def instance():
|
||||||
|
"""Singleton for TurboJPEG."""
|
||||||
|
if TurboJPEGSingleton.__instance is None:
|
||||||
|
TurboJPEGSingleton()
|
||||||
|
return TurboJPEGSingleton.__instance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Try to create TurboJPEG only once."""
|
||||||
|
try:
|
||||||
|
TurboJPEGSingleton.__instance = TurboJPEG()
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
_LOGGER.exception(
|
||||||
|
"libturbojpeg is not installed, cameras may impact HomeKit performance."
|
||||||
|
)
|
||||||
|
TurboJPEGSingleton.__instance = False
|
@ -2,7 +2,7 @@
|
|||||||
"domain": "homekit",
|
"domain": "homekit",
|
||||||
"name": "HomeKit",
|
"name": "HomeKit",
|
||||||
"documentation": "https://www.home-assistant.io/integrations/homekit",
|
"documentation": "https://www.home-assistant.io/integrations/homekit",
|
||||||
"requirements": ["HAP-python==2.8.3","fnvhash==0.1.0","PyQRCode==1.2.1","base36==0.1.1"],
|
"requirements": ["HAP-python==2.8.3","fnvhash==0.1.0","PyQRCode==1.2.1","base36==0.1.1","PyTurboJPEG==1.4.0"],
|
||||||
"dependencies": ["http", "camera", "ffmpeg"],
|
"dependencies": ["http", "camera", "ffmpeg"],
|
||||||
"after_dependencies": ["logbook"],
|
"after_dependencies": ["logbook"],
|
||||||
"codeowners": ["@bdraco"],
|
"codeowners": ["@bdraco"],
|
||||||
|
@ -29,10 +29,12 @@ from .const import (
|
|||||||
CONF_VIDEO_MAP,
|
CONF_VIDEO_MAP,
|
||||||
CONF_VIDEO_PACKET_SIZE,
|
CONF_VIDEO_PACKET_SIZE,
|
||||||
)
|
)
|
||||||
|
from .img_util import scale_jpeg_camera_image
|
||||||
from .util import CAMERA_SCHEMA
|
from .util import CAMERA_SCHEMA
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
VIDEO_OUTPUT = (
|
VIDEO_OUTPUT = (
|
||||||
"-map {v_map} -an "
|
"-map {v_map} -an "
|
||||||
"-c:v {v_codec} "
|
"-c:v {v_codec} "
|
||||||
@ -246,11 +248,11 @@ class Camera(HomeAccessory, PyhapCamera):
|
|||||||
|
|
||||||
def get_snapshot(self, image_size):
|
def get_snapshot(self, image_size):
|
||||||
"""Return a jpeg of a snapshot from the camera."""
|
"""Return a jpeg of a snapshot from the camera."""
|
||||||
return (
|
return scale_jpeg_camera_image(
|
||||||
asyncio.run_coroutine_threadsafe(
|
asyncio.run_coroutine_threadsafe(
|
||||||
self.hass.components.camera.async_get_image(self.entity_id),
|
self.hass.components.camera.async_get_image(self.entity_id),
|
||||||
self.hass.loop,
|
self.hass.loop,
|
||||||
)
|
).result(),
|
||||||
.result()
|
image_size["image-width"],
|
||||||
.content
|
image_size["image-height"],
|
||||||
)
|
)
|
||||||
|
@ -78,6 +78,9 @@ PySocks==1.7.1
|
|||||||
# homeassistant.components.transport_nsw
|
# homeassistant.components.transport_nsw
|
||||||
PyTransportNSW==0.1.1
|
PyTransportNSW==0.1.1
|
||||||
|
|
||||||
|
# homeassistant.components.homekit
|
||||||
|
PyTurboJPEG==1.4.0
|
||||||
|
|
||||||
# homeassistant.components.vicare
|
# homeassistant.components.vicare
|
||||||
PyViCare==0.1.10
|
PyViCare==0.1.10
|
||||||
|
|
||||||
|
@ -23,6 +23,9 @@ PyRMVtransport==0.2.9
|
|||||||
# homeassistant.components.transport_nsw
|
# homeassistant.components.transport_nsw
|
||||||
PyTransportNSW==0.1.1
|
PyTransportNSW==0.1.1
|
||||||
|
|
||||||
|
# homeassistant.components.homekit
|
||||||
|
PyTurboJPEG==1.4.0
|
||||||
|
|
||||||
# homeassistant.components.remember_the_milk
|
# homeassistant.components.remember_the_milk
|
||||||
RtmAPI==0.7.2
|
RtmAPI==0.7.2
|
||||||
|
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
"""Collection of fixtures and functions for the HomeKit tests."""
|
"""Collection of fixtures and functions for the HomeKit tests."""
|
||||||
from tests.async_mock import patch
|
from tests.async_mock import Mock, patch
|
||||||
|
|
||||||
|
EMPTY_8_6_JPEG = b"empty_8_6"
|
||||||
|
|
||||||
|
|
||||||
def patch_debounce():
|
def patch_debounce():
|
||||||
@ -8,3 +10,16 @@ def patch_debounce():
|
|||||||
"homeassistant.components.homekit.accessories.debounce",
|
"homeassistant.components.homekit.accessories.debounce",
|
||||||
lambda f: lambda *args, **kwargs: f(*args, **kwargs),
|
lambda f: lambda *args, **kwargs: f(*args, **kwargs),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mock_turbo_jpeg(
|
||||||
|
first_width=None, second_width=None, first_height=None, second_height=None
|
||||||
|
):
|
||||||
|
"""Mock a TurboJPEG instance."""
|
||||||
|
mocked_turbo_jpeg = Mock()
|
||||||
|
mocked_turbo_jpeg.decode_header.side_effect = [
|
||||||
|
(first_width, first_height, 0, 0),
|
||||||
|
(second_width, second_height, 0, 0),
|
||||||
|
]
|
||||||
|
mocked_turbo_jpeg.scale_with_quality.return_value = EMPTY_8_6_JPEG
|
||||||
|
return mocked_turbo_jpeg
|
||||||
|
62
tests/components/homekit/test_img_util.py
Normal file
62
tests/components/homekit/test_img_util.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"""Test HomeKit img_util module."""
|
||||||
|
from homeassistant.components.camera import Image
|
||||||
|
from homeassistant.components.homekit.img_util import (
|
||||||
|
TurboJPEGSingleton,
|
||||||
|
scale_jpeg_camera_image,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .common import EMPTY_8_6_JPEG, mock_turbo_jpeg
|
||||||
|
|
||||||
|
from tests.async_mock import patch
|
||||||
|
|
||||||
|
EMPTY_16_12_JPEG = b"empty_16_12"
|
||||||
|
|
||||||
|
|
||||||
|
def test_turbojpeg_singleton():
|
||||||
|
"""Verify the instance always gives back the same."""
|
||||||
|
assert TurboJPEGSingleton.instance() == TurboJPEGSingleton.instance()
|
||||||
|
|
||||||
|
|
||||||
|
def test_scale_jpeg_camera_image():
|
||||||
|
"""Test we can scale a jpeg image."""
|
||||||
|
|
||||||
|
camera_image = Image("image/jpeg", EMPTY_16_12_JPEG)
|
||||||
|
|
||||||
|
turbo_jpeg = mock_turbo_jpeg(first_width=16, first_height=12)
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.homekit.img_util.TurboJPEG", return_value=False
|
||||||
|
):
|
||||||
|
TurboJPEGSingleton()
|
||||||
|
assert scale_jpeg_camera_image(camera_image, 16, 12) == camera_image.content
|
||||||
|
|
||||||
|
turbo_jpeg = mock_turbo_jpeg(first_width=16, first_height=12)
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.homekit.img_util.TurboJPEG", return_value=turbo_jpeg
|
||||||
|
):
|
||||||
|
TurboJPEGSingleton()
|
||||||
|
assert scale_jpeg_camera_image(camera_image, 16, 12) == EMPTY_16_12_JPEG
|
||||||
|
|
||||||
|
turbo_jpeg = mock_turbo_jpeg(
|
||||||
|
first_width=16, first_height=12, second_width=8, second_height=6
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.homekit.img_util.TurboJPEG", return_value=turbo_jpeg
|
||||||
|
):
|
||||||
|
TurboJPEGSingleton()
|
||||||
|
jpeg_bytes = scale_jpeg_camera_image(camera_image, 8, 6)
|
||||||
|
|
||||||
|
assert jpeg_bytes == EMPTY_8_6_JPEG
|
||||||
|
|
||||||
|
|
||||||
|
def test_turbojpeg_load_failure():
|
||||||
|
"""Handle libjpegturbo not being installed."""
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.homekit.img_util.TurboJPEG", side_effect=Exception
|
||||||
|
):
|
||||||
|
TurboJPEGSingleton()
|
||||||
|
assert TurboJPEGSingleton.instance() is False
|
||||||
|
|
||||||
|
with patch("homeassistant.components.homekit.img_util.TurboJPEG"):
|
||||||
|
TurboJPEGSingleton()
|
||||||
|
assert TurboJPEGSingleton.instance()
|
@ -15,11 +15,14 @@ from homeassistant.components.homekit.const import (
|
|||||||
CONF_VIDEO_CODEC,
|
CONF_VIDEO_CODEC,
|
||||||
VIDEO_CODEC_COPY,
|
VIDEO_CODEC_COPY,
|
||||||
)
|
)
|
||||||
|
from homeassistant.components.homekit.img_util import TurboJPEGSingleton
|
||||||
from homeassistant.components.homekit.type_cameras import Camera
|
from homeassistant.components.homekit.type_cameras import Camera
|
||||||
from homeassistant.components.homekit.type_switches import Switch
|
from homeassistant.components.homekit.type_switches import Switch
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
|
from .common import mock_turbo_jpeg
|
||||||
|
|
||||||
from tests.async_mock import AsyncMock, MagicMock, patch
|
from tests.async_mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
MOCK_START_STREAM_TLV = "ARUCAQEBEDMD1QMXzEaatnKSQ2pxovYCNAEBAAIJAQECAgECAwEAAwsBAgAFAgLQAgMBHgQXAQFjAgQ768/RAwIrAQQEAAAAPwUCYgUDLAEBAwIMAQEBAgEAAwECBAEUAxYBAW4CBCzq28sDAhgABAQAAKBABgENBAEA"
|
MOCK_START_STREAM_TLV = "ARUCAQEBEDMD1QMXzEaatnKSQ2pxovYCNAEBAAIJAQECAgECAwEAAwsBAgAFAgLQAgMBHgQXAQFjAgQ768/RAwIrAQQEAAAAPwUCYgUDLAEBAwIMAQEBAgEAAwECBAEUAxYBAW4CBCzq28sDAhgABAQAAKBABgENBAEA"
|
||||||
@ -135,15 +138,30 @@ async def test_camera_stream_source_configured(hass, run_driver, events):
|
|||||||
await acc.stop_stream(session_info)
|
await acc.stop_stream(session_info)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
assert await hass.async_add_executor_job(acc.get_snapshot, 1024)
|
turbo_jpeg = mock_turbo_jpeg(
|
||||||
|
first_width=16, first_height=12, second_width=300, second_height=200
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.homekit.img_util.TurboJPEG", return_value=turbo_jpeg
|
||||||
|
):
|
||||||
|
TurboJPEGSingleton()
|
||||||
|
assert await hass.async_add_executor_job(
|
||||||
|
acc.get_snapshot, {"aid": 2, "image-width": 300, "image-height": 200}
|
||||||
|
)
|
||||||
|
# Verify the bridge only forwards get_snapshot for
|
||||||
|
# cameras and valid accessory ids
|
||||||
|
assert await hass.async_add_executor_job(
|
||||||
|
bridge.get_snapshot, {"aid": 2, "image-width": 300, "image-height": 200}
|
||||||
|
)
|
||||||
|
|
||||||
# Verify the bridge only forwards get_snapshot for
|
|
||||||
# cameras and valid accessory ids
|
|
||||||
assert await hass.async_add_executor_job(bridge.get_snapshot, {"aid": 2})
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
assert await hass.async_add_executor_job(bridge.get_snapshot, {"aid": 3})
|
assert await hass.async_add_executor_job(
|
||||||
|
bridge.get_snapshot, {"aid": 3, "image-width": 300, "image-height": 200}
|
||||||
|
)
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
assert await hass.async_add_executor_job(bridge.get_snapshot, {"aid": 4})
|
assert await hass.async_add_executor_job(
|
||||||
|
bridge.get_snapshot, {"aid": 4, "image-width": 300, "image-height": 200}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_camera_stream_source_configured_with_failing_ffmpeg(
|
async def test_camera_stream_source_configured_with_failing_ffmpeg(
|
||||||
@ -289,7 +307,9 @@ async def test_camera_with_no_stream(hass, run_driver, events):
|
|||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
with pytest.raises(HomeAssistantError):
|
with pytest.raises(HomeAssistantError):
|
||||||
await hass.async_add_executor_job(acc.get_snapshot, 1024)
|
await hass.async_add_executor_job(
|
||||||
|
acc.get_snapshot, {"aid": 2, "image-width": 300, "image-height": 200}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_camera_stream_source_configured_and_copy_codec(hass, run_driver, events):
|
async def test_camera_stream_source_configured_and_copy_codec(hass, run_driver, events):
|
||||||
|
Loading…
x
Reference in New Issue
Block a user