This commit is contained in:
2018-01-20 01:05:31 +01:00
commit e038165eee
4 changed files with 235 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
__pycache__/
*.py[cod]

34
README.md Normal file
View 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)
-

View 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

View 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'