Skip to content

Commit

Permalink
WebSocket support (instead of MQTT)
Browse files Browse the repository at this point in the history
**Breaking change**

A new version rely on WebSocket instead of MQTT,
Permanent API Key must support WebSocket connection, otherwise, integration will not work,

**What's new**
- Switched from MQTT to WebSocket events
- Motion sensors rely on motion detection instead of object detection
- Object detection and Face identification are being represented as an event
  • Loading branch information
elad-bar committed May 31, 2021
1 parent cb5b4c4 commit 3057748
Show file tree
Hide file tree
Showing 12 changed files with 156 additions and 249 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## 1.1.0

**Breaking change**

A new version rely on WebSocket instead of MQTT,
Permanent API Key must support WebSocket connection, otherwise, integration will not work,

**What's new**
- Switched from MQTT to WebSocket events
- Motion sensors rely on motion detection instead of object detection
- Object detection and Face identification are being represented as an event

## 1.0.7

- Added code protection, logs and documentation for API Key usage [#3](https://github.com/elad-bar/ha-shinobi/issues/3)
Expand Down
45 changes: 21 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,19 @@ Integration with Shinobi Video NVR. Creates the following components:

[Changelog](https://github.com/elad-bar/ha-shinobi/blob/master/CHANGELOG.md)

## How to

#### Requirements
- Shinobi Video Server available with credentials - Dashboard user with API Key
- MQTT Integration is optional - it will allow listening to Shinobi Video event
- Shinobi Video Server
- Dashboard user with API Key (with all permissions)
- JPEG API enabled
- Optional: Motion detection - [How to use Motion Detection](https://hub.shinobi.video/articles/view/LKdcgcgWy9RJfUh)

## How to

#### How to generate permanent API Key:
#### Generate permanent API Key:
In Shinobi Video Dashboard, click your username in the top left.
A menu will appear, click API.
Add new token - IP: 0.0.0.0, Permissions - Select all

#### Shinobi Video links:
- [Using MQTT to receive and trigger events](https://hub.shinobi.video/articles/view/xEMps3O4y4VEaYk)
- [How to use Motion Detection](https://hub.shinobi.video/articles/view/LKdcgcgWy9RJfUh)

#### Shinobi Video DeepStack Plugins:
[DeepStack-Face](https://github.com/elad-bar/shinobi-deepstack-face)
[DeepStack-Object](https://github.com/elad-bar/shinobi-deepstack-object)

#### Installations via HACS
- In HACS, look for "Shinobi Video NVR" and install
- In Configuration --> Integrations - Add Shinobi Video NVR
Expand Down Expand Up @@ -81,18 +75,9 @@ Please remove the integration and re-add it to make it work again.
## Components

#### Binary Sensors
Binary sensor are relying on MQTT, you will need to set up in Shinobi Video Server MQTT plugin and configure each of the monitors to trigger MQTT message.

Each binary sensor will have the name pattern - {Integration Title} {Camera Name} {Sound / Motion},
Once triggered, the following details will be added to the attributes of the binary sensor:

Attributes | Description |
--- | --- |
name | Event name - Yolo / Tensorflow / DeepStack-Object / audio
reason | Event details - object / soundChange
tags | relevant for motion only with object detection, will represent the detected object


###### Audio
Represents whether the camera is triggered for noise or not

Expand All @@ -111,6 +96,18 @@ FPS | -

## Events

#### Face Recognition
#### Face Recognition - shinobi/face
Supported by [DeepStack-Face](https://github.com/elad-bar/shinobi-deepstack-face) plugin only,
Will publish event name `shinobi/face_recognition`, payload will be the same as in the MQTT message

Payload:
```json

```

#### Object Detection - shinobi/object
Supported by [DeepStack-Face](https://github.com/elad-bar/shinobi-deepstack-object) plugin only,

Payload:
```json

```
61 changes: 26 additions & 35 deletions custom_components/shinobi/api/shinobi_websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,14 @@


class ShinobiWebSocket:
is_logged_in: bool
is_connected: bool
api: ShinobiApi
session: Optional[ClientSession]
hass: HomeAssistant
config_manager: ConfigManager
event_manager: EventManager
base_url: Optional[str]
is_aborted: bool

def __init__(self,
hass: HomeAssistant,
Expand All @@ -49,9 +50,9 @@ def __init__(self,
self._session = None
self._ws = None
self._pending_payloads = []
self.shutting_down = False
self.is_logged_in = False
self.is_connected = False
self.api = api
self.is_aborted = False

self._handlers = {
"log": self.handle_log,
Expand All @@ -69,16 +70,15 @@ async def initialize(self):
_LOGGER.debug("Initializing WS connection")

try:
self.is_logged_in = False
self.shutting_down = False

cd = self.config_data
endpoint = "/socket.io/?EIO=3&transport=websocket"

self.base_url = (
f"{cd.ws_protocol}://{cd.host}:{cd.port}{cd.path}{endpoint}"
f"{cd.ws_protocol}://{cd.host}:{cd.port}{cd.path}{SHINOBI_WS_ENDPOINT}"
)

if self.is_connected:
await self.close()

if self.hass is None:
self._session = aiohttp.client.ClientSession()
else:
Expand All @@ -96,9 +96,10 @@ async def initialize(self):
timeout=SCAN_INTERVAL_WS_TIMEOUT,
) as ws:

self.is_logged_in = True
self.is_connected = True

self._ws = ws

await self.listen()

except Exception as ex:
Expand All @@ -107,7 +108,7 @@ async def initialize(self):
else:
_LOGGER.warning(f"Failed to connect Shinobi Video WS, Error: {ex}")

self.is_logged_in = False
self.is_connected = False

_LOGGER.info("WS Connection terminated")

Expand Down Expand Up @@ -135,7 +136,7 @@ async def listen(self):
if (
not continue_to_next
or not self.is_initialized
or not self.is_logged_in
or not self.is_connected
):
break

Expand Down Expand Up @@ -166,17 +167,17 @@ def handle_next_message(self, msg):
return result

async def parse_message(self, message: str):
if message.startswith("0"):
if message.startswith(SHINOBI_WS_CONNECTION_ESTABLISHED_MESSAGE):
_LOGGER.debug(f"Connected, Message: {message[1:]}")

elif message.startswith("3"):
elif message.startswith(SHINOBI_WS_PONG_MESSAGE):
_LOGGER.debug(f"Pong received")

elif message.startswith("40"):
elif message.startswith(SHINOBI_WS_CONNECTION_READY_MESSAGE):
_LOGGER.debug(f"Back channel connected")
await self.send_connect_message()

elif message.startswith("42"):
elif message.startswith(SHINOBI_WS_ACTION_MESSAGE):
json_str = message[2:]
payload = json.loads(json_str)
await self.parse_payload(payload)
Expand Down Expand Up @@ -222,7 +223,7 @@ async def handle_detector_trigger(self, data):
monitor_id = data.get("id")
group_id = data.get("ke")

topic = f"{MQTT_ALL_TOPIC}/{group_id}/{monitor_id}/trigger"
topic = f"{group_id}/{monitor_id}"

self.event_manager.message_received(topic, data)

Expand All @@ -240,16 +241,12 @@ async def send_connect_message(self):
json_str = json.dumps(message_data)
message = f"42{json_str}"

if self.is_logged_in:
await self._ws.send_str(message)
await self.send(message)

async def send_ping_message(self):
_LOGGER.debug("Pinging")

message = f"2"

if self.is_logged_in:
await self._ws.send_str(message)
await self.send(SHINOBI_WS_PING_MESSAGE)

async def send_connect_monitor(self, monitor: CameraData):
message_data = [
Expand All @@ -267,29 +264,23 @@ async def send_connect_monitor(self, monitor: CameraData):
json_str = json.dumps(message_data)
message = f"42{json_str}"

_LOGGER.info(f"Start listen monitor #{monitor.monitorId}, Data: {message}")
await self.send(message)

await self._ws.send_str(message)
async def send(self, message: str):
_LOGGER.debug(f"Sending message, Data: {message}, Connected: {self.is_connected}")

def disconnect(self):
self.is_logged_in = False
if self.is_connected:
await self._ws.send_str(message)

async def close(self):
_LOGGER.info("Closing connection to WS")

self.is_logged_in = False
self.is_aborted = True
self.is_connected = False

if self._ws is not None:
await self._ws.close()

await asyncio.sleep(DISCONNECT_INTERVAL)

self._ws = None

@staticmethod
def get_keep_alive_data():
content = "2"

_LOGGER.debug(f"Keep alive data to be sent: {content}")

return content
4 changes: 2 additions & 2 deletions custom_components/shinobi/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

_LOGGER = logging.getLogger(__name__)

DEPENDENCIES = [DOMAIN, "mqtt"]
DEPENDENCIES = [DOMAIN]

CURRENT_DOMAIN = DOMAIN_BINARY_SENSOR

Expand All @@ -40,7 +40,7 @@ async def async_unload_entry(hass, config_entry):


class BaseBinarySensor(BinarySensorEntity, BaseEntity):
"""Representation a binary sensor that is updated by MQTT."""
"""Representation a binary sensor that is updated."""

@property
def should_poll(self):
Expand Down
2 changes: 1 addition & 1 deletion custom_components/shinobi/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def _immediate_update(self, previous_state: bool):
super()._immediate_update(previous_state)

async def async_added_to_hass_local(self):
"""Subscribe MQTT events."""
"""Subscribe events."""
_LOGGER.info(f"Added new {self.name}")

@property
Expand Down
18 changes: 11 additions & 7 deletions custom_components/shinobi/helpers/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

from homeassistant.components.binary_sensor import DOMAIN as DOMAIN_BINARY_SENSOR
from homeassistant.components.camera import DOMAIN as DOMAIN_CAMERA
from homeassistant.components.mqtt import DATA_MQTT
from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH
from homeassistant.const import (
CONF_HOST,
Expand Down Expand Up @@ -55,6 +54,14 @@

SHINOBI_AUTH_ERROR = "Authorization required"

SHINOBI_WS_ENDPOINT = "/socket.io/?EIO=3&transport=websocket"

SHINOBI_WS_CONNECTION_ESTABLISHED_MESSAGE = "0"
SHINOBI_WS_PING_MESSAGE = "2"
SHINOBI_WS_PONG_MESSAGE = "3"
SHINOBI_WS_CONNECTION_READY_MESSAGE = "40"
SHINOBI_WS_ACTION_MESSAGE = "42"

AUTHENTICATION_BASIC = "basic"

NOTIFICATION_ID = f"{DOMAIN}_notification"
Expand All @@ -75,8 +82,6 @@

SENSOR_MAIN_NAME = "Main"

MQTT_ALL_TOPIC = "shinobi"
DEFAULT_QOS = 0
MAX_MSG_SIZE = 0
DISCONNECT_INTERVAL = 5

Expand Down Expand Up @@ -228,16 +233,15 @@
SENSOR_TYPE_MOTION = "motion"
SENSOR_TYPE_SOUND = "sound"

EVENT_FACE_RECOGNITION = "shinobi/face_recognition"
SHINOBI_EVENT = "shinobi/"

REASON_MOTION = "motion"
REASON_SOUND = "soundChange"
REASON_FACE = "face"

PLUG_SENSOR_TYPE = {
REASON_MOTION: SENSOR_TYPE_MOTION,
REASON_SOUND: SENSOR_TYPE_SOUND,
REASON_FACE: EVENT_FACE_RECOGNITION
REASON_SOUND: SENSOR_TYPE_SOUND
}

SENSOR_AUTO_OFF_INTERVAL = {
Expand All @@ -249,4 +253,4 @@
TRIGGER_STATE: STATE_OFF
}

BINARY_SENSOR_ATTRIBUTES = [TRIGGER_NAME, TRIGGER_DETAILS_REASON, TRIGGER_TAGS]
BINARY_SENSOR_ATTRIBUTES = []
6 changes: 3 additions & 3 deletions custom_components/shinobi/managers/entity_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,9 @@ def create_components(self):
for camera in available_camera:
self.generate_camera_component(camera)

current_mqtt_binary_sensors = self.generate_camera_binary_sensors(camera)
current_binary_sensors = self.generate_camera_binary_sensors(camera)

binary_sensors.extend(current_mqtt_binary_sensors)
binary_sensors.extend(current_binary_sensors)

def update(self):
self.hass.async_create_task(self._async_update())
Expand Down Expand Up @@ -241,7 +241,7 @@ def get_camera_entity(self, camera: CameraData, sensor_type) -> EntityData:
entity_name = f"{self.integration_title} {camera.name} {sensor_type.capitalize()}"
unique_id = f"{DOMAIN}-{DOMAIN_BINARY_SENSOR}-{entity_name}"

state_topic = f"{MQTT_ALL_TOPIC}/{self.api.group_id}/{camera.monitorId}/trigger"
state_topic = f"{self.api.group_id}/{camera.monitorId}"

state = STATE_OFF
event_state = TRIGGER_DEFAULT
Expand Down
Loading

0 comments on commit 3057748

Please sign in to comment.