Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
18d03fb
Add homekit camera support
xdissent Mar 6, 2020
b2dbc57
Cleanup pyhapcamera inheritance
Jc2k Mar 9, 2020
a6ac5b5
Add camera to homekit manifest
xdissent Mar 6, 2020
6f0c250
Use upstream pyhap server handler in homekit
xdissent Mar 6, 2020
bfd19bb
Remove unused homekit constants
xdissent Apr 9, 2020
ba77b87
Fix lint errors in homekit camera
xdissent Apr 11, 2020
752acd8
Update homekit camera log messages
xdissent Apr 16, 2020
5015d7c
Black after conflict fixes
bdraco May 1, 2020
62bcb4d
More conflict fixes
bdraco May 1, 2020
20e6e31
missing srtp
bdraco May 1, 2020
960b218
Allow streaming retry when ffmpeg fails to connect
bdraco May 1, 2020
1eafe27
Fix inherit of camera config, force kill ffmpeg on failure
bdraco May 1, 2020
54310a4
Fix audio (Home Assistant only comes with OPUS)
bdraco May 1, 2020
020ce36
Fix audio (Home Assistant only comes with OPUS)
bdraco May 1, 2020
933fed8
Add camera to the list of supported domains.
bdraco May 1, 2020
815e5d7
add a test for camera creation
bdraco May 3, 2020
3e13e22
Add a basic test (still needs more as its only at 44% cover)
bdraco May 3, 2020
58fb34c
let super handle reconfigure_stream
bdraco May 3, 2020
d2cb48e
Remove scaling as it does not appear to be needed and causes artifacts
bdraco May 4, 2020
13ebe92
Some more basic tests
bdraco May 4, 2020
7d96b62
make sure no exceptions when finding the source from the entity
bdraco May 4, 2020
1c7fc17
make sure the bridge forwards get_snapshot
bdraco May 4, 2020
a52b784
restore full coverage to accessories.py
bdraco May 4, 2020
a8d052b
revert usage of super for start/stop stream
bdraco May 4, 2020
a84259d
one more test
bdraco May 4, 2020
854f26e
more mocking
bdraco May 4, 2020
2820afc
video codec support added
stickpin May 4, 2020
090648d
revert back to -c:v
stickpin May 4, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions homeassistant/components/homekit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@ async def _async_register_bridge(self):

def _start(self, bridged_states):
from . import ( # noqa: F401 pylint: disable=unused-import, import-outside-toplevel
type_cameras,
type_covers,
type_fans,
type_lights,
Expand Down
28 changes: 26 additions & 2 deletions homeassistant/components/homekit/accessories.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,9 @@ def get_accessory(hass, driver, state, aid, config):
elif state.domain == "water_heater":
a_type = "WaterHeater"

elif state.domain == "camera":
a_type = "Camera"

if a_type is None:
return None

Expand All @@ -220,10 +223,19 @@ class HomeAccessory(Accessory):
"""Adapter class for Accessory."""

def __init__(
self, hass, driver, name, entity_id, aid, config, category=CATEGORY_OTHER
self,
hass,
driver,
name,
entity_id,
aid,
config,
*args,
category=CATEGORY_OTHER,
**kwargs,
):
"""Initialize a Accessory object."""
super().__init__(driver, name, aid=aid)
super().__init__(driver=driver, display_name=name, aid=aid, *args, **kwargs)
model = split_entity_id(entity_id)[0].replace("_", " ").title()
self.set_info_service(
firmware_revision=__version__,
Expand Down Expand Up @@ -460,6 +472,18 @@ def __init__(self, hass, driver, name):
def setup_message(self):
"""Prevent print of pyhap setup message to terminal."""

def get_snapshot(self, info):
"""Get snapshot from accessory if supported."""
acc = self.accessories.get(info["aid"])
if acc is None:
raise ValueError("Requested snapshot for missing accessory")
if not hasattr(acc, "get_snapshot"):
raise ValueError(
"Got a request for snapshot, but the Accessory "
'does not define a "get_snapshot" method'
)
return acc.get_snapshot(info)


class HomeDriver(AccessoryDriver):
"""Adapter class for AccessoryDriver."""
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/homekit/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"alarm_control_panel",
"automation",
"binary_sensor",
"camera",
"climate",
"cover",
"demo",
Expand Down
19 changes: 19 additions & 0 deletions homeassistant/components/homekit/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

# #### Config ####
CONF_ADVERTISE_IP = "advertise_ip"
CONF_AUDIO_MAP = "audio_map"
CONF_AUDIO_PACKET_SIZE = "audio_packet_size"
CONF_AUTO_START = "auto_start"
CONF_ENTITY_CONFIG = "entity_config"
CONF_FEATURE = "feature"
Expand All @@ -27,16 +29,33 @@
CONF_LINKED_BATTERY_SENSOR = "linked_battery_sensor"
CONF_LINKED_BATTERY_CHARGING_SENSOR = "linked_battery_charging_sensor"
CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold"
CONF_MAX_FPS = "max_fps"
CONF_MAX_HEIGHT = "max_height"
CONF_MAX_WIDTH = "max_width"
CONF_SAFE_MODE = "safe_mode"
CONF_ZEROCONF_DEFAULT_INTERFACE = "zeroconf_default_interface"
CONF_STREAM_ADDRESS = "stream_address"
CONF_STREAM_SOURCE = "stream_source"
CONF_SUPPORT_AUDIO = "support_audio"
CONF_VIDEO_MAP = "video_map"
CONF_VIDEO_PACKET_SIZE = "video_packet_size"
CONF_VIDEO_CODEC = "video_codec"

# #### Config Defaults ####
DEFAULT_AUDIO_MAP = "0:a:0"
DEFAULT_AUDIO_PACKET_SIZE = 188
DEFAULT_AUTO_START = True
DEFAULT_LOW_BATTERY_THRESHOLD = 20
DEFAULT_MAX_FPS = 30
DEFAULT_MAX_HEIGHT = 1080
DEFAULT_MAX_WIDTH = 1920
DEFAULT_PORT = 51827
DEFAULT_CONFIG_FLOW_PORT = 51828
DEFAULT_SAFE_MODE = False
DEFAULT_ZEROCONF_DEFAULT_INTERFACE = False
DEFAULT_VIDEO_MAP = "0:v:0"
DEFAULT_VIDEO_PACKET_SIZE = 1316
DEFAULT_VIDEO_CODEC = "libx264"

# #### Features ####
FEATURE_ON_OFF = "on_off"
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/homekit/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "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"],
"dependencies": ["http"],
"dependencies": ["http", "camera", "ffmpeg"],
"after_dependencies": ["logbook"],
"codeowners": ["@bdraco"],
"config_flow": true
Expand Down
248 changes: 248 additions & 0 deletions homeassistant/components/homekit/type_cameras.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
"""Class to hold all camera accessories."""
import asyncio
import logging

from haffmpeg.core import HAFFmpeg
from pyhap.camera import (
VIDEO_CODEC_PARAM_LEVEL_TYPES,
VIDEO_CODEC_PARAM_PROFILE_ID_TYPES,
Camera as PyhapCamera,
)
from pyhap.const import CATEGORY_CAMERA

from homeassistant.components.camera.const import DOMAIN as DOMAIN_CAMERA
from homeassistant.components.ffmpeg import DATA_FFMPEG
from homeassistant.util import get_local_ip

from .accessories import TYPES, HomeAccessory
from .const import (
CONF_AUDIO_MAP,
CONF_AUDIO_PACKET_SIZE,
CONF_MAX_FPS,
CONF_MAX_HEIGHT,
CONF_MAX_WIDTH,
CONF_STREAM_ADDRESS,
CONF_STREAM_SOURCE,
CONF_SUPPORT_AUDIO,
CONF_VIDEO_CODEC,
CONF_VIDEO_MAP,
CONF_VIDEO_PACKET_SIZE,
)
from .util import CAMERA_SCHEMA

_LOGGER = logging.getLogger(__name__)

VIDEO_OUTPUT = (
"-map {v_map} -an "
"-c:v {v_codec} "
"{v_profile}"
"-tune zerolatency -pix_fmt yuv420p "
"-r {fps} "
"-b:v {v_max_bitrate}k -bufsize {v_bufsize}k -maxrate {v_max_bitrate}k "
"-payload_type 99 "
"-ssrc {v_ssrc} -f rtp "
"-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {v_srtp_key} "
"srtp://{address}:{v_port}?rtcpport={v_port}&"
"localrtcpport={v_port}&pkt_size={v_pkt_size}"
)

AUDIO_ENCODER_OPUS = "libopus -application lowdelay"

AUDIO_OUTPUT = (
"-map {a_map} -vn "
"-c:a {a_encoder} "
"-ac 1 -ar {a_sample_rate}k "
"-b:a {a_max_bitrate}k -bufsize {a_bufsize}k "
"-payload_type 110 "
"-ssrc {a_ssrc} -f rtp "
"-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {a_srtp_key} "
"srtp://{address}:{a_port}?rtcpport={a_port}&"
"localrtcpport={a_port}&pkt_size={a_pkt_size}"
)

SLOW_RESOLUTIONS = [
(320, 180, 15),
(320, 240, 15),
]

RESOLUTIONS = [
(320, 180),
(320, 240),
(480, 270),
(480, 360),
(640, 360),
(640, 480),
(1024, 576),
(1024, 768),
(1280, 720),
(1280, 960),
(1920, 1080),
]

VIDEO_PROFILE_NAMES = ["baseline", "main", "high"]


@TYPES.register("Camera")
class Camera(HomeAccessory, PyhapCamera):
"""Generate a Camera accessory."""

def __init__(self, hass, driver, name, entity_id, aid, config):
"""Initialize a Camera accessory object."""
self._ffmpeg = hass.data[DATA_FFMPEG]
self._camera = hass.data[DOMAIN_CAMERA]
config_w_defaults = CAMERA_SCHEMA(config)

max_fps = config_w_defaults[CONF_MAX_FPS]
max_width = config_w_defaults[CONF_MAX_WIDTH]
max_height = config_w_defaults[CONF_MAX_HEIGHT]
resolutions = [
(w, h, fps)
for w, h, fps in SLOW_RESOLUTIONS
if w <= max_width and h <= max_height and fps < max_fps
] + [
(w, h, max_fps)
for w, h in RESOLUTIONS
if w <= max_width and h <= max_height
]

video_options = {
"codec": {
"profiles": [
VIDEO_CODEC_PARAM_PROFILE_ID_TYPES["BASELINE"],
VIDEO_CODEC_PARAM_PROFILE_ID_TYPES["MAIN"],
VIDEO_CODEC_PARAM_PROFILE_ID_TYPES["HIGH"],
],
"levels": [
VIDEO_CODEC_PARAM_LEVEL_TYPES["TYPE3_1"],
VIDEO_CODEC_PARAM_LEVEL_TYPES["TYPE3_2"],
VIDEO_CODEC_PARAM_LEVEL_TYPES["TYPE4_0"],
],
},
"resolutions": resolutions,
}
audio_options = {"codecs": [{"type": "OPUS", "samplerate": 24}]}

stream_address = config_w_defaults.get(CONF_STREAM_ADDRESS, get_local_ip())

options = {
"video": video_options,
"audio": audio_options,
"address": stream_address,
"srtp": True,
}

super().__init__(
hass,
driver,
name,
entity_id,
aid,
config_w_defaults,
category=CATEGORY_CAMERA,
options=options,
)

def update_state(self, new_state):
"""Handle state change to update HomeKit value."""
pass # pylint: disable=unnecessary-pass

async def _async_get_stream_source(self):
"""Find the camera stream source url."""
camera = self._camera.get_entity(self.entity_id)
if not camera or not camera.is_on:
return None
stream_source = self.config.get(CONF_STREAM_SOURCE)
if stream_source:
return stream_source
try:
return await camera.stream_source()
except Exception: # pylint: disable=broad-except
_LOGGER.exception(
"Failed to get stream source - this could be a transient error or your camera might not be compatible with HomeKit yet"
)

async def start_stream(self, session_info, stream_config):
"""Start a new stream with the given configuration."""
_LOGGER.debug(
"[%s] Starting stream with the following parameters: %s",
session_info["id"],
stream_config,
)
input_source = await self._async_get_stream_source()
if not input_source:
_LOGGER.error("Camera has no stream source")
return False
if "-i " not in input_source:
input_source = "-i " + input_source
video_profile = ""
if self.config[CONF_VIDEO_CODEC] != "copy":
video_profile = (
"-profile:v "
+ VIDEO_PROFILE_NAMES[
int.from_bytes(stream_config["v_profile_id"], byteorder="big")
]
+ " "
)
output_vars = stream_config.copy()
output_vars.update(
{
"v_profile": video_profile,
"v_bufsize": stream_config["v_max_bitrate"] * 2,
"v_map": self.config[CONF_VIDEO_MAP],
"v_pkt_size": self.config[CONF_VIDEO_PACKET_SIZE],
"v_codec": self.config[CONF_VIDEO_CODEC],
"a_bufsize": stream_config["a_max_bitrate"] * 2,
"a_map": self.config[CONF_AUDIO_MAP],
"a_pkt_size": self.config[CONF_AUDIO_PACKET_SIZE],
"a_encoder": AUDIO_ENCODER_OPUS,
}
)
output = VIDEO_OUTPUT.format(**output_vars)
if self.config[CONF_SUPPORT_AUDIO]:
output = output + " " + AUDIO_OUTPUT.format(**output_vars)
_LOGGER.debug("FFmpeg output settings: %s", output)
stream = HAFFmpeg(self._ffmpeg.binary, loop=self.driver.loop)
opened = await stream.open(
cmd=[], input_source=input_source, output=output, stdout_pipe=False
)
if not opened:
_LOGGER.error("Failed to open ffmpeg stream")
return False
session_info["stream"] = stream
_LOGGER.info(
"[%s] Started stream process - PID %d",
session_info["id"],
stream.process.pid,
)
return True

async def stop_stream(self, session_info):
"""Stop the stream for the given ``session_id``."""
session_id = session_info["id"]
stream = session_info.get("stream")
if not stream:
_LOGGER.debug("No stream for session ID %s", session_id)
_LOGGER.info("[%s] Stopping stream.", session_id)

try:
await stream.close()
return
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Failed to gracefully close stream.")

try:
await stream.kill()
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Failed to forcefully close stream.")
_LOGGER.debug("Stream process stopped forcefully.")

def get_snapshot(self, image_size):
"""Return a jpeg of a snapshot from the camera."""
return (
asyncio.run_coroutine_threadsafe(
self.hass.components.camera.async_get_image(self.entity_id),
self.hass.loop,
)
.result()
.content
)
Loading