225 lines
6.3 KiB
Go
225 lines
6.3 KiB
Go
// Copyright 2024 Joshua Rich <joshua.rich@gmail.com>.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
//revive:disable:unused-receiver
|
|
package media
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"slices"
|
|
|
|
"github.com/eclipse/paho.golang/paho"
|
|
|
|
"github.com/blackjack/webcam"
|
|
mqtthass "github.com/joshuar/go-hass-anything/v12/pkg/hass"
|
|
mqttapi "github.com/joshuar/go-hass-anything/v12/pkg/mqtt"
|
|
|
|
"github.com/joshuar/go-hass-agent/internal/preferences"
|
|
)
|
|
|
|
const (
|
|
startIcon = "mdi:play"
|
|
stopIcon = "mdi:stop"
|
|
statusIcon = "mdi:webcam"
|
|
|
|
startedState = "Recording"
|
|
stoppedState = "Not Recording"
|
|
|
|
defaultCameraDevice = "/dev/video0"
|
|
defaultHeight uint32 = 640
|
|
defaultWidth uint32 = 480
|
|
)
|
|
|
|
var defaultPreferredFmts = []string{"Motion-JPEG"}
|
|
|
|
// CameraWorker represents all of the entities that make up a camera. This
|
|
// includes the entity for showing images, as well as button entities for
|
|
// start/stop commands and a sensor entity showing the recording status.
|
|
type CameraWorker struct {
|
|
Images *mqtthass.CameraEntity
|
|
StartButton *mqtthass.ButtonEntity
|
|
StopButton *mqtthass.ButtonEntity
|
|
Status *mqtthass.SensorEntity
|
|
camera *webcam.Webcam
|
|
state string
|
|
prefs CameraWorkerPrefs
|
|
}
|
|
|
|
// CameraWorkerPrefs are the preferences a user can set for the CameraWorker.
|
|
type CameraWorkerPrefs struct {
|
|
CameraDevice string `toml:"camera_device" comment:"The camera device to control. Defaults to /dev/video0."`
|
|
CameraFormats []string `toml:"camera_formats" comment:"Preferred camera video formats. Defaults to Motion-JPEG."`
|
|
Height uint32 `toml:"camera_video_height" comment:"Height (in pixels) of the camera image. Defaults to 640px."`
|
|
Width uint32 `toml:"camera_video_width" comment:"With (in pixels) of the camera image. Defaults to 480px."`
|
|
preferences.CommonWorkerPrefs
|
|
}
|
|
|
|
func (w *CameraWorker) PreferencesID() string {
|
|
return controlsPreferencesID
|
|
}
|
|
|
|
func (w *CameraWorker) DefaultPreferences() CameraWorkerPrefs {
|
|
return CameraWorkerPrefs{
|
|
CameraDevice: defaultCameraDevice,
|
|
Width: defaultWidth,
|
|
Height: defaultHeight,
|
|
CameraFormats: defaultPreferredFmts,
|
|
}
|
|
}
|
|
|
|
func (w *CameraWorker) Disabled() bool {
|
|
return w.prefs.Disabled
|
|
}
|
|
|
|
// NewCameraControl is called by the OS controller to provide the entities for a camera.
|
|
func NewCameraControl(ctx context.Context, msgCh chan *mqttapi.Msg, mqttDevice *mqtthass.Device) (*CameraWorker, error) {
|
|
var err error
|
|
|
|
worker := &CameraWorker{}
|
|
|
|
worker.prefs, err = preferences.LoadWorkerPreferences(ctx, worker)
|
|
if err != nil {
|
|
return worker, fmt.Errorf("could not load preferences: %w", err)
|
|
}
|
|
|
|
worker.Images = mqtthass.NewCameraEntity().
|
|
WithDetails(
|
|
mqtthass.App(preferences.AppName),
|
|
mqtthass.Name("Webcam"),
|
|
mqtthass.ID(mqttDevice.Name+"_camera"),
|
|
mqtthass.OriginInfo(preferences.MQTTOrigin()),
|
|
mqtthass.DeviceInfo(mqttDevice),
|
|
)
|
|
|
|
worker.StartButton = mqtthass.NewButtonEntity().
|
|
WithDetails(
|
|
mqtthass.App(preferences.AppName),
|
|
mqtthass.Name("Start Webcam"),
|
|
mqtthass.ID(mqttDevice.Name+"_start_camera"),
|
|
mqtthass.OriginInfo(preferences.MQTTOrigin()),
|
|
mqtthass.DeviceInfo(mqttDevice),
|
|
mqtthass.Icon(startIcon),
|
|
).WithCommand(
|
|
mqtthass.CommandCallback(func(_ *paho.Publish) {
|
|
var err error
|
|
// Open the camera device.
|
|
worker.camera, err = worker.openCamera()
|
|
if err != nil {
|
|
slog.Error("Could not open camera device.",
|
|
slog.Any("error", err))
|
|
return
|
|
}
|
|
|
|
slog.Info("Start recording webcam.")
|
|
|
|
worker.state = startedState
|
|
|
|
go publishImages(worker.camera, worker.Images.Topic, msgCh)
|
|
msgCh <- mqttapi.NewMsg(worker.Status.StateTopic, []byte(worker.state))
|
|
}))
|
|
|
|
worker.StopButton = mqtthass.NewButtonEntity().
|
|
WithDetails(
|
|
mqtthass.App(preferences.AppName),
|
|
mqtthass.Name("Stop Webcam"),
|
|
mqtthass.ID(mqttDevice.Name+"_stop_camera"),
|
|
mqtthass.OriginInfo(preferences.MQTTOrigin()),
|
|
mqtthass.DeviceInfo(mqttDevice),
|
|
mqtthass.Icon(stopIcon),
|
|
).WithCommand(
|
|
mqtthass.CommandCallback(func(_ *paho.Publish) {
|
|
if err := worker.camera.StopStreaming(); err != nil {
|
|
slog.Error("Stop streaming failed.", slog.Any("error", err))
|
|
}
|
|
|
|
if err := worker.camera.Close(); err != nil {
|
|
slog.Error("Close camera failed.", slog.Any("error", err))
|
|
}
|
|
|
|
worker.state = stoppedState
|
|
msgCh <- mqttapi.NewMsg(worker.Status.StateTopic, []byte(worker.state))
|
|
|
|
slog.Info("Stop recording webcam.")
|
|
}),
|
|
)
|
|
|
|
worker.Status = mqtthass.NewSensorEntity().
|
|
WithDetails(
|
|
mqtthass.App(preferences.AppName),
|
|
mqtthass.Name("Webcam Status"),
|
|
mqtthass.ID(mqttDevice.Name+"_camera_status"),
|
|
mqtthass.OriginInfo(preferences.MQTTOrigin()),
|
|
mqtthass.DeviceInfo(mqttDevice),
|
|
mqtthass.Icon(statusIcon),
|
|
).
|
|
WithState(
|
|
mqtthass.StateCallback(func(_ ...any) (json.RawMessage, error) {
|
|
return json.RawMessage(worker.state), nil
|
|
}),
|
|
)
|
|
|
|
go func() {
|
|
msgCh <- mqttapi.NewMsg(worker.Status.StateTopic, []byte(stoppedState))
|
|
}()
|
|
|
|
return worker, nil
|
|
}
|
|
|
|
// openCamera opens the camera device and ensures that it has a preferred image
|
|
// format, framerate and dimensions.
|
|
func (w *CameraWorker) openCamera() (*webcam.Webcam, error) {
|
|
cam, err := webcam.Open(w.prefs.CameraDevice)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not open camera %s: %w", w.prefs.CameraDevice, err)
|
|
}
|
|
|
|
// select pixel format
|
|
var preferredFormat webcam.PixelFormat
|
|
|
|
for format, desc := range cam.GetSupportedFormats() {
|
|
if slices.Contains(w.prefs.CameraFormats, desc) {
|
|
preferredFormat = format
|
|
break
|
|
}
|
|
}
|
|
|
|
if preferredFormat == 0 {
|
|
return nil, errors.New("could not determine an appropriate format")
|
|
}
|
|
|
|
_, _, _, err = cam.SetImageFormat(preferredFormat, w.prefs.Width, w.prefs.Height)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not set camera parameters: %w", err)
|
|
}
|
|
|
|
return cam, nil
|
|
}
|
|
|
|
// publishImages loops over the received frames from the camera and wraps them
|
|
// as a MQTT message to be sent back on the bus.
|
|
func publishImages(cam *webcam.Webcam, topic string, msgCh chan *mqttapi.Msg) {
|
|
if err := cam.StartStreaming(); err != nil {
|
|
slog.Error("Could not start recording", slog.Any("error", err))
|
|
|
|
return
|
|
}
|
|
|
|
for {
|
|
err := cam.WaitForFrame(uint32(5))
|
|
if err != nil && errors.Is(err, &webcam.Timeout{}) {
|
|
continue
|
|
}
|
|
|
|
frame, err := cam.ReadFrame()
|
|
if len(frame) == 0 || err != nil {
|
|
break
|
|
}
|
|
|
|
msgCh <- mqttapi.NewMsg(topic, frame)
|
|
}
|
|
}
|