1
0
mirror of https://github.com/home-assistant/core.git synced 2025-08-21 19:30:02 +00:00
Files
.devcontainer
.github
.vscode
homeassistant
auth
backports
brands
components
3_day_blinds
abode
accuweather
acer_projector
acmeda
acomax
actiontec
adax
adguard
ads
advantage_air
aemet
aep_ohio
aep_texas
aftership
agent_dvr
air_quality
airgradient
airly
airnow
airq
airthings
airthings_ble
airtouch4
airtouch5
airvisual
airvisual_pro
airzone
airzone_cloud
aladdin_connect
alarm_control_panel
alarmdecoder
alert
alexa
alpha_vantage
amazon_polly
amberelectric
ambient_network
ambient_station
amcrest
amp_motorization
ampio
analytics
analytics_insights
android_ip_webcam
androidtv
androidtv_remote
anel_pwrctrl
anova
anthemav
anwb_energie
aosmith
apache_kafka
apcupsd
api
appalachianpower
apple_tv
application_credentials
apprise
aprilaire
aprs
apsystems
aqualogic
aquostv
aranet
arcam_fmj
arest
arris_tg2492lg
aruba
arve
arwn
aseko_pool_live
assist_pipeline
asterisk_cdr
asterisk_mbox
asuswrt
atag
aten_pe
atlanticcityelectric
atome
august
august_ble
aurora
aurora_abb_powerone
aussie_broadband
auth
automation
avea
avion
awair
aws
axis
azure_data_explorer
azure_devops
azure_event_hub
azure_service_bus
backup
baf
baidu
balboa
bang_olufsen
bayesian
bbox
beewi_smartclim
bge
binary_sensor
bitcoin
bizkaibus
blackbird
blebox
blink
blinksticklight
bliss_automation
bloc_blinds
blockchain
bloomsky
blue_current
bluemaestro
blueprint
bluesound
bluetooth
bluetooth_adapters
bluetooth_le_tracker
bluetooth_tracker
bmw_connected_drive
bond
bosch_shc
brandt
braviatv
brel_home
bring
broadlink
brother
brottsplatskartan
browser
brunt
bsblan
bswitch
bt_home_hub_5
bt_smarthub
bthome
bticino
bubendorff
buienradar
button
caldav
calendar
camera
canary
cast
ccm15
cert_expiry
channels
circuit
cisco_ios
cisco_mobility_express
cisco_webex_teams
citybikes
clementine
clickatell
clicksend
clicksend_tts
climate
cloud
cloudflare
cmus
co2signal
coautilities
coinbase
color_extractor
comed
comed_hourly_pricing
comelit
comfoconnect
command_line
compensation
concord232
coned
config
configurator
control4
conversation
coolmaster
counter
cover
cozytouch
cppm_tracker
cpuspeed
cribl
crownstone
cups
currencylayer
dacia
daikin
danfoss_air
datadog
date
datetime
ddwrt
debugpy
deconz
decora
decora_wifi
default_config
delijn
delmarva
deluge
demo
denon
denonavr
derivative
devialet
device_automation
device_sun_light_trigger
device_tracker
devolo_home_control
devolo_home_network
dexcom
dhcp
diagnostics
dialogflow
diaz
digital_loggers
digital_ocean
directv
discogs
discord
discovergy
dlib_face_detect
dlib_face_identify
dlink
dlna_dmr
dlna_dms
dnsip
dominos
doods
doorbird
dooya
dormakaba_dkey
dovado
downloader
dremel_3d_printer
drop_connect
dsmr
dsmr_reader
dte_energy_bridge
dublin_bus_transport
duckdns
dunehd
duotecno
duquesne_light
dwd_weather_warnings
dweet
dynalite
eafm
eastron
easyenergy
ebox
ebusd
ecoal_boiler
ecobee
ecoforest
econet
ecovacs
ecowitt
eddystone_temperature
edimax
edl21
efergy
egardia
eight_sleep
electrasmart
electric_kiwi
elgato
eliqonline
elkm1
elmax
elv
elvia
emby
emoncms
emoncms_history
emonitor
emulated_hue
emulated_kasa
emulated_roku
energenie_power_sockets
energie_vanons
energy
energyzero
enigma2
enmax
enocean
enphase_envoy
entur_public_transport
environment_canada
envisalink
ephember
epic_games_store
epion
epson
eq3btsmart
escea
esera_onewire
esphome
etherscan
eufy
eufylife_ble
event
evergy
everlights
evil_genius_labs
evohome
ezviz
faa_delays
facebook
fail2ban
familyhub
fan
fastdotcom
feedreader
ffmpeg
ffmpeg_motion
ffmpeg_noise
fibaro
fido
file
file_upload
filesize
filter
fints
fire_tv
fireservicerota
firmata
fitbit
fivem
fixer
fjaraskupan
fleetgo
flexit
flexit_bacnet
flexom
flic
flick_electric
flipr
flo
flock
flume
flux
flux_led
folder
folder_watcher
foobot
forecast_solar
forked_daapd
fortios
foscam
foursquare
free_mobile
freebox
freedns
freedompro
fritz
fritzbox
fritzbox_callmonitor
fronius
frontend
frontier_silicon
fujitsu_anywair
fully_kiosk
futurenow
fyta
garadget
garages_amsterdam
gardena_bluetooth
gaviota
gc100
gdacs
generic
generic_hygrostat
generic_thermostat
geniushub
geo_json_events
geo_location
geo_rss_events
geocaching
geofency
geonetnz_quakes
geonetnz_volcano
gios
github
gitlab_ci
gitter
glances
goalzero
gogogate2
goodwe
google
google_assistant
google_assistant_sdk
google_cloud
google_domains
google_generative_ai_conversation
google_mail
google_maps
google_pubsub
google_sheets
google_tasks
google_translate
google_travel_time
google_wifi
govee_ble
govee_light_local
gpsd
gpslogger
graphite
gree
greeneye_monitor
greenwave
group
growatt_server
gstreamer
gtfs
guardian
habitica
hardkernel
hardware
harman_kardon_avr
harmony
hassio
havana_shade
haveibeenpwned
hddtemp
hdmi_cec
heatmiser
heiwa
heos
here_travel_time
hexaom
hi_kumo
hikvision
hikvisioncam
hisense_aehw4a1
history
history_stats
hitron_coda
hive
hko
hlk_sw16
holiday
home_connect
home_plus_control
homeassistant
homeassistant_alerts
homeassistant_green
homeassistant_hardware
homeassistant_sky_connect
homeassistant_yellow
homekit
homekit_controller
homematic
homematicip_cloud
homewizard
homeworks
honeywell
horizon
hp_ilo
html5
http
huawei_lte
hue
huisbaasje
humidifier
hunterdouglas_powerview
hurrican_shutters_wholesale
husqvarna_automower
huum
hvv_departures
hydrawise
hyperion
ialarm
iammeter
iaqualink
ibeacon
icloud
idasen_desk
idteck_prox
ifttt
iglo
ign_sismologia
ihc
image
image_processing
image_upload
imap
imgw_pib
improv_ble
incomfort
indianamichiganpower
influxdb
inkbird
input_boolean
input_button
input_datetime
input_number
input_select
input_text
inspired_shades
insteon
integration
intellifire
intent
intent_script
intesishome
ios
iotawatt
iperf3
ipma
ipp
iqvia
irish_rail_transport
isal
islamic_prayer_times
ismartwindow
iss
isy994
itach
itunes
izone
jellyfin
jewish_calendar
joaoapps_join
juicenet
justnimbus
jvc_projector
kaiterra
kaleidescape
kankun
keba
keenetic_ndms2
kef
kegtron
kentuckypower
keyboard
keyboard_remote
keymitt_ble
kira
kitchen_sink
kiwi
kmtronic
knx
kodi
konnected
kostal_plenticore
kraken
krispol
kulersky
kwb
lacrosse
lacrosse_view
lamarzocco
lametric
landisgyr_heat_meter
lannouncer
lastfm
launch_library
laundrify
lawn_mower
lcn
ld2410_ble
leaone
led_ble
legrand
lg_netcast
lg_soundbar
lidarr
life360
lifx
lifx_cloud
light
lightwave
limitlessled
linear_garage_door
linksys_smart
linode
linux_battery
lirc
litejet
litterrobot
livisi
llamalab_automate
local_calendar
local_file
local_ip
local_todo
locative
lock
logbook
logentries
logger
logi_circle
london_air
london_underground
lookin
loqed
lovelace
luci
luftdaten
lupusec
lutron
lutron_caseta
luxaflex
lw12wifi
lyric
madeco
mailbox
mailgun
manual
manual_mqtt
map
marantz
martec
marytts
mastodon
matrix
matter
maxcube
mazda
meater
medcom_ble
media_extractor
media_player
media_source
mediaroom
melcloud
melissa
melnor
meraki
message_bird
met
met_eireann
meteo_france
meteoalarm
meteoclimatic
metoffice
mfi
microbees
microsoft
microsoft_face
microsoft_face_detect
microsoft_face_identify
mijndomein_energie
mikrotik
mill
min_max
minecraft_server
minio
mjpeg
moat
mobile_app
mochad
modbus
modem_callerid
modern_forms
moehlenhoff_alpha2
mold_indicator
monessen
monoprice
monzo
moon
mopeka
motion_blinds
motionblinds_ble
motioneye
motionmount
mpd
mqtt
mqtt_eventstream
mqtt_json
mqtt_room
mqtt_statestream
msteams
mullvad
mutesync
mvglive
my
mycroft
myq
mysensors
mystrom
mythicbeastsdns
myuplink
nad
nam
namecheapdns
nanoleaf
neato
nederlandse_spoorwegen
ness_alarm
nest
netatmo
netdata
netgear
netgear_lte
netio
network
neurio_energy
nexia
nexity
nextbus
nextcloud
nextdns
nfandroidtv
nibe_heatpump
nightscout
niko_home_control
nilu
nina
nissan_leaf
nmap_tracker
nmbs
no_ip
noaa_tides
nobo_hub
norway_air
notify
notify_events
notion
nsw_fuel_station
nsw_rural_fire_service_feed
nuheat
nuki
numato
number
nut
nutrichef
nws
nx584
nzbget
oasa_telematics
obihai
octoprint
oem
ohmconnect
ollama
ombi
omnilogic
onboarding
oncue
ondilo_ico
onewire
onkyo
onvif
open_meteo
openai_conversation
openalpr_cloud
openerz
openevse
openexchangerates
opengarage
openhardwaremonitor
openhome
opensensemap
opensky
opentherm_gw
openuv
openweathermap
opnsense
opower
opple
oralb
oru
oru_opower
orvibo
osoenergy
osramlightify
otbr
otp
ourgroceries
overkiz
ovo_energy
owntracks
p1_monitor
panasonic_bluray
panasonic_viera
pandora
panel_custom
panel_iframe
pcs_lighting
peco
peco_opower
pegel_online
pencom
pepco
permobil
persistent_notification
person
pge
philips_js
pi_hole
picnic
picotts
pilight
ping
pioneer
piper
pjlink
plaato
plant
plex
plugwise
plum_lightpad
pocketcasts
point
poolsense
portlandgeneral
powerwall
private_ble_device
profiler
progettihwsw
proliphix
prometheus
prosegur
prowl
proximity
proxmoxve
proxy
prusalink
ps4
pse
psoklahoma
pulseaudio_loopback
pure_energie
purpleair
push
pushbullet
pushover
pushsafer
pvoutput
pvpc_hourly_pricing
pyload
python_script
qbittorrent
qingping
qld_bushfire
qnap
qnap_qsw
qrcode
quadrafire
quantum_gateway
qvr_pro
qwikswitch
rabbitair
rachio
radarr
radio_browser
radiotherm
rainbird
raincloud
rainforest_eagle
rainforest_raven
rainmachine
random
rapt_ble
raspberry_pi
raspyrfm
raven_rock_mfg
rdw
recollect_waste
recorder
recovery_mode
recswitch
reddit
refoss
rejseplanen
remember_the_milk
remote
remote_rpi_gpio
renault
renson
reolink
repairs
repetier
rest
rest_command
rexel
rflink
rfxtrx
rhasspy
ridwell
ring
ripple
risco
rituals_perfume_genie
rmvtransport
roborock
rocketchat
roku
romy
roomba
roon
route53
rova
rpi_camera
rpi_power
rss_feed_template
rtorrent
rtsp_to_webrtc
ruckus_unleashed
russound_rio
russound_rnet
ruuvi_gateway
ruuvitag_ble
rympro
sabnzbd
saj
samsam
samsungtv
sanix
satel_integra
scene
schedule
schlage
schluter
scl
scrape
screenaway
screenlogic
script
scsgate
search
season
select
sendgrid
sense
sensibo
sensirion_ble
sensor
sensorblue
sensorpro
sensorpush
sentry
senz
serial
serial_pm
sesame
seven_segments
seventeentrack
sfr_box
sharkiq
shell_command
shelly
shodan
shopping_list
sia
sigfox
sighthound
signal_messenger
simplepush
simplisafe
simply_automated
simu
simulated
sinch
siren
sisyphus
sky_hub
skybeacon
skybell
slack
sleepiq
slide
slimproto
sma
smappee
smart_blinds
smart_home
smart_meter_texas
smarther
smartthings
smarttub
smarty
smhi
sms
smtp
smud
snapcast
snips
snmp
snooz
solaredge
solaredge_local
solarlog
solax
soma
somfy
somfy_mylink
sonarr
songpal
sonos
sony_projector
soundtouch
spaceapi
spc
speedtestdotnet
spider
splunk
spotify
sql
squeezebox
srp_energy
ssdp
starline
starlingbank
starlink
startca
statistics
statsd
steam_online
steamist
stiebel_eltron
stookalert
stookwijzer
stream
streamlabswater
stt
subaru
suez_water
sun
sunweg
supervisord
supla
surepetcare
swepco
swiss_hydrological_data
swiss_public_transport
swisscom
switch
switch_as_x
switchbee
switchbot
switchbot_cloud
switcher_kis
switchmate
symfonisk
syncthing
syncthru
synology_chat
synology_dsm
synology_srm
syslog
system_bridge
system_health
system_log
systemmonitor
tado
tag
tailscale
tailwind
tami4
tank_utility
tankerkoenig
tapsaff
tasmota
tautulli
tcp
technove
ted5000
tedee
telegram
telegram_bot
tellduslive
tellstick
telnet
temper
template
tensorflow
tesla_wall_connector
teslemetry
tessie
text
tfiac
thermobeacon
thermoplus
thermopro
thermoworks_smoke
thethingsnetwork
thingspeak
thinkingcleaner
thomson
thread
threshold
tibber
tikteck
tile
tilt_ble
time
time_date
timer
tmb
tod
todo
todoist
tolo
tomato
tomorrowio
toon
torque
totalconnect
touchline
tplink
tplink_lte
tplink_omada
tplink_tapo
traccar
traccar_server
trace
tractive
tradfri
trafikverket_camera
trafikverket_ferry
trafikverket_train
trafikverket_weatherstation
transmission
transport_nsw
travisci
trend
tts
tuya
twentemilieu
twilio
twilio_call
twilio_sms
twinkly
twitch
twitter
ubiwizz
ubus
ue_smart_radio
uk_transport
ukraine_alarm
ultraloq
unifi
unifi_direct
unifiled
unifiprotect
__init__.py
binary_sensor.py
button.py
camera.py
config_flow.py
const.py
data.py
diagnostics.py
discovery.py
entity.py
icons.json
light.py
lock.py
manifest.json
media_player.py
media_source.py
migrate.py
models.py
number.py
repairs.py
select.py
sensor.py
services.py
services.yaml
strings.json
switch.py
text.py
utils.py
views.py
universal
upb
upc_connect
upcloud
update
upnp
uprise_smart_shades
uptime
uptimerobot
usb
usgs_earthquakes_feed
utility_meter
uvc
v2c
vacuum
vallox
valve
vasttrafik
velbus
velux
venstar
vera
verisure
vermont_castings
versasense
version
vesync
viaggiatreno
vicare
vilfo
vivotek
vizio
vlc
vlc_telnet
vodafone_station
voicerss
voip
volkszaehler
volumio
volvooncall
vulcan
vultr
w800rf32
wake_on_lan
wake_word
wallbox
waqi
water_heater
waterfurnace
watson_iot
watson_tts
watttime
waze_travel_time
weather
weatherflow
weatherflow_cloud
weatherkit
webhook
webmin
webostv
websocket_api
wemo
whirlpool
whisper
whois
wiffi
wilight
wirelesstag
withings
wiz
wled
wolflink
workday
worldclock
worldtidesinfo
worxlandroid
ws66i
wsdot
wyoming
x10
xbox
xeoma
xiaomi
xiaomi_aqara
xiaomi_ble
xiaomi_miio
xiaomi_tv
xmpp
xs1
yale_home
yale_smart_alarm
yalexs_ble
yamaha
yamaha_musiccast
yandex_transport
yandextts
yardian
yeelight
yeelightsunflower
yi
yolink
youless
youtube
zabbix
zamg
zengge
zeroconf
zerproc
zestimate
zeversolar
zha
zhong_hong
ziggo_mediabox_xl
zodiac
zondergas
zone
zoneminder
zwave_js
zwave_me
__init__.py
generated
helpers
scripts
util
__init__.py
__main__.py
block_async_io.py
bootstrap.py
config.py
config_entries.py
const.py
core.py
data_entry_flow.py
exceptions.py
loader.py
package_constraints.txt
py.typed
requirements.py
runner.py
setup.py
strings.json
machine
pylint
rootfs
script
tests
.core_files.yaml
.coveragerc
.dockerignore
.git-blame-ignore-revs
.gitattributes
.gitignore
.hadolint.yaml
.pre-commit-config.yaml
.prettierignore
.strict-typing
.yamllint
CLA.md
CODEOWNERS
CODE_OF_CONDUCT.md
CONTRIBUTING.md
Dockerfile
Dockerfile.dev
LICENSE.md
MANIFEST.in
README.rst
build.yaml
codecov.yml
mypy.ini
pyproject.toml
requirements.txt
requirements_all.txt
requirements_test.txt
requirements_test_all.txt
requirements_test_pre_commit.txt
core/homeassistant/components/unifiprotect/media_source.py

905 lines
30 KiB
Python

"""UniFi Protect media sources."""
from __future__ import annotations
import asyncio
from datetime import date, datetime, timedelta
from enum import Enum
from typing import Any, NoReturn, cast
from pyunifiprotect.data import (
Camera,
Event,
EventType,
ModelType,
SmartDetectObjectType,
)
from pyunifiprotect.exceptions import NvrError
from pyunifiprotect.utils import from_js_time
from yarl import URL
from homeassistant.components.camera import CameraImageView
from homeassistant.components.media_player import BrowseError, MediaClass
from homeassistant.components.media_source.models import (
BrowseMediaSource,
MediaSource,
MediaSourceItem,
PlayMedia,
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .data import ProtectData
from .views import async_generate_event_video_url, async_generate_thumbnail_url
VIDEO_FORMAT = "video/mp4"
THUMBNAIL_WIDTH = 185
THUMBNAIL_HEIGHT = 185
class SimpleEventType(str, Enum):
"""Enum to Camera Video events."""
ALL = "all"
RING = "ring"
MOTION = "motion"
SMART = "smart"
AUDIO = "audio"
class IdentifierType(str, Enum):
"""UniFi Protect identifier type."""
EVENT = "event"
EVENT_THUMB = "eventthumb"
BROWSE = "browse"
class IdentifierTimeType(str, Enum):
"""UniFi Protect identifier subtype."""
RECENT = "recent"
RANGE = "range"
EVENT_MAP: dict[SimpleEventType, set[EventType]] = {
SimpleEventType.ALL: {
EventType.RING,
EventType.MOTION,
EventType.SMART_DETECT,
EventType.SMART_DETECT_LINE,
EventType.SMART_AUDIO_DETECT,
},
SimpleEventType.RING: {EventType.RING},
SimpleEventType.MOTION: {EventType.MOTION},
SimpleEventType.SMART: {EventType.SMART_DETECT, EventType.SMART_DETECT_LINE},
SimpleEventType.AUDIO: {EventType.SMART_AUDIO_DETECT},
}
EVENT_NAME_MAP = {
SimpleEventType.ALL: "All Events",
SimpleEventType.RING: "Ring Events",
SimpleEventType.MOTION: "Motion Events",
SimpleEventType.SMART: "Object Detections",
SimpleEventType.AUDIO: "Audio Detections",
}
def get_ufp_event(event_type: SimpleEventType) -> set[EventType]:
"""Get UniFi Protect event type from SimpleEventType."""
return EVENT_MAP[event_type]
async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
"""Set up UniFi Protect media source."""
data_sources: dict[str, ProtectData] = {}
for data in hass.data.get(DOMAIN, {}).values():
if isinstance(data, ProtectData):
data_sources[data.api.bootstrap.nvr.id] = data
return ProtectMediaSource(hass, data_sources)
@callback
def _get_month_start_end(start: datetime) -> tuple[datetime, datetime]:
start = dt_util.as_local(start)
end = dt_util.now()
start = start.replace(day=1, hour=0, minute=0, second=1, microsecond=0)
end = end.replace(day=1, hour=0, minute=0, second=2, microsecond=0)
return start, end
@callback
def _bad_identifier(identifier: str, err: Exception | None = None) -> NoReturn:
msg = f"Unexpected identifier: {identifier}"
if err is None:
raise BrowseError(msg)
raise BrowseError(msg) from err
@callback
def _format_duration(duration: timedelta) -> str:
formatted = ""
seconds = int(duration.total_seconds())
if seconds > 3600:
hours = seconds // 3600
formatted += f"{hours}h "
seconds -= hours * 3600
if seconds > 60:
minutes = seconds // 60
formatted += f"{minutes}m "
seconds -= minutes * 60
if seconds > 0:
formatted += f"{seconds}s "
return formatted.strip()
@callback
def _get_object_name(event: Event | dict[str, Any]) -> str:
if isinstance(event, Event):
event = event.unifi_dict()
names = []
types = set(event["smartDetectTypes"])
metadata = event.get("metadata") or {}
for thumb in metadata.get("detectedThumbnails", []):
thumb_type = thumb.get("type")
if thumb_type not in types:
continue
types.remove(thumb_type)
if thumb_type == SmartDetectObjectType.VEHICLE.value:
attributes = thumb.get("attributes") or {}
color = attributes.get("color", {}).get("val", "")
vehicle_type = attributes.get("vehicleType", {}).get("val", "vehicle")
license_plate = metadata.get("licensePlate", {}).get("name")
name = f"{color} {vehicle_type}".strip().title()
if license_plate:
types.remove(SmartDetectObjectType.LICENSE_PLATE.value)
name = f"{name}: {license_plate}"
names.append(name)
else:
smart_type = SmartDetectObjectType(thumb_type)
names.append(smart_type.name.title().replace("_", " "))
for raw in types:
smart_type = SmartDetectObjectType(raw)
names.append(smart_type.name.title().replace("_", " "))
return ", ".join(sorted(names))
@callback
def _get_audio_name(event: Event | dict[str, Any]) -> str:
if isinstance(event, Event):
event = event.unifi_dict()
smart_types = [SmartDetectObjectType(e) for e in event["smartDetectTypes"]]
return ", ".join([s.name.title().replace("_", " ") for s in smart_types])
class ProtectMediaSource(MediaSource):
"""Represents all UniFi Protect NVRs."""
name: str = "UniFi Protect"
_registry: er.EntityRegistry | None
def __init__(
self, hass: HomeAssistant, data_sources: dict[str, ProtectData]
) -> None:
"""Initialize the UniFi Protect media source."""
super().__init__(DOMAIN)
self.hass = hass
self.data_sources = data_sources
self._registry = None
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
"""Return a streamable URL and associated mime type for a UniFi Protect event.
Accepted identifier format are
* {nvr_id}:event:{event_id} - MP4 video clip for specific event
* {nvr_id}:eventthumb:{event_id} - Thumbnail JPEG for specific event
"""
parts = item.identifier.split(":")
if len(parts) != 3 or parts[1] not in ("event", "eventthumb"):
_bad_identifier(item.identifier)
thumbnail_only = parts[1] == "eventthumb"
try:
data = self.data_sources[parts[0]]
except (KeyError, IndexError) as err:
_bad_identifier(item.identifier, err)
event = data.api.bootstrap.events.get(parts[2])
if event is None:
try:
event = await data.api.get_event(parts[2])
except NvrError as err:
_bad_identifier(item.identifier, err)
else:
# cache the event for later
data.api.bootstrap.events[event.id] = event
nvr = data.api.bootstrap.nvr
if thumbnail_only:
return PlayMedia(
async_generate_thumbnail_url(event.id, nvr.id), "image/jpeg"
)
return PlayMedia(async_generate_event_video_url(event), "video/mp4")
async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
"""Return a browsable UniFi Protect media source.
Identifier formatters for UniFi Protect media sources are all in IDs from
the UniFi Protect instance since events may not always map 1:1 to a Home
Assistant device or entity. It also drasically speeds up resolution.
The UniFi Protect Media source is timebased for the events recorded by the NVR.
So its structure is a bit different then many other media players. All browsable
media is a video clip. The media source could be greatly cleaned up if/when the
frontend has filtering supporting.
* ... Each NVR Console (hidden if there is only one)
* All Cameras
* ... Camera X
* All Events
* ... Event Type X
* Last 24 Hours -> Events
* Last 7 Days -> Events
* Last 30 Days -> Events
* ... This Month - X
* Whole Month -> Events
* ... Day X -> Events
Accepted identifier formats:
* {nvr_id}:event:{event_id}
Specific Event for NVR
* {nvr_id}:eventthumb:{event_id}
Specific Event Thumbnail for NVR
* {nvr_id}:browse
Root NVR browse source
* {nvr_id}:browse:all|{camera_id}
Root Camera(s) browse source
* {nvr_id}:browse:all|{camera_id}:all|{event_type}
Root Camera(s) Event Type(s) browse source
* {nvr_id}:browse:all|{camera_id}:all|{event_type}:recent:{day_count}
Listing of all events in last {day_count}, sorted in reverse chronological order
* {nvr_id}:browse:all|{camera_id}:all|{event_type}:range:{year}:{month}
List of folders for each day in month + all events for month
* {nvr_id}:browse:all|{camera_id}:all|{event_type}:range:{year}:{month}:all|{day}
Listing of all events for give {day} + {month} + {year} combination in chronological order
"""
if not item.identifier:
return await self._build_sources()
parts = item.identifier.split(":")
try:
data = self.data_sources[parts[0]]
except (KeyError, IndexError) as err:
_bad_identifier(item.identifier, err)
if len(parts) < 2:
_bad_identifier(item.identifier)
try:
identifier_type = IdentifierType(parts[1])
except ValueError as err:
_bad_identifier(item.identifier, err)
if identifier_type in (IdentifierType.EVENT, IdentifierType.EVENT_THUMB):
thumbnail_only = identifier_type == IdentifierType.EVENT_THUMB
return await self._resolve_event(data, parts[2], thumbnail_only)
# rest are params for browse
parts = parts[2:]
# {nvr_id}:browse
if len(parts) == 0:
return await self._build_console(data)
# {nvr_id}:browse:all|{camera_id}
camera_id = parts.pop(0)
if len(parts) == 0:
return await self._build_camera(data, camera_id, build_children=True)
# {nvr_id}:browse:all|{camera_id}:all|{event_type}
try:
event_type = SimpleEventType(parts.pop(0).lower())
except (IndexError, ValueError) as err:
_bad_identifier(item.identifier, err)
if len(parts) == 0:
return await self._build_events_type(
data, camera_id, event_type, build_children=True
)
try:
time_type = IdentifierTimeType(parts.pop(0))
except ValueError as err:
_bad_identifier(item.identifier, err)
if len(parts) == 0:
_bad_identifier(item.identifier)
# {nvr_id}:browse:all|{camera_id}:all|{event_type}:recent:{day_count}
if time_type == IdentifierTimeType.RECENT:
try:
days = int(parts.pop(0))
except (IndexError, ValueError) as err:
_bad_identifier(item.identifier, err)
return await self._build_recent(
data, camera_id, event_type, days, build_children=True
)
# {nvr_id}:all|{camera_id}:all|{event_type}:range:{year}:{month}
# {nvr_id}:all|{camera_id}:all|{event_type}:range:{year}:{month}:all|{day}
try:
start, is_month, is_all = self._parse_range(parts)
except (IndexError, ValueError) as err:
_bad_identifier(item.identifier, err)
if is_month:
return await self._build_month(
data, camera_id, event_type, start, build_children=True
)
return await self._build_days(
data, camera_id, event_type, start, build_children=True, is_all=is_all
)
def _parse_range(self, parts: list[str]) -> tuple[date, bool, bool]:
day = 1
is_month = True
is_all = True
year = int(parts[0])
month = int(parts[1])
if len(parts) == 3:
is_month = False
if parts[2] != "all":
is_all = False
day = int(parts[2])
start = date(year=year, month=month, day=day)
return start, is_month, is_all
async def _resolve_event(
self, data: ProtectData, event_id: str, thumbnail_only: bool = False
) -> BrowseMediaSource:
"""Resolve a specific event."""
subtype = "eventthumb" if thumbnail_only else "event"
try:
event = await data.api.get_event(event_id)
except NvrError as err:
_bad_identifier(f"{data.api.bootstrap.nvr.id}:{subtype}:{event_id}", err)
if event.start is None or event.end is None:
raise BrowseError("Event is still ongoing")
return await self._build_event(data, event, thumbnail_only)
@callback
def async_get_registry(self) -> er.EntityRegistry:
"""Get or return Entity Registry."""
if self._registry is None:
self._registry = er.async_get(self.hass)
return self._registry
def _breadcrumb(
self,
data: ProtectData,
base_title: str,
camera: Camera | None = None,
event_type: SimpleEventType | None = None,
count: int | None = None,
) -> str:
title = base_title
if count is not None:
if count == data.max_events:
title = f"{title} ({count} TRUNCATED)"
else:
title = f"{title} ({count})"
if event_type is not None:
title = f"{EVENT_NAME_MAP[event_type].title()} > {title}"
if camera is not None:
title = f"{camera.display_name} > {title}"
return f"{data.api.bootstrap.nvr.display_name} > {title}"
async def _build_event(
self,
data: ProtectData,
event: dict[str, Any] | Event,
thumbnail_only: bool = False,
) -> BrowseMediaSource:
"""Build media source for an individual event."""
if isinstance(event, Event):
event_id = event.id
event_type = event.type
start = event.start
end = event.end
else:
event_id = event["id"]
event_type = EventType(event["type"])
start = from_js_time(event["start"])
end = from_js_time(event["end"])
assert end is not None
title = dt_util.as_local(start).strftime("%x %X")
duration = end - start
title += f" {_format_duration(duration)}"
if event_type in EVENT_MAP[SimpleEventType.RING]:
event_text = "Ring Event"
elif event_type in EVENT_MAP[SimpleEventType.MOTION]:
event_text = "Motion Event"
elif event_type in EVENT_MAP[SimpleEventType.SMART]:
event_text = f"Object Detection - {_get_object_name(event)}"
elif event_type in EVENT_MAP[SimpleEventType.AUDIO]:
event_text = f"Audio Detection - {_get_audio_name(event)}"
title += f" {event_text}"
nvr = data.api.bootstrap.nvr
if thumbnail_only:
return BrowseMediaSource(
domain=DOMAIN,
identifier=f"{nvr.id}:eventthumb:{event_id}",
media_class=MediaClass.IMAGE,
media_content_type="image/jpeg",
title=title,
can_play=True,
can_expand=False,
thumbnail=async_generate_thumbnail_url(
event_id, nvr.id, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT
),
)
return BrowseMediaSource(
domain=DOMAIN,
identifier=f"{nvr.id}:event:{event_id}",
media_class=MediaClass.VIDEO,
media_content_type="video/mp4",
title=title,
can_play=True,
can_expand=False,
thumbnail=async_generate_thumbnail_url(
event_id, nvr.id, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT
),
)
async def _build_events(
self,
data: ProtectData,
start: datetime,
end: datetime,
camera_id: str | None = None,
event_types: set[EventType] | None = None,
reserve: bool = False,
) -> list[BrowseMediaSource]:
"""Build media source for a given range of time and event type."""
event_types = event_types or get_ufp_event(SimpleEventType.ALL)
types = list(event_types)
sources: list[BrowseMediaSource] = []
events = await data.api.get_events_raw(
start=start, end=end, types=types, limit=data.max_events
)
events = sorted(events, key=lambda e: cast(int, e["start"]), reverse=reserve)
for event in events:
# do not process ongoing events
if event.get("start") is None or event.get("end") is None:
continue
if camera_id is not None and event.get("camera") != camera_id:
continue
# smart detect events have a paired motion event
if event.get("type") == EventType.MOTION.value and event.get(
"smartDetectEvents"
):
continue
sources.append(await self._build_event(data, event))
return sources
async def _build_recent(
self,
data: ProtectData,
camera_id: str,
event_type: SimpleEventType,
days: int,
build_children: bool = False,
) -> BrowseMediaSource:
"""Build media source for events in relative days."""
base_id = f"{data.api.bootstrap.nvr.id}:browse:{camera_id}:{event_type.value}"
title = f"Last {days} Days"
if days == 1:
title = "Last 24 Hours"
source = BrowseMediaSource(
domain=DOMAIN,
identifier=f"{base_id}:recent:{days}",
media_class=MediaClass.DIRECTORY,
media_content_type="video/mp4",
title=title,
can_play=False,
can_expand=True,
children_media_class=MediaClass.VIDEO,
)
if not build_children:
return source
now = dt_util.now()
args = {
"data": data,
"start": now - timedelta(days=days),
"end": now,
"reserve": True,
"event_types": get_ufp_event(event_type),
}
camera: Camera | None = None
if camera_id != "all":
camera = data.api.bootstrap.cameras.get(camera_id)
args["camera_id"] = camera_id
events = await self._build_events(**args) # type: ignore[arg-type]
source.children = events
source.title = self._breadcrumb(
data,
title,
camera=camera,
event_type=event_type,
count=len(events),
)
return source
async def _build_month(
self,
data: ProtectData,
camera_id: str,
event_type: SimpleEventType,
start: date,
build_children: bool = False,
) -> BrowseMediaSource:
"""Build media source for selectors for a given month."""
base_id = f"{data.api.bootstrap.nvr.id}:browse:{camera_id}:{event_type.value}"
title = f"{start.strftime('%B %Y')}"
source = BrowseMediaSource(
domain=DOMAIN,
identifier=f"{base_id}:range:{start.year}:{start.month}",
media_class=MediaClass.DIRECTORY,
media_content_type=VIDEO_FORMAT,
title=title,
can_play=False,
can_expand=True,
children_media_class=MediaClass.VIDEO,
)
if not build_children:
return source
if data.api.bootstrap.recording_start is not None:
recording_start = data.api.bootstrap.recording_start.date()
start = max(recording_start, start)
recording_end = dt_util.now().date()
end = start.replace(month=start.month + 1) - timedelta(days=1)
end = min(recording_end, end)
children = [self._build_days(data, camera_id, event_type, start, is_all=True)]
while start <= end:
children.append(
self._build_days(data, camera_id, event_type, start, is_all=False)
)
start = start + timedelta(hours=24)
camera: Camera | None = None
if camera_id != "all":
camera = data.api.bootstrap.cameras.get(camera_id)
source.children = await asyncio.gather(*children)
source.title = self._breadcrumb(
data,
title,
camera=camera,
event_type=event_type,
)
return source
async def _build_days(
self,
data: ProtectData,
camera_id: str,
event_type: SimpleEventType,
start: date,
is_all: bool = True,
build_children: bool = False,
) -> BrowseMediaSource:
"""Build media source for events for a given day or whole month."""
base_id = f"{data.api.bootstrap.nvr.id}:browse:{camera_id}:{event_type.value}"
if is_all:
title = "Whole Month"
identifier = f"{base_id}:range:{start.year}:{start.month}:all"
else:
title = f"{start.strftime('%x')}"
identifier = f"{base_id}:range:{start.year}:{start.month}:{start.day}"
source = BrowseMediaSource(
domain=DOMAIN,
identifier=identifier,
media_class=MediaClass.DIRECTORY,
media_content_type=VIDEO_FORMAT,
title=title,
can_play=False,
can_expand=True,
children_media_class=MediaClass.VIDEO,
)
if not build_children:
return source
start_dt = datetime(
year=start.year,
month=start.month,
day=start.day,
hour=0,
minute=0,
second=0,
tzinfo=dt_util.get_default_time_zone(),
)
if is_all:
if start_dt.month < 12:
end_dt = start_dt.replace(month=start_dt.month + 1)
else:
end_dt = start_dt.replace(year=start_dt.year + 1, month=1)
else:
end_dt = start_dt + timedelta(hours=24)
args = {
"data": data,
"start": start_dt,
"end": end_dt,
"reserve": False,
"event_types": get_ufp_event(event_type),
}
camera: Camera | None = None
if camera_id != "all":
camera = data.api.bootstrap.cameras.get(camera_id)
args["camera_id"] = camera_id
title = f"{start.strftime('%B %Y')} > {title}"
events = await self._build_events(**args) # type: ignore[arg-type]
source.children = events
source.title = self._breadcrumb(
data,
title,
camera=camera,
event_type=event_type,
count=len(events),
)
return source
async def _build_events_type(
self,
data: ProtectData,
camera_id: str,
event_type: SimpleEventType,
build_children: bool = False,
) -> BrowseMediaSource:
"""Build folder media source for a selectors for a given event type."""
base_id = f"{data.api.bootstrap.nvr.id}:browse:{camera_id}:{event_type.value}"
title = EVENT_NAME_MAP[event_type].title()
source = BrowseMediaSource(
domain=DOMAIN,
identifier=base_id,
media_class=MediaClass.DIRECTORY,
media_content_type=VIDEO_FORMAT,
title=title,
can_play=False,
can_expand=True,
children_media_class=MediaClass.VIDEO,
)
if not build_children or data.api.bootstrap.recording_start is None:
return source
children = [
self._build_recent(data, camera_id, event_type, 1),
self._build_recent(data, camera_id, event_type, 7),
self._build_recent(data, camera_id, event_type, 30),
]
start, end = _get_month_start_end(data.api.bootstrap.recording_start)
while end > start:
children.append(self._build_month(data, camera_id, event_type, end.date()))
end = (end - timedelta(days=1)).replace(day=1)
camera: Camera | None = None
if camera_id != "all":
camera = data.api.bootstrap.cameras.get(camera_id)
source.children = await asyncio.gather(*children)
source.title = self._breadcrumb(data, title, camera=camera)
return source
async def _get_camera_thumbnail_url(self, camera: Camera) -> str | None:
"""Get camera thumbnail URL using the first available camera entity."""
if not camera.is_connected or camera.is_privacy_on:
return None
entity_id: str | None = None
entity_registry = self.async_get_registry()
for channel in camera.channels:
# do not use the package camera
if channel.id == 3:
continue
base_id = f"{camera.mac}_{channel.id}"
entity_id = entity_registry.async_get_entity_id(
Platform.CAMERA, DOMAIN, base_id
)
if entity_id is None:
entity_id = entity_registry.async_get_entity_id(
Platform.CAMERA, DOMAIN, f"{base_id}_insecure"
)
if entity_id:
# verify entity is available
entry = entity_registry.async_get(entity_id)
if entry and not entry.disabled:
break
entity_id = None
if entity_id is not None:
url = URL(CameraImageView.url.format(entity_id=entity_id))
return str(
url.update_query({"width": THUMBNAIL_WIDTH, "height": THUMBNAIL_HEIGHT})
)
return None
async def _build_camera(
self, data: ProtectData, camera_id: str, build_children: bool = False
) -> BrowseMediaSource:
"""Build media source for selectors for a UniFi Protect camera."""
name = "All Cameras"
is_doorbell = data.api.bootstrap.has_doorbell
has_smart = data.api.bootstrap.has_smart_detections
camera: Camera | None = None
if camera_id != "all":
camera = data.api.bootstrap.cameras.get(camera_id)
if camera is None:
raise BrowseError(f"Unknown Camera ID: {camera_id}")
name = camera.name or camera.market_name or camera.type
is_doorbell = camera.feature_flags.is_doorbell
has_smart = camera.feature_flags.has_smart_detect
thumbnail_url: str | None = None
if camera is not None:
thumbnail_url = await self._get_camera_thumbnail_url(camera)
source = BrowseMediaSource(
domain=DOMAIN,
identifier=f"{data.api.bootstrap.nvr.id}:browse:{camera_id}",
media_class=MediaClass.DIRECTORY,
media_content_type=VIDEO_FORMAT,
title=name,
can_play=False,
can_expand=True,
thumbnail=thumbnail_url,
children_media_class=MediaClass.VIDEO,
)
if not build_children:
return source
source.children = [
await self._build_events_type(data, camera_id, SimpleEventType.MOTION),
]
if is_doorbell:
source.children.insert(
0,
await self._build_events_type(data, camera_id, SimpleEventType.RING),
)
if has_smart:
source.children.append(
await self._build_events_type(data, camera_id, SimpleEventType.SMART)
)
source.children.append(
await self._build_events_type(data, camera_id, SimpleEventType.AUDIO)
)
if is_doorbell or has_smart:
source.children.insert(
0,
await self._build_events_type(data, camera_id, SimpleEventType.ALL),
)
source.title = self._breadcrumb(data, name)
return source
async def _build_cameras(self, data: ProtectData) -> list[BrowseMediaSource]:
"""Build media source for a single UniFi Protect NVR."""
cameras: list[BrowseMediaSource] = [await self._build_camera(data, "all")]
for camera in data.get_by_types({ModelType.CAMERA}):
camera = cast(Camera, camera)
if not camera.can_read_media(data.api.bootstrap.auth_user):
continue
cameras.append(await self._build_camera(data, camera.id))
return cameras
async def _build_console(self, data: ProtectData) -> BrowseMediaSource:
"""Build media source for a single UniFi Protect NVR."""
return BrowseMediaSource(
domain=DOMAIN,
identifier=f"{data.api.bootstrap.nvr.id}:browse",
media_class=MediaClass.DIRECTORY,
media_content_type=VIDEO_FORMAT,
title=data.api.bootstrap.nvr.name,
can_play=False,
can_expand=True,
children_media_class=MediaClass.VIDEO,
children=await self._build_cameras(data),
)
async def _build_sources(self) -> BrowseMediaSource:
"""Return all media source for all UniFi Protect NVRs."""
consoles: list[BrowseMediaSource] = []
for data_source in self.data_sources.values():
if not data_source.api.bootstrap.has_media:
continue
console_source = await self._build_console(data_source)
consoles.append(console_source)
if len(consoles) == 1:
return consoles[0]
return BrowseMediaSource(
domain=DOMAIN,
identifier=None,
media_class=MediaClass.DIRECTORY,
media_content_type=VIDEO_FORMAT,
title=self.name,
can_play=False,
can_expand=True,
children_media_class=MediaClass.VIDEO,
children=consoles,
)