Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add camera support #336

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions hatasmota/camera.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Tasmota camera."""

from __future__ import annotations

from collections.abc import Awaitable
from dataclasses import dataclass
import logging
from typing import Any

from aiohttp import ClientResponse, ClientSession

from .const import CONF_DEEP_SLEEP, CONF_IP, CONF_MAC
from .entity import (
TasmotaAvailability,
TasmotaAvailabilityConfig,
TasmotaEntity,
TasmotaEntityConfig,
)
from .mqtt import ReceiveMessage
from .utils import (
config_get_state_offline,
config_get_state_online,
get_topic_command,
get_topic_command_state,
get_topic_stat_result,
get_topic_tele_sensor,
get_topic_tele_state,
get_topic_tele_will,
get_value_by_path,
)

_LOGGER = logging.getLogger(__name__)


@dataclass(frozen=True, kw_only=True)
class TasmotaCameraConfig(TasmotaAvailabilityConfig, TasmotaEntityConfig):
"""Tasmota camera configuation."""

idx: int
Copy link
Owner

@emontnemery emontnemery Feb 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does tasmota support more than a single camera? If not, can we skip this, it seems to be hardcoded to 0 below?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.


command_topic: str
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem to be used

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was added earlier to support the configuring the camera parameters. But since it is not used, I have removed it.
The configuration is done easily via the blueprint mentioned earlier.

result_topic: str
state_topic: str
sensor_topic: str
ip_address: str

@classmethod
def from_discovery_message(cls, config: dict, platform: str) -> TasmotaCameraConfig:
"""Instantiate from discovery message."""
return cls(
endpoint="camera",
idx=0,
friendly_name=None,
mac=config[CONF_MAC],
platform=platform,
poll_payload="",
poll_topic=get_topic_command_state(config),
availability_topic=get_topic_tele_will(config),
availability_offline=config_get_state_offline(config),
availability_online=config_get_state_online(config),
deep_sleep_enabled=config[CONF_DEEP_SLEEP],
command_topic=get_topic_command(config),
result_topic=get_topic_stat_result(config),
state_topic=get_topic_tele_state(config),
sensor_topic=get_topic_tele_sensor(config),
ip_address=config[CONF_IP],
)


class TasmotaCamera(TasmotaAvailability, TasmotaEntity):
"""Representation of a Tasmota camera."""

_cfg: TasmotaCameraConfig

def __init__(self, **kwds: Any):
"""Initialize."""
self._sub_state: dict | None = None
super().__init__(**kwds)

async def subscribe_topics(self) -> None:
"""Subscribe to topics."""

def state_message_received(msg: ReceiveMessage) -> None:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this used for? The core PR doesn't seem to use it?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was part of the original version of the core PR. It was removed during the review.
I have updated this PR to reflect that.

"""Handle new MQTT state messages."""
if not self._on_state_callback:
return

if msg.topic == self._cfg.sensor_topic:
if state := get_value_by_path(msg.payload, ["CAMERA"]):
self._on_state_callback(state)

availability_topics = self.get_availability_topics()
topics = {
"result_topic": {
"event_loop_safe": True,
"topic": self._cfg.result_topic,
"msg_callback": state_message_received,
},
"state_topic": {
"event_loop_safe": True,
"topic": self._cfg.state_topic,
"msg_callback": state_message_received,
},
"sensor_topic": {
"event_loop_safe": True,
"topic": self._cfg.sensor_topic,
"msg_callback": state_message_received,
},
}
topics = {**topics, **availability_topics}

self._sub_state = await self._mqtt_client.subscribe(
self._sub_state,
topics,
)

async def unsubscribe_topics(self) -> None:
"""Unsubscribe to all MQTT topics."""
self._sub_state = await self._mqtt_client.unsubscribe(self._sub_state)

def get_still_image_stream(
self, websession: ClientSession
) -> Awaitable[ClientResponse]:
"""Get the io stream to read the static image."""
still_image_url = f"http://{self._cfg.ip_address}/snapshot.jpg"
return websession.get(still_image_url)

def get_mjpeg_stream(self, websession: ClientSession) -> Awaitable[ClientResponse]:
"""Get the io stream to read the mjpeg stream."""
mjpeg_url = f"http://{self._cfg.ip_address}:81/cam.mjpeg"
return websession.get(mjpeg_url)
1 change: 1 addition & 0 deletions hatasmota/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
CONF_FRIENDLYNAME: Final = "fn"
CONF_FULLTOPIC: Final = "ft"
CONF_IFAN: Final = "if"
CONF_CAM: Final = "cam"
CONF_IP: Final = "ip"
CONF_HOSTNAME: Final = "hn"
CONF_MAC: Final = "mac"
Expand Down
22 changes: 22 additions & 0 deletions hatasmota/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@

from . import config_validation as cv
from .button import TasmotaButtonTrigger, TasmotaButtonTriggerConfig
from .camera import TasmotaCamera, TasmotaCameraConfig
from .const import (
CONF_BATTERY,
CONF_BUTTON,
CONF_CAM,
CONF_DEEP_SLEEP,
CONF_DEVICENAME,
CONF_FRIENDLYNAME,
Expand Down Expand Up @@ -121,6 +123,7 @@
CONF_FULLTOPIC: cv.string,
CONF_HOSTNAME: cv.string,
vol.Optional(CONF_IFAN, default=0): cv.bit, # Added in Tasmota 9.0.0.4
vol.Optional(CONF_CAM, default=0): cv.bit,
CONF_IP: cv.string,
CONF_LIGHT_SUBTYPE: cv.positive_int,
CONF_LINK_RGB_CT: cv.bit,
Expand Down Expand Up @@ -392,6 +395,21 @@ def get_fan_entities(
return fan_entities


def get_camera_entities(
discovery_msg: dict,
) -> list[tuple[TasmotaCameraConfig | None, DiscoveryHashType]]:
"""Generate camera configuration."""
camera_entities: list[tuple[TasmotaCameraConfig | None, DiscoveryHashType]] = []

entity = None
discovery_hash = (discovery_msg[CONF_MAC], "cam", "cam", 0)
if CONF_CAM in discovery_msg and discovery_msg[CONF_CAM]:
entity = TasmotaCameraConfig.from_discovery_message(discovery_msg, "camera")
camera_entities.append((entity, discovery_hash))

return camera_entities


Copy link
Owner

@emontnemery emontnemery Feb 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move this to before get_cover_entities to keep the functions mostly sorted alphabetically

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

def get_switch_entities(
discovery_msg: dict,
) -> list[tuple[TasmotaRelayConfig | None, DiscoveryHashType]]:
Expand Down Expand Up @@ -486,6 +504,8 @@ def get_entities_for_platform(
entities.extend(get_status_sensor_entities(discovery_msg))
elif platform == "switch":
entities.extend(get_switch_entities(discovery_msg))
elif platform == "camera":
entities.extend(get_camera_entities(discovery_msg))
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move this before cover to keep the alphabetical sorting

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

return entities


Expand Down Expand Up @@ -514,6 +534,8 @@ def get_entity(
return TasmotaStatusSensor(config=config, mqtt_client=mqtt_client)
if platform == "switch":
return TasmotaRelay(config=config, mqtt_client=mqtt_client)
if platform == "camera":
return TasmotaCamera(config=config, mqtt_client=mqtt_client)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move this before cover to keep the alphabetical sorting

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

return None


Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
voluptuous>=0.12.0
aiohttp>=3.11.12