add: debian file, perform various fixes
This commit is contained in:
+225
-154
@@ -8,6 +8,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
@@ -19,6 +20,8 @@ import (
|
|||||||
"github.com/pion/webrtc/v3"
|
"github.com/pion/webrtc/v3"
|
||||||
"github.com/pion/webrtc/v3/pkg/media"
|
"github.com/pion/webrtc/v3/pkg/media"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,18 +49,6 @@ func main() {
|
|||||||
fmt.Sprintf("https://%s/", *signalerServer),
|
fmt.Sprintf("https://%s/", *signalerServer),
|
||||||
connect.WithGRPC(),
|
connect.WithGRPC(),
|
||||||
)
|
)
|
||||||
authToken, err := client.CreateAuthToken(ctx, connect.NewRequest(&pb.CreateAuthTokenRequest{
|
|
||||||
Home: cfg.HomeName,
|
|
||||||
Type: &pb.CreateAuthTokenRequest_Camera_{
|
|
||||||
Camera: &pb.CreateAuthTokenRequest_Camera{
|
|
||||||
Id: cfg.CameraName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("failed to get auth token")
|
|
||||||
}
|
|
||||||
token := authToken.Msg.GetToken()
|
|
||||||
|
|
||||||
vid, err := h264video.Default.Get()
|
vid, err := h264video.Default.Get()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -78,6 +69,11 @@ func main() {
|
|||||||
sensorCh, sensorDone := sensors.Join()
|
sensorCh, sensorDone := sensors.Join()
|
||||||
defer sensorDone()
|
defer sensorDone()
|
||||||
|
|
||||||
|
token, err := getAuthToken(ctx, client, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed to get auth token")
|
||||||
|
}
|
||||||
|
|
||||||
go handleSensor(ctx, client, token, sensorCh)
|
go handleSensor(ctx, client, token, sensorCh)
|
||||||
|
|
||||||
// Create a new RTCPeerConnection
|
// Create a new RTCPeerConnection
|
||||||
@@ -87,6 +83,14 @@ func main() {
|
|||||||
// Wait for a session request
|
// Wait for a session request
|
||||||
session, err := client.PopSession(ctx, withAuth(token, &pb.PopSessionRequest{}))
|
session, err := client.PopSession(ctx, withAuth(token, &pb.PopSessionRequest{}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound {
|
||||||
|
// try getting a new token
|
||||||
|
token, err = getAuthToken(ctx, client, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed to recreate auth token")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
log.Error().Err(err).Msg("error creating session")
|
log.Error().Err(err).Msg("error creating session")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -94,6 +98,22 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getAuthToken(ctx context.Context, client servicepb.SignalerServiceClient, cfg *config.Config) (string, error) {
|
||||||
|
authToken, err := client.CreateAuthToken(ctx, connect.NewRequest(&pb.CreateAuthTokenRequest{
|
||||||
|
Home: cfg.HomeName,
|
||||||
|
Type: &pb.CreateAuthTokenRequest_Camera_{
|
||||||
|
Camera: &pb.CreateAuthTokenRequest_Camera{
|
||||||
|
Id: cfg.CameraName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("CreateAuthToken failed: %w", err)
|
||||||
|
}
|
||||||
|
token := authToken.Msg.GetToken()
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
func handleSensor(ctx context.Context, client servicepb.SignalerServiceClient, token string, ch <-chan *pb.Sample) {
|
func handleSensor(ctx context.Context, client servicepb.SignalerServiceClient, token string, ch <-chan *pb.Sample) {
|
||||||
for {
|
for {
|
||||||
var sample *pb.Sample
|
var sample *pb.Sample
|
||||||
@@ -111,76 +131,12 @@ func handleSensor(ctx context.Context, client servicepb.SignalerServiceClient, t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSession(ctx context.Context, client servicepb.SignalerServiceClient, token string, session *connect.Response[pb.Session], vid *h264video.Video) {
|
func getIceConnection(ctx context.Context, peerConnection *webrtc.PeerConnection, client servicepb.SignalerServiceClient, token string, session *connect.Response[pb.Session]) (context.Context, error) {
|
||||||
var err error
|
|
||||||
log.Debug().Msg("new session")
|
|
||||||
|
|
||||||
peerConnection, err := webrtc.NewPeerConnection(webrtc.Configuration{
|
|
||||||
ICEServers: []webrtc.ICEServer{
|
|
||||||
{
|
|
||||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Info().Err(err).Msg("failed to get a connection")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// connect to the video stream; the cleanup is done in the goroutine which
|
|
||||||
// consumes the framess
|
|
||||||
ch, trackCodec, cleanUp := vid.Join()
|
|
||||||
// Create a video track
|
|
||||||
videoTrack, videoTrackErr := webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{MimeType: trackCodec}, "video", "pion")
|
|
||||||
if videoTrackErr != nil {
|
|
||||||
log.Info().Err(videoTrackErr).Msg("Failed to create video track")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rtpSender, err := peerConnection.AddTrack(videoTrack)
|
|
||||||
if err != nil {
|
|
||||||
log.Info().Err(err).Msg("Failed to add track to connection")
|
|
||||||
}
|
|
||||||
|
|
||||||
// We use the cancel func to signal that the stream is ready
|
// We use the cancel func to signal that the stream is ready
|
||||||
iceConnectedCtx, iceConnectedCtxCancel := context.WithCancel(context.Background())
|
iceConnectedCtx, iceConnectedCtxCancel := context.WithCancel(context.Background())
|
||||||
defer func() {
|
iceDisconnectedCtx, iceDeconnectedCtxCancel := context.WithCancel(ctx)
|
||||||
if err := peerConnection.Close(); err != nil {
|
readyToSend := sync.WaitGroup{}
|
||||||
log.Debug().Err(err).Msg("cannot close peerConnection")
|
readyToSend.Add(1)
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Read incoming RTCP packets
|
|
||||||
// Before these packets are returned they are processed by interceptors. For things
|
|
||||||
// like NACK this needs to be called.
|
|
||||||
go func() {
|
|
||||||
rtcpBuf := make([]byte, 1500)
|
|
||||||
for {
|
|
||||||
if _, _, err := rtpSender.Read(rtcpBuf); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer cleanUp()
|
|
||||||
readyToSend := false
|
|
||||||
for frame := range ch {
|
|
||||||
select {
|
|
||||||
case <-iceConnectedCtx.Done():
|
|
||||||
readyToSend = true
|
|
||||||
default:
|
|
||||||
// do nothing
|
|
||||||
}
|
|
||||||
if !readyToSend {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := videoTrack.WriteSample(media.Sample{Data: frame, Duration: time.Millisecond * 33}); err != nil {
|
|
||||||
log.Err(err).Msg("failed to write sample")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Set the handler for ICE connection state
|
// Set the handler for ICE connection state
|
||||||
// This will notify you when the peer has connected/disconnected
|
// This will notify you when the peer has connected/disconnected
|
||||||
@@ -193,7 +149,6 @@ func handleSession(ctx context.Context, client servicepb.SignalerServiceClient,
|
|||||||
|
|
||||||
// Set the handler for Peer connection state
|
// Set the handler for Peer connection state
|
||||||
// This will notify you when the peer has connected/disconnected
|
// This will notify you when the peer has connected/disconnected
|
||||||
exitCh := make(chan struct{})
|
|
||||||
peerConnection.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
|
peerConnection.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
|
||||||
log.Debug().Msgf("Peer Connection State has changed: %s\n", s.String())
|
log.Debug().Msgf("Peer Connection State has changed: %s\n", s.String())
|
||||||
|
|
||||||
@@ -201,15 +156,12 @@ func handleSession(ctx context.Context, client servicepb.SignalerServiceClient,
|
|||||||
// Wait until PeerConnection has had no network activity for 30 seconds or another failure. It may be reconnected using an ICE Restart.
|
// Wait until PeerConnection has had no network activity for 30 seconds or another failure. It may be reconnected using an ICE Restart.
|
||||||
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
|
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
|
||||||
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
|
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
|
||||||
close(exitCh)
|
iceDeconnectedCtxCancel()
|
||||||
return
|
|
||||||
}
|
|
||||||
if s == webrtc.PeerConnectionStateDisconnected {
|
|
||||||
close(exitCh)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
peerConnection.OnICECandidate(func(i *webrtc.ICECandidate) {
|
peerConnection.OnICECandidate(func(i *webrtc.ICECandidate) {
|
||||||
|
readyToSend.Wait()
|
||||||
if i == nil {
|
if i == nil {
|
||||||
if _, err := client.CreateIceMessage(ctx, withAuth(token, &pb.CreateIceMessageRequest{
|
if _, err := client.CreateIceMessage(ctx, withAuth(token, &pb.CreateIceMessageRequest{
|
||||||
SessionIdentifier: session.Msg.GetId(),
|
SessionIdentifier: session.Msg.GetId(),
|
||||||
@@ -226,6 +178,7 @@ func handleSession(ctx context.Context, client servicepb.SignalerServiceClient,
|
|||||||
if c.UsernameFragment != nil {
|
if c.UsernameFragment != nil {
|
||||||
usernameFragment = proto.String(*c.UsernameFragment)
|
usernameFragment = proto.String(*c.UsernameFragment)
|
||||||
}
|
}
|
||||||
|
log.Info().Msgf("got candidate %+v", c)
|
||||||
client.CreateIceMessage(ctx, withAuth(token, &pb.CreateIceMessageRequest{
|
client.CreateIceMessage(ctx, withAuth(token, &pb.CreateIceMessageRequest{
|
||||||
SessionIdentifier: session.Msg.GetId(),
|
SessionIdentifier: session.Msg.GetId(),
|
||||||
IceMessage: &pb.IceMessage{
|
IceMessage: &pb.IceMessage{
|
||||||
@@ -240,85 +193,203 @@ func handleSession(ctx context.Context, client servicepb.SignalerServiceClient,
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
log.Info().Msg("Spawning helper")
|
|
||||||
|
|
||||||
// helper which sends answers, waits for
|
// Get the offer from the other
|
||||||
|
msg, err := client.PopIceMessage(iceDisconnectedCtx, withAuth(token, &pb.PopIceMessageRequest{
|
||||||
|
SessionIdentifier: session.Msg.GetId(),
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
log.Info().Err(err).Msg("failed to pop ice message")
|
||||||
|
}
|
||||||
|
iceSession := msg.Msg.GetSession()
|
||||||
|
|
||||||
|
switch iceSession.SdpType {
|
||||||
|
case int64(webrtc.SDPTypeOffer):
|
||||||
|
offer := webrtc.SessionDescription{
|
||||||
|
Type: webrtc.SDPType(iceSession.SdpType),
|
||||||
|
SDP: iceSession.Sdp,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := peerConnection.SetRemoteDescription(offer); err != nil {
|
||||||
|
log.Warn().Err(err).Msg("failed to set remote description")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
log.Info().Msgf("unexpected sdp type: %v", webrtc.SDPType(iceSession.SdpType).String())
|
||||||
|
}
|
||||||
|
log.Info().Msg("Accepted promise!")
|
||||||
|
|
||||||
|
// Send back an answer
|
||||||
|
answer, err := peerConnection.CreateAnswer(nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Debug().Err(err).Msg("Candidate failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := peerConnection.SetLocalDescription(answer); err != nil {
|
||||||
|
log.Info().Err(err).Msg("Failed to set local description")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.CreateIceMessage(iceDisconnectedCtx, withAuth(token, &pb.CreateIceMessageRequest{
|
||||||
|
SessionIdentifier: session.Msg.GetId(),
|
||||||
|
IceMessage: &pb.IceMessage{
|
||||||
|
Type: &pb.IceMessage_Session{
|
||||||
|
Session: &pb.IceSessionDescription{
|
||||||
|
SdpType: int64(answer.Type),
|
||||||
|
Sdp: answer.SDP,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
log.Info().Err(err).Msg("Failed to send answer")
|
||||||
|
}
|
||||||
|
readyToSend.Done()
|
||||||
|
|
||||||
|
// TODO: do we add the video right here?
|
||||||
|
|
||||||
|
// Go into a loop processing ice candidates
|
||||||
|
|
||||||
// Add ICE candidates from remote
|
// Add ICE candidates from remote
|
||||||
for {
|
go func() {
|
||||||
select {
|
for {
|
||||||
case <-exitCh:
|
select {
|
||||||
return
|
case <-iceDisconnectedCtx.Done():
|
||||||
default:
|
return
|
||||||
// check for another message
|
default:
|
||||||
}
|
// check for another message
|
||||||
msg, err := client.PopIceMessage(ctx, withAuth(token, &pb.PopIceMessageRequest{
|
|
||||||
SessionIdentifier: session.Msg.GetId(),
|
|
||||||
}))
|
|
||||||
if err != nil {
|
|
||||||
log.Info().Err(err).Msg("failed to pop ice message")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch msg.Msg.Type.(type) {
|
|
||||||
case *pb.IceMessage_Candidate:
|
|
||||||
candidate := msg.Msg.GetCandidate()
|
|
||||||
var sdpMLine *uint16
|
|
||||||
if candidate.SdpLineIndex != nil {
|
|
||||||
t := uint16(candidate.GetSdpLineIndex())
|
|
||||||
sdpMLine = &t
|
|
||||||
}
|
}
|
||||||
if err := peerConnection.AddICECandidate(webrtc.ICECandidateInit{
|
msg, err := client.PopIceMessage(iceDisconnectedCtx, withAuth(token, &pb.PopIceMessageRequest{
|
||||||
Candidate: candidate.GetCandidate(),
|
|
||||||
SDPMid: candidate.SdpMid,
|
|
||||||
SDPMLineIndex: sdpMLine,
|
|
||||||
}); err != nil {
|
|
||||||
log.Warn().Err(err).Msg("failed to add ice candidate")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send back an answer
|
|
||||||
answer, err := peerConnection.CreateAnswer(nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Debug().Msg("Candidate failed")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := peerConnection.SetLocalDescription(answer); err != nil {
|
|
||||||
log.Info().Err(err).Msg("Failed to set local description")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = client.CreateIceMessage(ctx, withAuth(token, &pb.CreateIceMessageRequest{
|
|
||||||
SessionIdentifier: session.Msg.GetId(),
|
SessionIdentifier: session.Msg.GetId(),
|
||||||
IceMessage: &pb.IceMessage{
|
|
||||||
Type: &pb.IceMessage_Session{
|
|
||||||
Session: &pb.IceSessionDescription{
|
|
||||||
SdpType: int64(answer.Type),
|
|
||||||
Sdp: answer.SDP,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Info().Err(err).Msg("Failed to send answer")
|
log.Info().Err(err).Msg("failed to pop ice message")
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
case *pb.IceMessage_Session:
|
switch msg.Msg.Type.(type) {
|
||||||
iceSession := msg.Msg.GetSession()
|
case *pb.IceMessage_Candidate:
|
||||||
|
candidate := msg.Msg.GetCandidate()
|
||||||
switch iceSession.SdpType {
|
var sdpMLine *uint16
|
||||||
case int64(webrtc.SDPTypeOffer):
|
if candidate.SdpLineIndex != nil {
|
||||||
offer := webrtc.SessionDescription{
|
t := uint16(candidate.GetSdpLineIndex())
|
||||||
Type: webrtc.SDPType(iceSession.SdpType),
|
sdpMLine = &t
|
||||||
SDP: iceSession.Sdp,
|
|
||||||
}
|
}
|
||||||
|
if err := peerConnection.AddICECandidate(webrtc.ICECandidateInit{
|
||||||
if err := peerConnection.SetRemoteDescription(offer); err != nil {
|
Candidate: candidate.GetCandidate(),
|
||||||
log.Warn().Err(err).Msg("failed to set remote description")
|
SDPMid: candidate.SdpMid,
|
||||||
|
SDPMLineIndex: sdpMLine,
|
||||||
|
}); err != nil {
|
||||||
|
log.Warn().Err(err).Msg("failed to add ice candidate")
|
||||||
}
|
}
|
||||||
default:
|
case *pb.IceMessage_Session:
|
||||||
log.Info().Msgf("unexpected sdp type: %v", webrtc.SDPType(iceSession.SdpType).String())
|
|
||||||
|
case *pb.IceMessage_NoMoreCandidates:
|
||||||
|
// do nothing
|
||||||
}
|
}
|
||||||
log.Info().Msg("Accepted promise!")
|
|
||||||
case *pb.IceMessage_NoMoreCandidates:
|
|
||||||
// do nothing
|
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-iceConnectedCtx.Done():
|
||||||
|
// Success; return the connection
|
||||||
|
return iceDisconnectedCtx, nil
|
||||||
|
case <-iceDisconnectedCtx.Done():
|
||||||
|
// No connection; return error
|
||||||
|
return ctx, fmt.Errorf("failed to create connection: ICE disconnected")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleSession(ctx context.Context, client servicepb.SignalerServiceClient, token string, session *connect.Response[pb.Session], vid *h264video.Video) {
|
||||||
|
log.Debug().Msg("new session")
|
||||||
|
|
||||||
|
// connect to the video stream; the cleanup is done in the goroutine which
|
||||||
|
// consumes the framess
|
||||||
|
ch, trackCodec, cleanUp := vid.Join()
|
||||||
|
defer cleanUp()
|
||||||
|
|
||||||
|
// Create a video track
|
||||||
|
videoTrack, err := webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{MimeType: trackCodec}, "video", "pion")
|
||||||
|
if err != nil {
|
||||||
|
log.Info().Err(err).Msg("Failed to create video track")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
peerConnection, err := webrtc.NewPeerConnection(webrtc.Configuration{
|
||||||
|
ICEServers: []webrtc.ICEServer{
|
||||||
|
{
|
||||||
|
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to make connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
rtpSender, err := peerConnection.AddTrack(videoTrack)
|
||||||
|
if err != nil {
|
||||||
|
log.Info().Err(err).Msg("Failed to add track to connection")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCtx, err := getIceConnection(ctx, peerConnection, client, token, session)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed creating ice connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Msgf("State is %+v", rtpSender.Transport().State())
|
||||||
|
|
||||||
|
// Read incoming RTCP packets
|
||||||
|
// Before these packets are returned they are processed by interceptors. For things
|
||||||
|
// like NACK this needs to be called.
|
||||||
|
go func() {
|
||||||
|
rtcpBuf := make([]byte, 1500)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-disconnectedCtx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
if _, _, err := rtpSender.Read(rtcpBuf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Start a routine to send frames from a buffer
|
||||||
|
var frame []byte
|
||||||
|
var frameLock sync.Mutex
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(time.Millisecond * 30)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-disconnectedCtx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
frameLock.Lock()
|
||||||
|
myFrame := frame
|
||||||
|
frame = nil
|
||||||
|
frameLock.Unlock()
|
||||||
|
if myFrame == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := videoTrack.WriteSample(media.Sample{Data: myFrame, Duration: time.Millisecond * 33}); err != nil {
|
||||||
|
log.Err(err).Msg("failed to write sample")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for myFrame := range ch {
|
||||||
|
select {
|
||||||
|
case <-disconnectedCtx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
frameLock.Lock()
|
||||||
|
frame = myFrame
|
||||||
|
frameLock.Unlock()
|
||||||
|
}
|
||||||
|
// TODO: Video ended; close the connection
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Package: watcher
|
||||||
|
Version: 0.2
|
||||||
|
Maintainer: Charles
|
||||||
|
Architecture: all
|
||||||
|
Description: Watches cameras and temp sensors
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
# Make sure its enabled
|
||||||
|
systemctl enable --now watcher
|
||||||
|
# Restart it; it might have already been installed and running
|
||||||
|
systemctl restart watcher
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Install to /etc/systemd/system/watcher.service
|
||||||
|
[Unit]
|
||||||
|
Description=Run watcher
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target systemd-networkd-wait-online.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=exec
|
||||||
|
ExecStart=/usr/local/bin/watcher --watcher_config /etc/watcher_config.yaml --watcher_name /etc/watcher_name.txt
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Install to /etc/systemd/system/watcher.service
|
||||||
|
[Unit]
|
||||||
|
Description=Run watcher
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target systemd-networkd-wait-online.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=exec
|
||||||
|
ExecStart=/usr/local/bin --watcher_config /etc/watcher.yaml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
home: Sunnyvale
|
||||||
|
name: Office
|
||||||
|
h264:
|
||||||
|
binary: "/usr/bin/libcamera-vid"
|
||||||
|
arguments:
|
||||||
|
- "-n"
|
||||||
|
- "-t"
|
||||||
|
- "0"
|
||||||
|
- "--codec"
|
||||||
|
- "h264"
|
||||||
|
- "--mode"
|
||||||
|
- "1640:1232"
|
||||||
|
- "--inline"
|
||||||
|
- "-o"
|
||||||
|
- "-"
|
||||||
|
sensor:
|
||||||
|
binary: "/usr/bin/python3"
|
||||||
|
arguments:
|
||||||
|
- "/usr/local/bin/temperature.py"
|
||||||
|
sensor_rate_ms: 10000
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import bme280
|
||||||
|
import smbus2
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
port = 1
|
||||||
|
address = 0x77 # Adafruit BME280 address. Other BME280s may be different
|
||||||
|
bus = smbus2.SMBus(port)
|
||||||
|
|
||||||
|
bme280.load_calibration_params(bus,address)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
bme280_data = bme280.sample(bus,address)
|
||||||
|
humidity = bme280_data.humidity
|
||||||
|
pressure = bme280_data.pressure
|
||||||
|
ambient_temperature = bme280_data.temperature
|
||||||
|
print(humidity, pressure, ambient_temperature)
|
||||||
|
sleep(1)
|
||||||
Executable
BIN
Binary file not shown.
@@ -25,20 +25,23 @@ type Config struct {
|
|||||||
SensorRateMS int64 `yaml:"sensor_rate_ms"`
|
SensorRateMS int64 `yaml:"sensor_rate_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(source []byte) (*Config, error) {
|
func New(source []byte, name string) (*Config, error) {
|
||||||
config := &Config{}
|
config := &Config{}
|
||||||
if err := yaml.Unmarshal(source, config); err != nil {
|
if err := yaml.Unmarshal(source, config); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||||
}
|
}
|
||||||
|
config.CameraName = name
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mod struct {
|
type Mod struct {
|
||||||
filePath string
|
filePath string
|
||||||
|
namePath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Mod) RegisterFlags(fs *flag.FlagSet) {
|
func (m *Mod) RegisterFlags(fs *flag.FlagSet) {
|
||||||
fs.StringVar(&m.filePath, "watcher_config", "", "path to the watcher configuration")
|
fs.StringVar(&m.filePath, "watcher_config", "", "path to the watcher configuration")
|
||||||
|
fs.StringVar(&m.namePath, "watcher_name", "/var/lib/dbus/machine-id", "location of the file to pull name from")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Mod) Get() (*Config, error) {
|
func (m *Mod) Get() (*Config, error) {
|
||||||
@@ -47,7 +50,13 @@ func (m *Mod) Get() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("failed to read file %q: %w", m.filePath, err)
|
return nil, fmt.Errorf("failed to read file %q: %w", m.filePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := New(bytes)
|
// Override name using a unique ID; we can let users name things in the app
|
||||||
|
myID, err := os.ReadFile(m.namePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read machine ID from %q", m.namePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := New(bytes, string(myID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,7 +223,16 @@ func (s *Server) PopSession(ctx context.Context, request *connect.Request[pb.Pop
|
|||||||
ch := s.sessionsByCamera[authToken.Uid]
|
ch := s.sessionsByCamera[authToken.Uid]
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
sess := <-ch
|
var sess *session
|
||||||
|
tick := time.NewTicker(time.Second * 30)
|
||||||
|
defer tick.Stop()
|
||||||
|
select {
|
||||||
|
case sess = <-ch:
|
||||||
|
// OK
|
||||||
|
case <-tick.C:
|
||||||
|
// have them retry
|
||||||
|
return nil, connect.NewError(connect.CodeDeadlineExceeded, fmt.Errorf("try again"))
|
||||||
|
}
|
||||||
|
|
||||||
if sess == nil {
|
if sess == nil {
|
||||||
return nil, status.Errorf(codes.DataLoss, "someone else stole the session")
|
return nil, status.Errorf(codes.DataLoss, "someone else stole the session")
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: signaler
|
- name: signaler
|
||||||
image: us-central1-docker.pkg.dev/home-sensors-400805/signaler/image:20231003-0032
|
image: us-central1-docker.pkg.dev/home-sensors-400805/signaler/image:20240110-2245
|
||||||
command:
|
command:
|
||||||
- /signaler
|
- /signaler
|
||||||
ports:
|
ports:
|
||||||
@@ -81,3 +81,10 @@ spec:
|
|||||||
domains:
|
domains:
|
||||||
- home.chathaway.codes
|
- home.chathaway.codes
|
||||||
- www.home.chathaway.codes
|
- www.home.chathaway.codes
|
||||||
|
---
|
||||||
|
apiVersion: cloud.google.com/v1
|
||||||
|
kind: BackendConfig
|
||||||
|
metadata:
|
||||||
|
name: my-bsc-backendconfig
|
||||||
|
spec:
|
||||||
|
timeoutSec: 40
|
||||||
+1
-15
@@ -10,25 +10,11 @@ h264:
|
|||||||
- "h264"
|
- "h264"
|
||||||
- "--mode"
|
- "--mode"
|
||||||
- "1640:1232"
|
- "1640:1232"
|
||||||
- "--denoise"
|
|
||||||
- "off"
|
|
||||||
- "--inline"
|
- "--inline"
|
||||||
- "-o"
|
- "-o"
|
||||||
- "-"
|
- "-"
|
||||||
ivf:
|
|
||||||
binary: "/usr/bin/ffmpeg"
|
|
||||||
arguments:
|
|
||||||
- "-i"
|
|
||||||
- "-"
|
|
||||||
- "-g"
|
|
||||||
- "30"
|
|
||||||
- "-b:v"
|
|
||||||
- "2M"
|
|
||||||
- "-f"
|
|
||||||
- "ivf"
|
|
||||||
- "-"
|
|
||||||
sensor:
|
sensor:
|
||||||
binary: "/usr/bin/python3"
|
binary: "/usr/bin/python3"
|
||||||
arguments:
|
arguments:
|
||||||
- "/home/charles/temperature.py"
|
- "/usr/local/bin/temperature.py"
|
||||||
sensor_rate_ms: 10000
|
sensor_rate_ms: 10000
|
||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
mkdir -p dbuild/usr/local/bin
|
||||||
|
mkdir -p dbuild/etc/
|
||||||
|
mkdir -p dbuild/etc/systemd/system/
|
||||||
|
mkdir -p dbuild/DEBIAN
|
||||||
|
|
||||||
|
cat <<EOF > dbuild/DEBIAN/control
|
||||||
|
Package: watcher
|
||||||
|
Version: 0.2
|
||||||
|
Maintainer: Charles
|
||||||
|
Architecture: all
|
||||||
|
Description: Watches cameras and temp sensors
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat <<EOF > dbuild/DEBIAN/postinst
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
# Make sure its enabled
|
||||||
|
systemctl enable --now watcher
|
||||||
|
# Restart it; it might have already been installed and running
|
||||||
|
systemctl restart watcher
|
||||||
|
EOF
|
||||||
|
chmod +x dbuild/DEBIAN/postinst
|
||||||
|
|
||||||
|
GOOS=linux GOARCH=arm64 go build -o dbuild/usr/local/bin/watcher ./cmd/watcher
|
||||||
|
|
||||||
|
cp ./rpi_camera.yaml dbuild/etc/watcher_config.yaml
|
||||||
|
cp ./watcher.systemd dbuild/etc/systemd/system/watcher.service
|
||||||
|
cp ./temperature.py dbuild/usr/local/bin/
|
||||||
|
|
||||||
|
|
||||||
|
dpkg-deb --build dbuild
|
||||||
@@ -31,5 +31,6 @@
|
|||||||
android:value="2" />
|
android:value="2" />
|
||||||
</application>
|
</application>
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
+24
-13
@@ -38,15 +38,16 @@ class CallState extends State<Call> {
|
|||||||
logger.i("Init remote renderer");
|
logger.i("Init remote renderer");
|
||||||
await _remoteRenderer.initialize();
|
await _remoteRenderer.initialize();
|
||||||
logger.i("Creating session");
|
logger.i("Creating session");
|
||||||
await _createSesson();
|
await _createSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
_createSesson() async {
|
_createSession() async {
|
||||||
var callOptions = CallOptions(metadata: {
|
var callOptions = CallOptions(metadata: {
|
||||||
'Authorization': await widget.sessionService.getAuthToken(widget.home)
|
'Authorization': await widget.sessionService.getAuthToken(widget.home)
|
||||||
});
|
});
|
||||||
|
|
||||||
var cancelCreate = Completer();
|
var cancelCreate = Completer();
|
||||||
|
var sendIceCandidates = Completer();
|
||||||
|
|
||||||
var clientSession = await widget.client.createSession(
|
var clientSession = await widget.client.createSession(
|
||||||
pb.CreateSessionRequest(
|
pb.CreateSessionRequest(
|
||||||
@@ -73,6 +74,8 @@ class CallState extends State<Call> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
peerConnection.onIceCandidate = (candidate) async {
|
peerConnection.onIceCandidate = (candidate) async {
|
||||||
|
await sendIceCandidates.future;
|
||||||
|
logger.i("Sending ICE candidate");
|
||||||
if (candidate.candidate == null) {
|
if (candidate.candidate == null) {
|
||||||
await widget.client.createIceMessage(
|
await widget.client.createIceMessage(
|
||||||
CreateIceMessageRequest(
|
CreateIceMessageRequest(
|
||||||
@@ -99,16 +102,15 @@ class CallState extends State<Call> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
peerConnection.onIceConnectionState = (state) {
|
peerConnection.onIceConnectionState = (state) {
|
||||||
statusLine = "Ice state now $state";
|
statusLine = "$state";
|
||||||
setState(() {});
|
setState(() {});
|
||||||
logger.i("Ice state now $state");
|
|
||||||
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case RTCIceConnectionState.RTCIceConnectionStateClosed:
|
//case RTCIceConnectionState.RTCIceConnectionStateClosed:
|
||||||
case RTCIceConnectionState.RTCIceConnectionStateDisconnected:
|
//case RTCIceConnectionState.RTCIceConnectionStateDisconnected:
|
||||||
case RTCIceConnectionState.RTCIceConnectionStateFailed:
|
case RTCIceConnectionState.RTCIceConnectionStateFailed:
|
||||||
cancelCreate.complete(CallCancelled());
|
cancelCreate.complete(CallCancelled());
|
||||||
_connect();
|
//_connect();
|
||||||
default:
|
default:
|
||||||
// do nothing
|
// do nothing
|
||||||
}
|
}
|
||||||
@@ -136,7 +138,6 @@ class CallState extends State<Call> {
|
|||||||
var offer = await peerConnection.createOffer();
|
var offer = await peerConnection.createOffer();
|
||||||
await peerConnection.setLocalDescription(offer);
|
await peerConnection.setLocalDescription(offer);
|
||||||
// Send offer through signaling server
|
// Send offer through signaling server
|
||||||
logger.i("Offer is $offer");
|
|
||||||
await widget.client.createIceMessage(
|
await widget.client.createIceMessage(
|
||||||
pb.CreateIceMessageRequest(
|
pb.CreateIceMessageRequest(
|
||||||
sessionIdentifier: clientSession.id,
|
sessionIdentifier: clientSession.id,
|
||||||
@@ -149,6 +150,20 @@ class CallState extends State<Call> {
|
|||||||
),
|
),
|
||||||
options: callOptions);
|
options: callOptions);
|
||||||
|
|
||||||
|
// Expect back a response
|
||||||
|
var someResponse = await Future.any([
|
||||||
|
widget.client.popIceMessage(
|
||||||
|
pb.PopIceMessageRequest(sessionIdentifier: clientSession.id),
|
||||||
|
options: callOptions),
|
||||||
|
cancelCreate.future,
|
||||||
|
]);
|
||||||
|
var resp = someResponse as pb.IceMessage;
|
||||||
|
var session = resp.session;
|
||||||
|
await peerConnection
|
||||||
|
.setRemoteDescription(RTCSessionDescription(session.sdp, "answer"));
|
||||||
|
|
||||||
|
sendIceCandidates.complete();
|
||||||
|
|
||||||
// Get candidates from remote
|
// Get candidates from remote
|
||||||
while (true) {
|
while (true) {
|
||||||
var someResponse = await Future.any([
|
var someResponse = await Future.any([
|
||||||
@@ -168,10 +183,6 @@ class CallState extends State<Call> {
|
|||||||
resp.candidate.sdpLineIndex));
|
resp.candidate.sdpLineIndex));
|
||||||
} else if (resp.hasNoMoreCandidates()) {
|
} else if (resp.hasNoMoreCandidates()) {
|
||||||
logger.i("No more candidates from remote");
|
logger.i("No more candidates from remote");
|
||||||
} else if (resp.hasSession()) {
|
|
||||||
var session = resp.session;
|
|
||||||
await peerConnection
|
|
||||||
.setRemoteDescription(RTCSessionDescription(session.sdp, "answer"));
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,7 +194,7 @@ class CallState extends State<Call> {
|
|||||||
Text(widget.cameraID.id),
|
Text(widget.cameraID.id),
|
||||||
Text(statusLine),
|
Text(statusLine),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 480,
|
height: 320,
|
||||||
child: _ready
|
child: _ready
|
||||||
? RTCVideoView(_remoteRenderer)
|
? RTCVideoView(_remoteRenderer)
|
||||||
: const Text("Loading...")),
|
: const Text("Loading...")),
|
||||||
|
|||||||
+27
-18
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
//import 'package:grpc/grpc_web.dart';
|
//import 'package:grpc/grpc_web.dart';
|
||||||
import 'package:grpc/grpc.dart';
|
import 'package:grpc/grpc.dart';
|
||||||
@@ -36,9 +38,9 @@ class MyApp extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Flutter Demo',
|
title: 'Home Sensors',
|
||||||
theme: ThemeData(
|
theme: ThemeData.dark(
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
// colorScheme: ColorScheme.fromSeed(seedColor: Colors.black87),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
),
|
),
|
||||||
home: MyHomePage(
|
home: MyHomePage(
|
||||||
@@ -75,8 +77,8 @@ class MyHomePage extends StatefulWidget {
|
|||||||
|
|
||||||
class _MyHomePageState extends State<MyHomePage> {
|
class _MyHomePageState extends State<MyHomePage> {
|
||||||
String topMessage = "Creating session...";
|
String topMessage = "Creating session...";
|
||||||
List<Call> camerasToRender = [];
|
List<Widget> camerasToRender = [];
|
||||||
List<Widget> samples = [];
|
Map<String, Widget> cameraSamples = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -100,8 +102,11 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
.listSamples(ListSamplesRequest(), options: callOptions);
|
.listSamples(ListSamplesRequest(), options: callOptions);
|
||||||
|
|
||||||
for (var sample in resp.samples) {
|
for (var sample in resp.samples) {
|
||||||
samples
|
if (sample.type == Sample_Type.TEMPERATURE_C) {
|
||||||
.add(Text("${sample.type}: ${sample.reading} on ${sample.cameraId}"));
|
var reading = (sample.reading * 9.0 / 5.0) + 32;
|
||||||
|
cameraSamples[sample.cameraId.id] =
|
||||||
|
Text("${reading.toStringAsFixed(2)} f");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
@@ -113,12 +118,22 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
var cameras = await widget.client
|
var cameras = await widget.client
|
||||||
.listCameras(ListCamerasRequest(), options: callOptions);
|
.listCameras(ListCamerasRequest(), options: callOptions);
|
||||||
|
|
||||||
|
cameras.cameras.sort((a, b) => a.identifier.id.compareTo(b.identifier.id));
|
||||||
|
camerasToRender = [];
|
||||||
for (var camera in cameras.cameras) {
|
for (var camera in cameras.cameras) {
|
||||||
camerasToRender.add(Call(
|
List<Widget> children = [
|
||||||
widget.client,
|
Call(
|
||||||
widget.sessionService,
|
widget.client,
|
||||||
cameraID: camera.identifier,
|
widget.sessionService,
|
||||||
home: widget.home,
|
cameraID: camera.identifier,
|
||||||
|
home: widget.home,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (cameraSamples.containsKey(camera.identifier.id)) {
|
||||||
|
children.add(cameraSamples[camera.identifier.id]!);
|
||||||
|
}
|
||||||
|
camerasToRender.add(Column(
|
||||||
|
children: children,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
setState(() {});
|
setState(() {});
|
||||||
@@ -134,12 +149,7 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
// than having to individually change instances of widgets.
|
// than having to individually change instances of widgets.
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
// TRY THIS: Try changing the color here to a specific color (to
|
|
||||||
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
|
|
||||||
// change color while the other colors stay the same.
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||||
// Here we take the value from the MyHomePage object that was created by
|
|
||||||
// the App.build method, and use it to set our appbar title.
|
|
||||||
title: Text(widget.title),
|
title: Text(widget.title),
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
@@ -150,7 +160,6 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(topMessage),
|
Text(topMessage),
|
||||||
] +
|
] +
|
||||||
samples +
|
|
||||||
camerasToRender,
|
camerasToRender,
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Install to /etc/systemd/system/watcher.service
|
||||||
|
[Unit]
|
||||||
|
Description=Run watcher
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target systemd-networkd-wait-online.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=exec
|
||||||
|
ExecStart=/usr/local/bin/watcher --watcher_config /etc/watcher_config.yaml --watcher_name /etc/watcher_name.txt
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Reference in New Issue
Block a user