init
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
34
README.md
Normal file
34
README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Home Assistant custom components
|
||||
|
||||
[Home Assistant](https://home-assistant.io/)
|
||||
|
||||
## Shinobi
|
||||
|
||||
[Shinobi](https://shinobi.video/) is an open source cctv solution.
|
||||
|
||||
To enable this component, first copy `custom_components/` inside your Home Assistant config directory and add the shinobi component and platform, for example in `configuration.yaml`. See https://github.com/moeiscool/Shinobi/wiki/API-Access on how to get your `api_key` and `group_key`. The `ssl` param is optional and defaults to `false`
|
||||
|
||||
```
|
||||
…
|
||||
shinobi:
|
||||
host: <your-cams_ip or hostname>
|
||||
api_key: <api_key>
|
||||
group_key: <group_key>
|
||||
ssl: false
|
||||
|
||||
camera:
|
||||
- platform: shinobi
|
||||
…
|
||||
```
|
||||
|
||||
At the moment, all configured and activated cameras (in state, `Record` or `Watch-only`, see https://shinobi.video/docs/settings) are fetched and added to Home Assistant as `MjpegCamera`. Please note that I couldn't get the actual Mjpeg stream to work in general using other software so this is also not working in HA for now. The preview with still images is working perfectly though.
|
||||
|
||||
No additional packages have to be installed.
|
||||
|
||||
## TODO
|
||||
|
||||
- get the (mjpeg-) stream to work, also figure out which camera platform is working best for this (Shinobi allows to switch between different stream output configs)
|
||||
- allow filtering of which monitors/cams to add to HA
|
||||
- add movement detection as (binary?) sensors
|
||||
- add ptz control (as switches? I have not yet seen a good solution in HA, anybody who can help please drop some lines)
|
||||
- …
|
||||
73
custom_components/camera/shinobi.py
Normal file
73
custom_components/camera/shinobi.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import homeassistant.loader as loader
|
||||
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.components.camera.mjpeg import (
|
||||
CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ['shinobi']
|
||||
DOMAIN = 'shinobi'
|
||||
|
||||
shinobi = loader.get_component('shinobi')
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
monitors = shinobi.get_all_started_monitors()
|
||||
cameras = []
|
||||
|
||||
_LOGGER.debug(config)
|
||||
|
||||
if not monitors:
|
||||
_LOGGER.warning('No active monitors found')
|
||||
return
|
||||
|
||||
for monitor in monitors:
|
||||
device_info = {
|
||||
CONF_NAME: monitor['name'],
|
||||
CONF_MJPEG_URL: shinobi.monitor_stream_url(monitor['mid']),
|
||||
CONF_STILL_IMAGE_URL: shinobi.monitor_still_url(monitor['mid'])
|
||||
}
|
||||
cameras.append(ShinobiCamera(hass, device_info, monitor))
|
||||
|
||||
if not cameras:
|
||||
_LOGGER.warning('No active cameras found')
|
||||
return
|
||||
|
||||
async_add_devices(cameras)
|
||||
|
||||
|
||||
class ShinobiCamera(MjpegCamera):
|
||||
"""Representation of a Shinobi Monitor Stream."""
|
||||
|
||||
def __init__(self, hass, device_info, monitor):
|
||||
"""Initialize as a subclass of MjpegCamera."""
|
||||
super().__init__(hass, device_info)
|
||||
self._monitor_id = monitor['mid']
|
||||
self._is_recording = None
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Update the recording state periodically."""
|
||||
return True
|
||||
|
||||
def update(self):
|
||||
"""Update our recording state from the Shinobi API."""
|
||||
_LOGGER.debug('Updating camera state for monitor {}'.format(self._monitor_id))
|
||||
|
||||
status_response = shinobi.get_monitor_state(self._monitor_id)
|
||||
|
||||
if not status_response:
|
||||
_LOGGER.warning('Could not get status for monitor {}'.format(self._monitor_id))
|
||||
return
|
||||
_LOGGER.debug('Monitor {} is in status {}'.format(self._monitor_id, status_response['mode']))
|
||||
self._is_recording = status_response.get('status') == shinobi.SHINOBI_CAM_RECORDING
|
||||
|
||||
@property
|
||||
def is_recording(self):
|
||||
"""Return whether the monitor is in recording mode."""
|
||||
return self._is_recording
|
||||
|
||||
125
custom_components/shinobi.py
Normal file
125
custom_components/shinobi.py
Normal file
@@ -0,0 +1,125 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
import voluptuous as vol
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, CONF_API_KEY, CONF_SSL)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = 'shinobi'
|
||||
DEFAULT_TIMEOUT = 10
|
||||
DEFAULT_SSL = False
|
||||
|
||||
SHINOBI_CAM_DISABLED = 'stop'
|
||||
SHINOBI_CAM_WATCHING = 'start'
|
||||
SHINOBI_CAM_RECORDING = 'record'
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_API_KEY): cv.string,
|
||||
vol.Required('group_key'): cv.string,
|
||||
vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
|
||||
vol.Optional('monitors', default=[]): cv.ensure_list
|
||||
})
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
|
||||
global SHINOBI
|
||||
SHINOBI = {}
|
||||
|
||||
conf = config[DOMAIN]
|
||||
if conf[CONF_SSL]:
|
||||
schema = 'https'
|
||||
else:
|
||||
schema = 'http'
|
||||
|
||||
server_origin = '{}://{}'.format(schema, conf[CONF_HOST])
|
||||
|
||||
SHINOBI['server_origin'] = server_origin
|
||||
SHINOBI['api_key'] = conf.get(CONF_API_KEY, None)
|
||||
SHINOBI['group_key'] = conf.get('group_key', None)
|
||||
SHINOBI['monitors'] = conf.get('monitors')
|
||||
|
||||
hass.data[DOMAIN] = SHINOBI
|
||||
|
||||
# unfortunately, the api does not return error codes. The only way to check if the credentials are working is to check the response of an (arbitrary) request (e.g. get all monitors)
|
||||
try:
|
||||
check_creds_response = get_all_started_monitors()
|
||||
except:
|
||||
return False
|
||||
|
||||
# expected response contains a list with activated monitors (or an empty list)
|
||||
if isinstance(check_creds_response, list):
|
||||
return True
|
||||
else:
|
||||
# response payload contains ok: false if authentication has not been successful
|
||||
if not check_creds_response.ok:
|
||||
_LOGGER.error('Wrong api_key or non existing group_key provided')
|
||||
else:
|
||||
_LOGGER.error('Unknown error occured while retrieving monitors')
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _shinobi_request(api_path, method='get', data=None):
|
||||
"""Perform a Shinobi request."""
|
||||
|
||||
api_base = SHINOBI['server_origin'] + '/' + SHINOBI['api_key']
|
||||
|
||||
req = requests.get(api_base + api_path, timeout=DEFAULT_TIMEOUT)
|
||||
|
||||
# TODO some more error handling here?
|
||||
try:
|
||||
response = req.json()
|
||||
except ValueError:
|
||||
_LOGGER.exception('JSON decode exception caught while attempting to '
|
||||
'decode "{}"', req.text)
|
||||
return response
|
||||
|
||||
|
||||
def get_all_started_monitors():
|
||||
"""Get all started monitors from the Shinobi API."""
|
||||
_LOGGER.debug('Sending request to Shinobi to get all started monitors')
|
||||
|
||||
get_monitors_path = '/smonitor/' + SHINOBI['group_key']
|
||||
|
||||
response = _shinobi_request(get_monitors_path)
|
||||
|
||||
# TODO is it necessary to save monitors globally in SHINOBI?
|
||||
SHINOBI['monitors'] = response
|
||||
|
||||
_LOGGER.debug('Shinobi returned {} monitors: {}'.format(str(len(SHINOBI['monitors'])), str([monitor['name'] for monitor in SHINOBI['monitors']])))
|
||||
|
||||
return SHINOBI['monitors']
|
||||
|
||||
|
||||
def get_monitor_state(monitor_id):
|
||||
"""Get the state of a monitor."""
|
||||
api_path = '/monitor/{}/{}'.format(SHINOBI['group_key'], monitor_id)
|
||||
return _shinobi_request(api_path)
|
||||
|
||||
|
||||
def set_monitor_state(monitor_id, mode):
|
||||
"""Get the state of a monitor."""
|
||||
if not (mode == SHINOBI_CAM_DISABLED or mode == SHINOBI_CAM_WATCHING or mode == SHINOBI_CAM_RECORDING):
|
||||
raise ValueError('monitor state must be either {}, {} or {}'.format(SHINOBI_CAM_DISABLED, SHINOBI_CAM_WATCHING, SHINOBI_CAM_RECORDING))
|
||||
api_path = '/monitor/{}/{}'.format(SHINOBI['group_key'], monitor_id)
|
||||
return _shinobi_request(api_path)
|
||||
|
||||
|
||||
def monitor_stream_url(monitor_id):
|
||||
"""Get the stream url. See https://shinobi.video/docs/api#content-embedding-streams for more information."""
|
||||
return SHINOBI['server_origin'] + '/' + SHINOBI['api_key'] + '/embed/' + SHINOBI['group_key'] + '/' + monitor_id
|
||||
|
||||
|
||||
def monitor_still_url(monitor_id):
|
||||
"""Get the url of still jpg images. Snapshots must be enabled in cam settings, see https://shinobi.video/docs/api#content-get-streams"""
|
||||
return SHINOBI['server_origin'] + '/' + SHINOBI['api_key'] + '/jpeg/' + SHINOBI['group_key'] + '/' + monitor_id + '/s.jpg'
|
||||
Reference in New Issue
Block a user