222 lines
6.4 KiB
Go
222 lines
6.4 KiB
Go
// Copyright (c) 2024 Joshua Rich <joshua.rich@gmail.com>
|
|
//
|
|
// This software is released under the MIT License.
|
|
// https://opensource.org/licenses/MIT
|
|
|
|
package media
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/eclipse/paho.golang/paho"
|
|
"github.com/vladimirvivien/go4vl/device"
|
|
"github.com/vladimirvivien/go4vl/v4l2"
|
|
|
|
mqtthass "github.com/joshuar/go-hass-anything/v11/pkg/hass"
|
|
mqttapi "github.com/joshuar/go-hass-anything/v11/pkg/mqtt"
|
|
|
|
"github.com/joshuar/go-hass-agent/internal/logging"
|
|
"github.com/joshuar/go-hass-agent/internal/preferences"
|
|
)
|
|
|
|
const (
|
|
startIcon = "mdi:play"
|
|
stopIcon = "mdi:stop"
|
|
statusIcon = "mdi:webcam"
|
|
|
|
startedState = "Recording"
|
|
stoppedState = "Not Recording"
|
|
)
|
|
|
|
// Some defaults for the device file, formats and image size.
|
|
var (
|
|
defaultDevice = "/dev/video0"
|
|
preferredFmts = []v4l2.FourCCType{v4l2.PixelFmtMPEG, v4l2.PixelFmtMJPEG, v4l2.PixelFmtJPEG, v4l2.PixelFmtYUYV}
|
|
defaultHeight uint32 = 640
|
|
defaultWidth uint32 = 480
|
|
)
|
|
|
|
// CameraEntities 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 CameraEntities struct {
|
|
Images *mqtthass.ImageEntity
|
|
StartButton *mqtthass.ButtonEntity
|
|
StopButton *mqtthass.ButtonEntity
|
|
Status *mqtthass.SensorEntity
|
|
}
|
|
|
|
// cameraControl is an internal struct that contains the data used to control
|
|
// the camera and populate the entities.
|
|
type cameraControl struct {
|
|
device *device.Device
|
|
cancelFunc context.CancelFunc
|
|
logger *slog.Logger
|
|
state string
|
|
fps time.Duration
|
|
}
|
|
|
|
// 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) *CameraEntities {
|
|
camera := newCamera(ctx)
|
|
entities := &CameraEntities{}
|
|
|
|
entities.Images = mqtthass.AsImage(mqtthass.NewEntity(preferences.AppName, "Webcam", mqttDevice.Name+"_camera").
|
|
WithDeviceInfo(mqttDevice).
|
|
WithDefaultOriginInfo(), mqtthass.ModeImage)
|
|
|
|
entities.StartButton = mqtthass.AsButton(mqtthass.NewEntity(preferences.AppName, "Start Webcam", mqttDevice.Name+"_start_camera").
|
|
WithDeviceInfo(mqttDevice).
|
|
WithDefaultOriginInfo().
|
|
WithIcon(startIcon).
|
|
WithCommandCallback(func(_ *paho.Publish) {
|
|
err := camera.openCamera(defaultDevice)
|
|
if err != nil {
|
|
camera.logger.Error("Could not open camera device.", slog.Any("error", err))
|
|
|
|
return
|
|
}
|
|
|
|
camCtx, cancelFunc := context.WithCancel(ctx)
|
|
camera.cancelFunc = cancelFunc
|
|
camera.state = startedState
|
|
|
|
camera.logger.Debug("Start recording webcam.")
|
|
|
|
go camera.publishImages(camCtx, entities.Images.GetImageTopic(), msgCh)
|
|
msgCh <- mqttapi.NewMsg(entities.Status.StateTopic, []byte(camera.state))
|
|
}))
|
|
|
|
entities.StopButton = mqtthass.AsButton(mqtthass.NewEntity(preferences.AppName, "Stop Webcam", mqttDevice.Name+"_stop_camera").
|
|
WithDeviceInfo(mqttDevice).
|
|
WithDefaultOriginInfo().
|
|
WithIcon(stopIcon).
|
|
WithCommandCallback(func(_ *paho.Publish) {
|
|
camera.state = stoppedState
|
|
if camera.cancelFunc != nil {
|
|
camera.cancelFunc()
|
|
camera.logger.Debug("Stop recording webcam.")
|
|
|
|
if err := camera.closeCamera(); err != nil {
|
|
camera.logger.Error("Close camera failed.", slog.Any("error", err))
|
|
}
|
|
}
|
|
msgCh <- mqttapi.NewMsg(entities.Status.StateTopic, []byte(camera.state))
|
|
}))
|
|
|
|
entities.Status = mqtthass.AsSensor(mqtthass.NewEntity(preferences.AppName, "Webcam Status", mqttDevice.Name+"_camera_status").
|
|
WithDeviceInfo(mqttDevice).
|
|
WithDefaultOriginInfo().
|
|
WithIcon(statusIcon).
|
|
WithValueTemplate("{{ value }}").
|
|
WithStateCallback(func(_ ...any) (json.RawMessage, error) {
|
|
return json.RawMessage(camera.state), nil
|
|
}))
|
|
|
|
go func() {
|
|
msgCh <- mqttapi.NewMsg(entities.Status.StateTopic, []byte(camera.state))
|
|
}()
|
|
|
|
return entities
|
|
}
|
|
|
|
func newCamera(ctx context.Context) *cameraControl {
|
|
return &cameraControl{
|
|
logger: logging.FromContext(ctx).With(slog.String("controller", "camera")),
|
|
state: stoppedState,
|
|
}
|
|
}
|
|
|
|
// openCamera opens the camera device and ensures that it has a preferred image
|
|
// format, framerate and dimensions.
|
|
func (c *cameraControl) openCamera(cameraDevice string) error {
|
|
camDev, err := device.Open(cameraDevice)
|
|
if err != nil {
|
|
return fmt.Errorf("could not open camera %s: %w", cameraDevice, err)
|
|
}
|
|
|
|
fps, err := camDev.GetFrameRate()
|
|
if err != nil {
|
|
return fmt.Errorf("could not determine camera frame rate: %w", err)
|
|
}
|
|
|
|
fmtDescs, err := camDev.GetFormatDescriptions()
|
|
if err != nil {
|
|
return fmt.Errorf("could not determine camera formats: %w", err)
|
|
}
|
|
|
|
var fmtDesc *v4l2.FormatDescription
|
|
for _, preferredFmt := range preferredFmts {
|
|
fmtDesc = getFormats(fmtDescs, preferredFmt)
|
|
if fmtDesc != nil {
|
|
break
|
|
}
|
|
}
|
|
|
|
if fmtDesc == nil {
|
|
return fmt.Errorf("camera does not support any preferred formats: %w", err)
|
|
}
|
|
|
|
if err = camDev.SetPixFormat(v4l2.PixFormat{
|
|
Width: defaultWidth,
|
|
Height: defaultHeight,
|
|
PixelFormat: fmtDesc.PixelFormat,
|
|
Field: v4l2.FieldNone,
|
|
}); err != nil {
|
|
return fmt.Errorf("could not configure camera: %w", err)
|
|
}
|
|
|
|
pixFmt, err := camDev.GetPixFormat()
|
|
if err == nil {
|
|
c.logger.Debug("Camera configured.",
|
|
slog.Any("format", pixFmt),
|
|
slog.Any("fps", fps))
|
|
}
|
|
|
|
c.device = camDev
|
|
c.fps = time.Second / time.Duration(fps)
|
|
|
|
return 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 (c *cameraControl) publishImages(ctx context.Context, topic string, msgCh chan *mqttapi.Msg) {
|
|
if err := c.device.Start(ctx); err != nil {
|
|
c.logger.Error("Could not start recording", slog.Any("error", err))
|
|
|
|
return
|
|
}
|
|
|
|
for frame := range c.device.GetOutput() {
|
|
c.logger.Log(ctx, mqttapi.LevelTrace, "Sending frame.")
|
|
msgCh <- mqttapi.NewMsg(topic, frame)
|
|
|
|
time.Sleep(c.fps)
|
|
}
|
|
}
|
|
|
|
// closeCamera wraps the v4l2 camera close method.
|
|
func (c *cameraControl) closeCamera() error {
|
|
if err := c.device.Close(); err != nil {
|
|
return fmt.Errorf("could not close camera device: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getFormats finds an appropriate image format to use for the camera.
|
|
func getFormats(fmts []v4l2.FormatDescription, pixEncoding v4l2.FourCCType) *v4l2.FormatDescription {
|
|
for _, desc := range fmts {
|
|
if desc.PixelFormat == pixEncoding {
|
|
return &desc
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|