mirror of
https://github.com/home-assistant/core.git
synced 2025-07-18 18:57:06 +00:00
Merge pull request #40706 from home-assistant/rc
This commit is contained in:
commit
b4237be609
@ -3,7 +3,7 @@
|
|||||||
"name": "Airly",
|
"name": "Airly",
|
||||||
"documentation": "https://www.home-assistant.io/integrations/airly",
|
"documentation": "https://www.home-assistant.io/integrations/airly",
|
||||||
"codeowners": ["@bieniu"],
|
"codeowners": ["@bieniu"],
|
||||||
"requirements": ["airly==0.0.2"],
|
"requirements": ["airly==1.0.0"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"quality_scale": "platinum"
|
"quality_scale": "platinum"
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,7 @@ from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
|||||||
from homeassistant.components.media_player.const import (
|
from homeassistant.components.media_player.const import (
|
||||||
ATTR_MEDIA_CONTENT_ID,
|
ATTR_MEDIA_CONTENT_ID,
|
||||||
ATTR_MEDIA_CONTENT_TYPE,
|
ATTR_MEDIA_CONTENT_TYPE,
|
||||||
|
ATTR_MEDIA_EXTRA,
|
||||||
DOMAIN as DOMAIN_MP,
|
DOMAIN as DOMAIN_MP,
|
||||||
SERVICE_PLAY_MEDIA,
|
SERVICE_PLAY_MEDIA,
|
||||||
)
|
)
|
||||||
@ -46,7 +47,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401
|
|||||||
PLATFORM_SCHEMA,
|
PLATFORM_SCHEMA,
|
||||||
PLATFORM_SCHEMA_BASE,
|
PLATFORM_SCHEMA_BASE,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.entity import Entity
|
from homeassistant.helpers.entity import Entity, entity_sources
|
||||||
from homeassistant.helpers.entity_component import EntityComponent
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
from homeassistant.helpers.network import get_url
|
from homeassistant.helpers.network import get_url
|
||||||
from homeassistant.loader import bind_hass
|
from homeassistant.loader import bind_hass
|
||||||
@ -695,13 +696,46 @@ async def async_handle_play_stream_service(camera, service_call):
|
|||||||
options=camera.stream_options,
|
options=camera.stream_options,
|
||||||
)
|
)
|
||||||
data = {
|
data = {
|
||||||
ATTR_ENTITY_ID: entity_ids,
|
|
||||||
ATTR_MEDIA_CONTENT_ID: f"{get_url(hass)}{url}",
|
ATTR_MEDIA_CONTENT_ID: f"{get_url(hass)}{url}",
|
||||||
ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[fmt],
|
ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[fmt],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# It is required to send a different payload for cast media players
|
||||||
|
cast_entity_ids = [
|
||||||
|
entity
|
||||||
|
for entity, source in entity_sources(hass).items()
|
||||||
|
if entity in entity_ids and source["domain"] == "cast"
|
||||||
|
]
|
||||||
|
other_entity_ids = list(set(entity_ids) - set(cast_entity_ids))
|
||||||
|
|
||||||
|
if cast_entity_ids:
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
DOMAIN_MP, SERVICE_PLAY_MEDIA, data, blocking=True, context=service_call.context
|
DOMAIN_MP,
|
||||||
|
SERVICE_PLAY_MEDIA,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: cast_entity_ids,
|
||||||
|
**data,
|
||||||
|
ATTR_MEDIA_EXTRA: {
|
||||||
|
"stream_type": "LIVE",
|
||||||
|
"media_info": {
|
||||||
|
"hlsVideoSegmentFormat": "fmp4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
context=service_call.context,
|
||||||
|
)
|
||||||
|
|
||||||
|
if other_entity_ids:
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN_MP,
|
||||||
|
SERVICE_PLAY_MEDIA,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: other_entity_ids,
|
||||||
|
**data,
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
context=service_call.context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
"name": "Google Cast",
|
"name": "Google Cast",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/cast",
|
"documentation": "https://www.home-assistant.io/integrations/cast",
|
||||||
"requirements": ["pychromecast==7.2.1"],
|
"requirements": ["pychromecast==7.5.0"],
|
||||||
"after_dependencies": ["cloud", "http", "media_source", "tts", "zeroconf"],
|
"after_dependencies": ["cloud", "http", "media_source", "tts", "zeroconf"],
|
||||||
"zeroconf": ["_googlecast._tcp.local."],
|
"zeroconf": ["_googlecast._tcp.local."],
|
||||||
"codeowners": ["@emontnemery"]
|
"codeowners": ["@emontnemery"]
|
||||||
|
@ -21,6 +21,7 @@ from homeassistant.components import media_source, zeroconf
|
|||||||
from homeassistant.components.http.auth import async_sign_path
|
from homeassistant.components.http.auth import async_sign_path
|
||||||
from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity
|
from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity
|
||||||
from homeassistant.components.media_player.const import (
|
from homeassistant.components.media_player.const import (
|
||||||
|
ATTR_MEDIA_EXTRA,
|
||||||
MEDIA_TYPE_MOVIE,
|
MEDIA_TYPE_MOVIE,
|
||||||
MEDIA_TYPE_MUSIC,
|
MEDIA_TYPE_MUSIC,
|
||||||
MEDIA_TYPE_TVSHOW,
|
MEDIA_TYPE_TVSHOW,
|
||||||
@ -574,7 +575,9 @@ class CastDevice(MediaPlayerEntity):
|
|||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
_LOGGER.error("App %s not supported", app_name)
|
_LOGGER.error("App %s not supported", app_name)
|
||||||
else:
|
else:
|
||||||
self._chromecast.media_controller.play_media(media_id, media_type)
|
self._chromecast.media_controller.play_media(
|
||||||
|
media_id, media_type, **kwargs.get(ATTR_MEDIA_EXTRA, {})
|
||||||
|
)
|
||||||
|
|
||||||
# ========== Properties ==========
|
# ========== Properties ==========
|
||||||
@property
|
@property
|
||||||
|
@ -185,9 +185,7 @@ def request_app_setup(hass, config, add_entities, config_path, discovery_info=No
|
|||||||
else:
|
else:
|
||||||
setup_platform(hass, config, add_entities, discovery_info)
|
setup_platform(hass, config, add_entities, discovery_info)
|
||||||
|
|
||||||
start_url = (
|
start_url = f"{get_url(hass)}{FITBIT_AUTH_CALLBACK_PATH}"
|
||||||
f"{get_url(hass, require_current_request=True)}{FITBIT_AUTH_CALLBACK_PATH}"
|
|
||||||
)
|
|
||||||
|
|
||||||
description = f"""Please create a Fitbit developer app at
|
description = f"""Please create a Fitbit developer app at
|
||||||
https://dev.fitbit.com/apps/new.
|
https://dev.fitbit.com/apps/new.
|
||||||
@ -222,7 +220,7 @@ def request_oauth_completion(hass):
|
|||||||
def fitbit_configuration_callback(callback_data):
|
def fitbit_configuration_callback(callback_data):
|
||||||
"""Handle configuration updates."""
|
"""Handle configuration updates."""
|
||||||
|
|
||||||
start_url = f"{get_url(hass, require_current_request=True)}{FITBIT_AUTH_START}"
|
start_url = f"{get_url(hass)}{FITBIT_AUTH_START}"
|
||||||
|
|
||||||
description = f"Please authorize Fitbit by visiting {start_url}"
|
description = f"Please authorize Fitbit by visiting {start_url}"
|
||||||
|
|
||||||
@ -314,9 +312,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
|
|||||||
config_file.get(CONF_CLIENT_ID), config_file.get(CONF_CLIENT_SECRET)
|
config_file.get(CONF_CLIENT_ID), config_file.get(CONF_CLIENT_SECRET)
|
||||||
)
|
)
|
||||||
|
|
||||||
redirect_uri = (
|
redirect_uri = f"{get_url(hass)}{FITBIT_AUTH_CALLBACK_PATH}"
|
||||||
f"{get_url(hass, require_current_request=True)}{FITBIT_AUTH_CALLBACK_PATH}"
|
|
||||||
)
|
|
||||||
|
|
||||||
fitbit_auth_start_url, _ = oauth.authorize_token_url(
|
fitbit_auth_start_url, _ = oauth.authorize_token_url(
|
||||||
redirect_uri=redirect_uri,
|
redirect_uri=redirect_uri,
|
||||||
|
@ -71,6 +71,7 @@ from .const import (
|
|||||||
ATTR_MEDIA_DURATION,
|
ATTR_MEDIA_DURATION,
|
||||||
ATTR_MEDIA_ENQUEUE,
|
ATTR_MEDIA_ENQUEUE,
|
||||||
ATTR_MEDIA_EPISODE,
|
ATTR_MEDIA_EPISODE,
|
||||||
|
ATTR_MEDIA_EXTRA,
|
||||||
ATTR_MEDIA_PLAYLIST,
|
ATTR_MEDIA_PLAYLIST,
|
||||||
ATTR_MEDIA_POSITION,
|
ATTR_MEDIA_POSITION,
|
||||||
ATTR_MEDIA_POSITION_UPDATED_AT,
|
ATTR_MEDIA_POSITION_UPDATED_AT,
|
||||||
@ -139,6 +140,7 @@ MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = {
|
|||||||
vol.Required(ATTR_MEDIA_CONTENT_TYPE): cv.string,
|
vol.Required(ATTR_MEDIA_CONTENT_TYPE): cv.string,
|
||||||
vol.Required(ATTR_MEDIA_CONTENT_ID): cv.string,
|
vol.Required(ATTR_MEDIA_CONTENT_ID): cv.string,
|
||||||
vol.Optional(ATTR_MEDIA_ENQUEUE): cv.boolean,
|
vol.Optional(ATTR_MEDIA_ENQUEUE): cv.boolean,
|
||||||
|
vol.Optional(ATTR_MEDIA_EXTRA, default={}): dict,
|
||||||
}
|
}
|
||||||
|
|
||||||
ATTR_TO_PROPERTY = [
|
ATTR_TO_PROPERTY = [
|
||||||
|
@ -12,6 +12,7 @@ ATTR_MEDIA_CONTENT_ID = "media_content_id"
|
|||||||
ATTR_MEDIA_CONTENT_TYPE = "media_content_type"
|
ATTR_MEDIA_CONTENT_TYPE = "media_content_type"
|
||||||
ATTR_MEDIA_DURATION = "media_duration"
|
ATTR_MEDIA_DURATION = "media_duration"
|
||||||
ATTR_MEDIA_ENQUEUE = "enqueue"
|
ATTR_MEDIA_ENQUEUE = "enqueue"
|
||||||
|
ATTR_MEDIA_EXTRA = "extra"
|
||||||
ATTR_MEDIA_EPISODE = "media_episode"
|
ATTR_MEDIA_EPISODE = "media_episode"
|
||||||
ATTR_MEDIA_PLAYLIST = "media_playlist"
|
ATTR_MEDIA_PLAYLIST = "media_playlist"
|
||||||
ATTR_MEDIA_POSITION = "media_position"
|
ATTR_MEDIA_POSITION = "media_position"
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"domain": "plugwise",
|
"domain": "plugwise",
|
||||||
"name": "Plugwise",
|
"name": "Plugwise",
|
||||||
"documentation": "https://www.home-assistant.io/integrations/plugwise",
|
"documentation": "https://www.home-assistant.io/integrations/plugwise",
|
||||||
"requirements": ["Plugwise_Smile==1.4.0"],
|
"requirements": ["Plugwise_Smile==1.5.1"],
|
||||||
"codeowners": ["@CoMPaTech", "@bouwew"],
|
"codeowners": ["@CoMPaTech", "@bouwew"],
|
||||||
"zeroconf": ["_plugwise._tcp.local."],
|
"zeroconf": ["_plugwise._tcp.local."],
|
||||||
"config_flow": true
|
"config_flow": true
|
||||||
|
@ -57,6 +57,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
info = await self._async_get_info(host)
|
info = await self._async_get_info(host)
|
||||||
except HTTP_CONNECT_ERRORS:
|
except HTTP_CONNECT_ERRORS:
|
||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
|
except aioshelly.FirmwareUnsupported:
|
||||||
|
return self.async_abort(reason="unsupported_firmware")
|
||||||
except Exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
_LOGGER.exception("Unexpected exception")
|
_LOGGER.exception("Unexpected exception")
|
||||||
errors["base"] = "unknown"
|
errors["base"] = "unknown"
|
||||||
@ -128,6 +130,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
self.info = info = await self._async_get_info(zeroconf_info["host"])
|
self.info = info = await self._async_get_info(zeroconf_info["host"])
|
||||||
except HTTP_CONNECT_ERRORS:
|
except HTTP_CONNECT_ERRORS:
|
||||||
return self.async_abort(reason="cannot_connect")
|
return self.async_abort(reason="cannot_connect")
|
||||||
|
except aioshelly.FirmwareUnsupported:
|
||||||
|
return self.async_abort(reason="unsupported_firmware")
|
||||||
|
|
||||||
await self.async_set_unique_id(info["mac"])
|
await self.async_set_unique_id(info["mac"])
|
||||||
self._abort_if_unique_id_configured({CONF_HOST: zeroconf_info["host"]})
|
self._abort_if_unique_id_configured({CONF_HOST: zeroconf_info["host"]})
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
"name": "Shelly",
|
"name": "Shelly",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/shelly",
|
"documentation": "https://www.home-assistant.io/integrations/shelly",
|
||||||
"requirements": ["aioshelly==0.3.2"],
|
"requirements": ["aioshelly==0.3.3"],
|
||||||
"zeroconf": [{ "type": "_http._tcp.local.", "name": "shelly*" }],
|
"zeroconf": [{ "type": "_http._tcp.local.", "name": "shelly*" }],
|
||||||
"codeowners": ["@balloob", "@bieniu"]
|
"codeowners": ["@balloob", "@bieniu"]
|
||||||
}
|
}
|
||||||
|
@ -24,7 +24,8 @@
|
|||||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||||
|
"unsupported_firmware": "The device is using an unsupported firmware version."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -36,3 +36,114 @@ def get_m4s(segment: io.BytesIO, sequence: int) -> bytes:
|
|||||||
mfra_location = next(find_box(segment, b"mfra"))
|
mfra_location = next(find_box(segment, b"mfra"))
|
||||||
segment.seek(moof_location)
|
segment.seek(moof_location)
|
||||||
return segment.read(mfra_location - moof_location)
|
return segment.read(mfra_location - moof_location)
|
||||||
|
|
||||||
|
|
||||||
|
def get_codec_string(segment: io.BytesIO) -> str:
|
||||||
|
"""Get RFC 6381 codec string."""
|
||||||
|
codecs = []
|
||||||
|
|
||||||
|
# Find moov
|
||||||
|
moov_location = next(find_box(segment, b"moov"))
|
||||||
|
|
||||||
|
# Find tracks
|
||||||
|
for trak_location in find_box(segment, b"trak", moov_location):
|
||||||
|
# Drill down to media info
|
||||||
|
mdia_location = next(find_box(segment, b"mdia", trak_location))
|
||||||
|
minf_location = next(find_box(segment, b"minf", mdia_location))
|
||||||
|
stbl_location = next(find_box(segment, b"stbl", minf_location))
|
||||||
|
stsd_location = next(find_box(segment, b"stsd", stbl_location))
|
||||||
|
|
||||||
|
# Get stsd box
|
||||||
|
segment.seek(stsd_location)
|
||||||
|
stsd_length = int.from_bytes(segment.read(4), byteorder="big")
|
||||||
|
segment.seek(stsd_location)
|
||||||
|
stsd_box = segment.read(stsd_length)
|
||||||
|
|
||||||
|
# Base Codec
|
||||||
|
codec = stsd_box[20:24].decode("utf-8")
|
||||||
|
|
||||||
|
# Handle H264
|
||||||
|
if (
|
||||||
|
codec in ("avc1", "avc2", "avc3", "avc4")
|
||||||
|
and stsd_length > 110
|
||||||
|
and stsd_box[106:110] == b"avcC"
|
||||||
|
):
|
||||||
|
profile = stsd_box[111:112].hex()
|
||||||
|
compatibility = stsd_box[112:113].hex()
|
||||||
|
level = stsd_box[113:114].hex()
|
||||||
|
codec += "." + profile + compatibility + level
|
||||||
|
|
||||||
|
# Handle H265
|
||||||
|
elif (
|
||||||
|
codec in ("hev1", "hvc1")
|
||||||
|
and stsd_length > 110
|
||||||
|
and stsd_box[106:110] == b"hvcC"
|
||||||
|
):
|
||||||
|
tmp_byte = int.from_bytes(stsd_box[111:112], byteorder="big")
|
||||||
|
|
||||||
|
# Profile Space
|
||||||
|
codec += "."
|
||||||
|
profile_space_map = {0: "", 1: "A", 2: "B", 3: "C"}
|
||||||
|
profile_space = tmp_byte >> 6
|
||||||
|
codec += profile_space_map[profile_space]
|
||||||
|
general_profile_idc = tmp_byte & 31
|
||||||
|
codec += str(general_profile_idc)
|
||||||
|
|
||||||
|
# Compatibility
|
||||||
|
codec += "."
|
||||||
|
general_profile_compatibility = int.from_bytes(
|
||||||
|
stsd_box[112:116], byteorder="big"
|
||||||
|
)
|
||||||
|
reverse = 0
|
||||||
|
for i in range(0, 32):
|
||||||
|
reverse |= general_profile_compatibility & 1
|
||||||
|
if i == 31:
|
||||||
|
break
|
||||||
|
reverse <<= 1
|
||||||
|
general_profile_compatibility >>= 1
|
||||||
|
codec += hex(reverse)[2:]
|
||||||
|
|
||||||
|
# Tier Flag
|
||||||
|
if (tmp_byte & 32) >> 5 == 0:
|
||||||
|
codec += ".L"
|
||||||
|
else:
|
||||||
|
codec += ".H"
|
||||||
|
codec += str(int.from_bytes(stsd_box[122:123], byteorder="big"))
|
||||||
|
|
||||||
|
# Constraint String
|
||||||
|
has_byte = False
|
||||||
|
constraint_string = ""
|
||||||
|
for i in range(121, 115, -1):
|
||||||
|
gci = int.from_bytes(stsd_box[i : i + 1], byteorder="big")
|
||||||
|
if gci or has_byte:
|
||||||
|
constraint_string = "." + hex(gci)[2:] + constraint_string
|
||||||
|
has_byte = True
|
||||||
|
codec += constraint_string
|
||||||
|
|
||||||
|
# Handle Audio
|
||||||
|
elif codec == "mp4a":
|
||||||
|
oti = None
|
||||||
|
dsi = None
|
||||||
|
|
||||||
|
# Parse ES Descriptors
|
||||||
|
oti_loc = stsd_box.find(b"\x04\x80\x80\x80")
|
||||||
|
if oti_loc > 0:
|
||||||
|
oti = stsd_box[oti_loc + 5 : oti_loc + 6].hex()
|
||||||
|
codec += f".{oti}"
|
||||||
|
|
||||||
|
dsi_loc = stsd_box.find(b"\x05\x80\x80\x80")
|
||||||
|
if dsi_loc > 0:
|
||||||
|
dsi_length = int.from_bytes(
|
||||||
|
stsd_box[dsi_loc + 4 : dsi_loc + 5], byteorder="big"
|
||||||
|
)
|
||||||
|
dsi_data = stsd_box[dsi_loc + 5 : dsi_loc + 5 + dsi_length]
|
||||||
|
dsi0 = int.from_bytes(dsi_data[0:1], byteorder="big")
|
||||||
|
dsi = (dsi0 & 248) >> 3
|
||||||
|
if dsi == 31 and len(dsi_data) >= 2:
|
||||||
|
dsi1 = int.from_bytes(dsi_data[1:2], byteorder="big")
|
||||||
|
dsi = 32 + ((dsi0 & 7) << 3) + ((dsi1 & 224) >> 5)
|
||||||
|
codec += f".{dsi}"
|
||||||
|
|
||||||
|
codecs.append(codec)
|
||||||
|
|
||||||
|
return ",".join(codecs)
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
"""Provide functionality to stream HLS."""
|
"""Provide functionality to stream HLS."""
|
||||||
|
import io
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
@ -7,7 +8,7 @@ from homeassistant.core import callback
|
|||||||
|
|
||||||
from .const import FORMAT_CONTENT_TYPE
|
from .const import FORMAT_CONTENT_TYPE
|
||||||
from .core import PROVIDERS, StreamOutput, StreamView
|
from .core import PROVIDERS, StreamOutput, StreamView
|
||||||
from .fmp4utils import get_init, get_m4s
|
from .fmp4utils import get_codec_string, get_init, get_m4s
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
@ -16,7 +17,43 @@ def async_setup_hls(hass):
|
|||||||
hass.http.register_view(HlsPlaylistView())
|
hass.http.register_view(HlsPlaylistView())
|
||||||
hass.http.register_view(HlsSegmentView())
|
hass.http.register_view(HlsSegmentView())
|
||||||
hass.http.register_view(HlsInitView())
|
hass.http.register_view(HlsInitView())
|
||||||
return "/api/hls/{}/playlist.m3u8"
|
hass.http.register_view(HlsMasterPlaylistView())
|
||||||
|
return "/api/hls/{}/master_playlist.m3u8"
|
||||||
|
|
||||||
|
|
||||||
|
class HlsMasterPlaylistView(StreamView):
|
||||||
|
"""Stream view used only for Chromecast compatibility."""
|
||||||
|
|
||||||
|
url = r"/api/hls/{token:[a-f0-9]+}/master_playlist.m3u8"
|
||||||
|
name = "api:stream:hls:master_playlist"
|
||||||
|
cors_allowed = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def render(track):
|
||||||
|
"""Render M3U8 file."""
|
||||||
|
# Need to calculate max bandwidth as input_container.bit_rate doesn't seem to work
|
||||||
|
# Calculate file size / duration and use a multiplier to account for variation
|
||||||
|
segment = track.get_segment(track.segments[-1])
|
||||||
|
bandwidth = round(
|
||||||
|
segment.segment.seek(0, io.SEEK_END) * 8 / segment.duration * 3
|
||||||
|
)
|
||||||
|
codecs = get_codec_string(segment.segment)
|
||||||
|
lines = [
|
||||||
|
"#EXTM3U",
|
||||||
|
f'#EXT-X-STREAM-INF:BANDWIDTH={bandwidth},CODECS="{codecs}"',
|
||||||
|
"playlist.m3u8",
|
||||||
|
]
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
async def handle(self, request, stream, sequence):
|
||||||
|
"""Return m3u8 playlist."""
|
||||||
|
track = stream.add_provider("hls")
|
||||||
|
stream.start()
|
||||||
|
# Wait for a segment to be ready
|
||||||
|
if not track.segments:
|
||||||
|
await track.recv()
|
||||||
|
headers = {"Content-Type": FORMAT_CONTENT_TYPE["hls"]}
|
||||||
|
return web.Response(body=self.render(track).encode("utf-8"), headers=headers)
|
||||||
|
|
||||||
|
|
||||||
class HlsPlaylistView(StreamView):
|
class HlsPlaylistView(StreamView):
|
||||||
@ -26,18 +63,50 @@ class HlsPlaylistView(StreamView):
|
|||||||
name = "api:stream:hls:playlist"
|
name = "api:stream:hls:playlist"
|
||||||
cors_allowed = True
|
cors_allowed = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def render_preamble(track):
|
||||||
|
"""Render preamble."""
|
||||||
|
return [
|
||||||
|
"#EXT-X-VERSION:7",
|
||||||
|
f"#EXT-X-TARGETDURATION:{track.target_duration}",
|
||||||
|
'#EXT-X-MAP:URI="init.mp4"',
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def render_playlist(track):
|
||||||
|
"""Render playlist."""
|
||||||
|
segments = track.segments
|
||||||
|
|
||||||
|
if not segments:
|
||||||
|
return []
|
||||||
|
|
||||||
|
playlist = ["#EXT-X-MEDIA-SEQUENCE:{}".format(segments[0])]
|
||||||
|
|
||||||
|
for sequence in segments:
|
||||||
|
segment = track.get_segment(sequence)
|
||||||
|
playlist.extend(
|
||||||
|
[
|
||||||
|
"#EXTINF:{:.04f},".format(float(segment.duration)),
|
||||||
|
f"./segment/{segment.sequence}.m4s",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return playlist
|
||||||
|
|
||||||
|
def render(self, track):
|
||||||
|
"""Render M3U8 file."""
|
||||||
|
lines = ["#EXTM3U"] + self.render_preamble(track) + self.render_playlist(track)
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
async def handle(self, request, stream, sequence):
|
async def handle(self, request, stream, sequence):
|
||||||
"""Return m3u8 playlist."""
|
"""Return m3u8 playlist."""
|
||||||
renderer = M3U8Renderer(stream)
|
|
||||||
track = stream.add_provider("hls")
|
track = stream.add_provider("hls")
|
||||||
stream.start()
|
stream.start()
|
||||||
# Wait for a segment to be ready
|
# Wait for a segment to be ready
|
||||||
if not track.segments:
|
if not track.segments:
|
||||||
await track.recv()
|
await track.recv()
|
||||||
headers = {"Content-Type": FORMAT_CONTENT_TYPE["hls"]}
|
headers = {"Content-Type": FORMAT_CONTENT_TYPE["hls"]}
|
||||||
return web.Response(
|
return web.Response(body=self.render(track).encode("utf-8"), headers=headers)
|
||||||
body=renderer.render(track).encode("utf-8"), headers=headers
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class HlsInitView(StreamView):
|
class HlsInitView(StreamView):
|
||||||
@ -77,49 +146,6 @@ class HlsSegmentView(StreamView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class M3U8Renderer:
|
|
||||||
"""M3U8 Render Helper."""
|
|
||||||
|
|
||||||
def __init__(self, stream):
|
|
||||||
"""Initialize renderer."""
|
|
||||||
self.stream = stream
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def render_preamble(track):
|
|
||||||
"""Render preamble."""
|
|
||||||
return [
|
|
||||||
"#EXT-X-VERSION:7",
|
|
||||||
f"#EXT-X-TARGETDURATION:{track.target_duration}",
|
|
||||||
'#EXT-X-MAP:URI="init.mp4"',
|
|
||||||
]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def render_playlist(track):
|
|
||||||
"""Render playlist."""
|
|
||||||
segments = track.segments
|
|
||||||
|
|
||||||
if not segments:
|
|
||||||
return []
|
|
||||||
|
|
||||||
playlist = ["#EXT-X-MEDIA-SEQUENCE:{}".format(segments[0])]
|
|
||||||
|
|
||||||
for sequence in segments:
|
|
||||||
segment = track.get_segment(sequence)
|
|
||||||
playlist.extend(
|
|
||||||
[
|
|
||||||
"#EXTINF:{:.04f},".format(float(segment.duration)),
|
|
||||||
f"./segment/{segment.sequence}.m4s",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
return playlist
|
|
||||||
|
|
||||||
def render(self, track):
|
|
||||||
"""Render M3U8 file."""
|
|
||||||
lines = ["#EXTM3U"] + self.render_preamble(track) + self.render_playlist(track)
|
|
||||||
return "\n".join(lines) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
@PROVIDERS.register("hls")
|
@PROVIDERS.register("hls")
|
||||||
class HlsStreamOutput(StreamOutput):
|
class HlsStreamOutput(StreamOutput):
|
||||||
"""Represents HLS Output formats."""
|
"""Represents HLS Output formats."""
|
||||||
@ -137,7 +163,7 @@ class HlsStreamOutput(StreamOutput):
|
|||||||
@property
|
@property
|
||||||
def audio_codecs(self) -> str:
|
def audio_codecs(self) -> str:
|
||||||
"""Return desired audio codecs."""
|
"""Return desired audio codecs."""
|
||||||
return {"aac", "ac3", "mp3"}
|
return {"aac", "mp3"}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def video_codecs(self) -> tuple:
|
def video_codecs(self) -> tuple:
|
||||||
|
@ -78,7 +78,7 @@ class RecorderOutput(StreamOutput):
|
|||||||
@property
|
@property
|
||||||
def audio_codecs(self) -> str:
|
def audio_codecs(self) -> str:
|
||||||
"""Return desired audio codec."""
|
"""Return desired audio codec."""
|
||||||
return {"aac", "ac3", "mp3"}
|
return {"aac", "mp3"}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def video_codecs(self) -> tuple:
|
def video_codecs(self) -> tuple:
|
||||||
|
@ -11,6 +11,7 @@ from typing import Dict, Optional
|
|||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
import mutagen
|
import mutagen
|
||||||
|
from mutagen.id3 import TextFrame as ID3Text
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.http import HomeAssistantView
|
from homeassistant.components.http import HomeAssistantView
|
||||||
@ -467,9 +468,9 @@ class SpeechManager:
|
|||||||
try:
|
try:
|
||||||
tts_file = mutagen.File(data_bytes, easy=True)
|
tts_file = mutagen.File(data_bytes, easy=True)
|
||||||
if tts_file is not None:
|
if tts_file is not None:
|
||||||
tts_file["artist"] = artist
|
tts_file["artist"] = ID3Text(encoding=3, text=artist)
|
||||||
tts_file["album"] = album
|
tts_file["album"] = ID3Text(encoding=3, text=album)
|
||||||
tts_file["title"] = message
|
tts_file["title"] = ID3Text(encoding=3, text=message)
|
||||||
tts_file.save(data_bytes)
|
tts_file.save(data_bytes)
|
||||||
except mutagen.MutagenError as err:
|
except mutagen.MutagenError as err:
|
||||||
_LOGGER.error("ID3 tag error: %s", err)
|
_LOGGER.error("ID3 tag error: %s", err)
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
"""Constants used by Home Assistant components."""
|
"""Constants used by Home Assistant components."""
|
||||||
MAJOR_VERSION = 0
|
MAJOR_VERSION = 0
|
||||||
MINOR_VERSION = 115
|
MINOR_VERSION = 115
|
||||||
PATCH_VERSION = "3"
|
PATCH_VERSION = "4"
|
||||||
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||||
__version__ = f"{__short_version__}.{PATCH_VERSION}"
|
__version__ = f"{__short_version__}.{PATCH_VERSION}"
|
||||||
REQUIRED_PYTHON_VER = (3, 7, 1)
|
REQUIRED_PYTHON_VER = (3, 7, 1)
|
||||||
|
@ -39,6 +39,10 @@ urllib3>=1.24.3
|
|||||||
# Constrain httplib2 to protect against CVE-2020-11078
|
# Constrain httplib2 to protect against CVE-2020-11078
|
||||||
httplib2>=0.18.0
|
httplib2>=0.18.0
|
||||||
|
|
||||||
|
# gRPC 1.32+ currently causes issues on ARMv7, see:
|
||||||
|
# https://github.com/home-assistant/core/issues/40148
|
||||||
|
grpcio==1.31.0
|
||||||
|
|
||||||
# This is a old unmaintained library and is replaced with pycryptodome
|
# This is a old unmaintained library and is replaced with pycryptodome
|
||||||
pycrypto==1000000000.0.0
|
pycrypto==1000000000.0.0
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@ Mastodon.py==1.5.1
|
|||||||
OPi.GPIO==0.4.0
|
OPi.GPIO==0.4.0
|
||||||
|
|
||||||
# homeassistant.components.plugwise
|
# homeassistant.components.plugwise
|
||||||
Plugwise_Smile==1.4.0
|
Plugwise_Smile==1.5.1
|
||||||
|
|
||||||
# homeassistant.components.essent
|
# homeassistant.components.essent
|
||||||
PyEssent==0.13
|
PyEssent==0.13
|
||||||
@ -221,7 +221,7 @@ aiopvpc==2.0.2
|
|||||||
aiopylgtv==0.3.3
|
aiopylgtv==0.3.3
|
||||||
|
|
||||||
# homeassistant.components.shelly
|
# homeassistant.components.shelly
|
||||||
aioshelly==0.3.2
|
aioshelly==0.3.3
|
||||||
|
|
||||||
# homeassistant.components.switcher_kis
|
# homeassistant.components.switcher_kis
|
||||||
aioswitcher==1.2.1
|
aioswitcher==1.2.1
|
||||||
@ -233,7 +233,7 @@ aiounifi==23
|
|||||||
aioymaps==1.1.0
|
aioymaps==1.1.0
|
||||||
|
|
||||||
# homeassistant.components.airly
|
# homeassistant.components.airly
|
||||||
airly==0.0.2
|
airly==1.0.0
|
||||||
|
|
||||||
# homeassistant.components.aladdin_connect
|
# homeassistant.components.aladdin_connect
|
||||||
aladdin_connect==0.3
|
aladdin_connect==0.3
|
||||||
@ -1265,7 +1265,7 @@ pycfdns==0.0.1
|
|||||||
pychannels==1.0.0
|
pychannels==1.0.0
|
||||||
|
|
||||||
# homeassistant.components.cast
|
# homeassistant.components.cast
|
||||||
pychromecast==7.2.1
|
pychromecast==7.5.0
|
||||||
|
|
||||||
# homeassistant.components.cmus
|
# homeassistant.components.cmus
|
||||||
pycmus==0.1.1
|
pycmus==0.1.1
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
HAP-python==3.0.0
|
HAP-python==3.0.0
|
||||||
|
|
||||||
# homeassistant.components.plugwise
|
# homeassistant.components.plugwise
|
||||||
Plugwise_Smile==1.4.0
|
Plugwise_Smile==1.5.1
|
||||||
|
|
||||||
# homeassistant.components.flick_electric
|
# homeassistant.components.flick_electric
|
||||||
PyFlick==0.0.2
|
PyFlick==0.0.2
|
||||||
@ -131,7 +131,7 @@ aiopvpc==2.0.2
|
|||||||
aiopylgtv==0.3.3
|
aiopylgtv==0.3.3
|
||||||
|
|
||||||
# homeassistant.components.shelly
|
# homeassistant.components.shelly
|
||||||
aioshelly==0.3.2
|
aioshelly==0.3.3
|
||||||
|
|
||||||
# homeassistant.components.switcher_kis
|
# homeassistant.components.switcher_kis
|
||||||
aioswitcher==1.2.1
|
aioswitcher==1.2.1
|
||||||
@ -143,7 +143,7 @@ aiounifi==23
|
|||||||
aioymaps==1.1.0
|
aioymaps==1.1.0
|
||||||
|
|
||||||
# homeassistant.components.airly
|
# homeassistant.components.airly
|
||||||
airly==0.0.2
|
airly==1.0.0
|
||||||
|
|
||||||
# homeassistant.components.ambiclimate
|
# homeassistant.components.ambiclimate
|
||||||
ambiclimate==0.2.1
|
ambiclimate==0.2.1
|
||||||
@ -613,7 +613,7 @@ pyblackbird==0.5
|
|||||||
pybotvac==0.0.17
|
pybotvac==0.0.17
|
||||||
|
|
||||||
# homeassistant.components.cast
|
# homeassistant.components.cast
|
||||||
pychromecast==7.2.1
|
pychromecast==7.5.0
|
||||||
|
|
||||||
# homeassistant.components.coolmaster
|
# homeassistant.components.coolmaster
|
||||||
pycoolmasternet-async==0.1.2
|
pycoolmasternet-async==0.1.2
|
||||||
|
@ -67,6 +67,10 @@ urllib3>=1.24.3
|
|||||||
# Constrain httplib2 to protect against CVE-2020-11078
|
# Constrain httplib2 to protect against CVE-2020-11078
|
||||||
httplib2>=0.18.0
|
httplib2>=0.18.0
|
||||||
|
|
||||||
|
# gRPC 1.32+ currently causes issues on ARMv7, see:
|
||||||
|
# https://github.com/home-assistant/core/issues/40148
|
||||||
|
grpcio==1.31.0
|
||||||
|
|
||||||
# This is a old unmaintained library and is replaced with pycryptodome
|
# This is a old unmaintained library and is replaced with pycryptodome
|
||||||
pycrypto==1000000000.0.0
|
pycrypto==1000000000.0.0
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import aioshelly
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant import config_entries, setup
|
from homeassistant import config_entries, setup
|
||||||
@ -109,10 +110,7 @@ async def test_form_errors_get_info(hass, error):
|
|||||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with patch("aioshelly.get_info", side_effect=exc):
|
||||||
"aioshelly.get_info",
|
|
||||||
side_effect=exc,
|
|
||||||
):
|
|
||||||
result2 = await hass.config_entries.flow.async_configure(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
{"host": "1.1.1.1"},
|
{"host": "1.1.1.1"},
|
||||||
@ -134,10 +132,7 @@ async def test_form_errors_test_connection(hass, error):
|
|||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"aioshelly.get_info", return_value={"mac": "test-mac", "auth": False}
|
"aioshelly.get_info", return_value={"mac": "test-mac", "auth": False}
|
||||||
), patch(
|
), patch("aioshelly.Device.create", new=AsyncMock(side_effect=exc)):
|
||||||
"aioshelly.Device.create",
|
|
||||||
side_effect=exc,
|
|
||||||
):
|
|
||||||
result2 = await hass.config_entries.flow.async_configure(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
{"host": "1.1.1.1"},
|
{"host": "1.1.1.1"},
|
||||||
@ -175,6 +170,22 @@ async def test_form_already_configured(hass):
|
|||||||
assert entry.data["host"] == "1.1.1.1"
|
assert entry.data["host"] == "1.1.1.1"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form_firmware_unsupported(hass):
|
||||||
|
"""Test we abort if device firmware is unsupported."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("aioshelly.get_info", side_effect=aioshelly.FirmwareUnsupported):
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{"host": "1.1.1.1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result2["type"] == "abort"
|
||||||
|
assert result2["reason"] == "unsupported_firmware"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"error",
|
"error",
|
||||||
[
|
[
|
||||||
@ -309,12 +320,22 @@ async def test_zeroconf_already_configured(hass):
|
|||||||
assert entry.data["host"] == "1.1.1.1"
|
assert entry.data["host"] == "1.1.1.1"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zeroconf_firmware_unsupported(hass):
|
||||||
|
"""Test we abort if device firmware is unsupported."""
|
||||||
|
with patch("aioshelly.get_info", side_effect=aioshelly.FirmwareUnsupported):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
data={"host": "1.1.1.1", "name": "shelly1pm-12345"},
|
||||||
|
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == "abort"
|
||||||
|
assert result["reason"] == "unsupported_firmware"
|
||||||
|
|
||||||
|
|
||||||
async def test_zeroconf_cannot_connect(hass):
|
async def test_zeroconf_cannot_connect(hass):
|
||||||
"""Test we get the form."""
|
"""Test we get the form."""
|
||||||
with patch(
|
with patch("aioshelly.get_info", side_effect=asyncio.TimeoutError):
|
||||||
"aioshelly.get_info",
|
|
||||||
side_effect=asyncio.TimeoutError,
|
|
||||||
):
|
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
data={"host": "1.1.1.1", "name": "shelly1pm-12345"},
|
data={"host": "1.1.1.1", "name": "shelly1pm-12345"},
|
||||||
|
Loading…
x
Reference in New Issue
Block a user