diff --git a/README.md b/README.md index ce0c4e8..23102f2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +Latest pipeline run: [![Go](https://github.com/bugbundle/phantom/actions/workflows/go.yml/badge.svg)](https://github.com/bugbundle/phantom/actions/workflows/go.yml) + # phantom Stream video from usb device over the web. diff --git a/cmd/main.go b/cmd/main.go index e95e196..42db73d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -5,5 +5,6 @@ import ( ) func main() { - http.Server("0.0.0.0:8080") + svc := http.NewService("127.0.0.1:8080") + svc.Run() } diff --git a/internal/adapter/camera/camera.go b/internal/adapter/camera/camera.go index 522ea51..f6221da 100644 --- a/internal/adapter/camera/camera.go +++ b/internal/adapter/camera/camera.go @@ -1 +1,9 @@ package camera + +import "gocv.io/x/gocv" + +type CameraService interface { + Open(deviceId int) error + Close() error + CaptureImage() (*gocv.Mat, error) +} diff --git a/internal/app/http/http.go b/internal/adapter/http/controls/controls.go similarity index 53% rename from internal/app/http/http.go rename to internal/adapter/http/controls/controls.go index 78eb1c8..1218dae 100644 --- a/internal/app/http/http.go +++ b/internal/adapter/http/controls/controls.go @@ -1,59 +1,51 @@ -package http +package controls import ( "fmt" - "html/template" "image" "log" "mime/multipart" "net/http" "net/textproto" + "strconv" "time" "github.com/bugbundle/phantom/internal/port/camera" "gocv.io/x/gocv" ) -// Return default Homepage, a simple alpineJS application to users -func Homepage(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Content-Type", "text/html") - template, err := template.ParseFiles("templates/index.html.tpl") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if err := template.Execute(w, nil); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } +func RegisterRoutes(router *http.ServeMux) { + router.HandleFunc("POST /cameras/{deviceId}", startCameraHandler) + router.HandleFunc("DELETE /cameras", stopCameraHandler) + router.HandleFunc("GET /cameras", StreamVideoHandler) } -func StreamStatus(w http.ResponseWriter, r *http.Request) { - // If the camera is unavailable return 428 - _, err := camera.GetCamera() +// If not yet, create a camera entity +func startCameraHandler(w http.ResponseWriter, r *http.Request) { + g, err := strconv.Atoi(r.PathValue("deviceId")) if err != nil { - w.Write([]byte("false")) - return + http.Error(w, "Invalid deviceId", http.StatusNotFound) } - w.Write([]byte("true")) + // Trigger singleton to instanciate camera + device := camera.GetInstance() + device.Open(g) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) +} + +// If existing, remove a camera entity +func stopCameraHandler(w http.ResponseWriter, r *http.Request) { + device := camera.GetInstance() + device.Close() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) } // This function retrieve camera device and start streaming using multipart/x-mixed-replace // TODO: Add device number option -func StreamVideo(w http.ResponseWriter, r *http.Request) { +func StreamVideoHandler(w http.ResponseWriter, r *http.Request) { // If the camera is unavailable return 428 - webcam, err := camera.GetCamera() - if err != nil { - w.Header().Set("Content-Type", "application/json") - http.Error(w, "{\"reason\": \"webcam is not started\"}", http.StatusPreconditionRequired) - return - } - - // Try to read the webcam - openErr := webcam.Open() - if openErr != nil { - log.Println("Got the following error:", openErr) - } + webcam := camera.GetInstance() mimeWriter := multipart.NewWriter(w) w.Header().Set("Content-Type", fmt.Sprintf("multipart/x-mixed-replace; boundary=%s", mimeWriter.Boundary())) @@ -61,16 +53,11 @@ func StreamVideo(w http.ResponseWriter, r *http.Request) { partHeader.Add("Content-Type", "image/jpeg") for { - if !webcam.IsOpen() { - http.Error(w, "Missing camera", http.StatusServiceUnavailable) - return - } - img, err := webcam.CaptureImage() if err != nil { http.Error(w, "Error capturing image: %v", http.StatusServiceUnavailable) } - defer img.Close() + // defer img.Close() resizedFrame := gocv.NewMat() @@ -94,20 +81,3 @@ func StreamVideo(w http.ResponseWriter, r *http.Request) { time.Sleep(200 * time.Millisecond) } } - -// Create Camera using POST request -// TODO: Add device number option -func CreateCamera(w http.ResponseWriter, r *http.Request) { - // Trigger singleton to instanciate camera - camera.CreateOrGetCamera() - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) -} - -// Delete Camera using DELETE request -// TODO: Add device number option -func DeleteCamera(w http.ResponseWriter, r *http.Request) { - camera.DeleteCamera() - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) -} diff --git a/internal/adapter/http/front/front.go b/internal/adapter/http/front/front.go new file mode 100644 index 0000000..c16b598 --- /dev/null +++ b/internal/adapter/http/front/front.go @@ -0,0 +1,25 @@ +package front + +import ( + "net/http" + "text/template" +) + +func RegisterRoutes(router *http.ServeMux) { + router.HandleFunc("/", Homepage) + router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) +} + +// Return default Homepage, a simple alpineJS application to users +func Homepage(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "text/html") + template, err := template.ParseFiles("templates/index.html.tpl") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := template.Execute(w, nil); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} diff --git a/internal/adapter/http/http.go b/internal/adapter/http/http.go index 6727d6a..c56a5cd 100644 --- a/internal/adapter/http/http.go +++ b/internal/adapter/http/http.go @@ -2,19 +2,28 @@ package http import ( "fmt" - "log/slog" + "log" "net/http" + "github.com/bugbundle/phantom/internal/adapter/http/controls" + "github.com/bugbundle/phantom/internal/adapter/http/front" "github.com/bugbundle/phantom/internal/adapter/logger" - httpRoutes "github.com/bugbundle/phantom/internal/app/http" ) -func Recovery(next http.Handler) http.Handler { +type service struct { + config config +} + +type config struct { + addr string +} + +func panicHandler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { err := recover() if err != nil { - slog.Error(fmt.Sprint(err)) + log.Print(fmt.Errorf("an error occured:\n%s", err)) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) @@ -26,25 +35,27 @@ func Recovery(next http.Handler) http.Handler { }) } -func Server(addr string) { - router := http.NewServeMux() +func NewService(addr string) *service { + return &service{ + config: config{ + addr: addr, + }, + } +} - router.HandleFunc("/", httpRoutes.Homepage) - router.HandleFunc("POST /cameras", httpRoutes.CreateCamera) - router.HandleFunc("GET /cameras/status", httpRoutes.StreamStatus) - router.HandleFunc("DELETE /cameras", httpRoutes.DeleteCamera) - router.HandleFunc("GET /cameras", httpRoutes.StreamVideo) - router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) +func (svc *service) Run() error { + router := http.NewServeMux() + controls.RegisterRoutes(router) + front.RegisterRoutes(router) - server_config := &http.Server{ - Addr: addr, + serverConfig := &http.Server{ + Addr: svc.config.addr, Handler: logger.LoggingHandler( - Recovery(router), + panicHandler(router), ), } - - slog.Info("Starting server...", "interface", addr) - if err := server_config.ListenAndServe(); err != nil { - slog.Error("An error occured !", "interface", addr, "error", err) + if err := serverConfig.ListenAndServe(); err != nil { + return fmt.Errorf("an error occured while starting the server.\n%w", err) } + return nil } diff --git a/internal/port/camera/camera.go b/internal/port/camera/camera.go index 8f9df94..e4becec 100644 --- a/internal/port/camera/camera.go +++ b/internal/port/camera/camera.go @@ -1,84 +1,70 @@ package camera import ( - "errors" "fmt" + "sync" + "github.com/bugbundle/phantom/internal/adapter/camera" "gocv.io/x/gocv" ) -type WebCamSingleton struct { - Capture *gocv.VideoCapture +type webCamSingleton struct { + capture *gocv.VideoCapture isOpen bool + mu sync.Mutex } -var instance *WebCamSingleton +var ( + instance *webCamSingleton + once sync.Once +) // CaptureImage captures an image from the webcam and returns it. -func (wc *WebCamSingleton) CaptureImage() (*gocv.Mat, error) { - if !wc.IsOpen() { - return nil, fmt.Errorf("webcam is not open") +func (w *webCamSingleton) CaptureImage() (*gocv.Mat, error) { + w.mu.Lock() + defer w.mu.Unlock() + if !w.isOpen { + return nil, fmt.Errorf("webcam unavailable") } - img := gocv.NewMat() - if ok := wc.Capture.Read(&img); !ok { - return nil, fmt.Errorf("failed to read image from webcam") + if ok := w.capture.Read(&img); !ok { + return nil, fmt.Errorf("failed to capture image") } return &img, nil } -func GetCamera() (*WebCamSingleton, error) { - if instance != nil { - return instance, nil - } - return nil, errors.New("camera is missing") -} - -func CreateOrGetCamera() *WebCamSingleton { - if instance == nil { - instance = &WebCamSingleton{} - } +func GetInstance() camera.CameraService { + once.Do(func() { + instance = &webCamSingleton{} + }) return instance } -func DeleteCamera() error { - if instance == nil { - return nil - } - - instance.Stop() - instance = nil - - return nil -} - -func (wc *WebCamSingleton) IsOpen() bool { - return wc.isOpen -} - -// Open starts the webcam capture. -func (wc *WebCamSingleton) Open() error { - if wc.isOpen { - return fmt.Errorf("webcam is already open") +// Open opens the webcam if not already open. +func (w *webCamSingleton) Open(deviceId int) error { + w.mu.Lock() + defer w.mu.Unlock() + if w.isOpen { + return fmt.Errorf("webcam already open") } - - capture, err := gocv.OpenVideoCapture(0) // Use 0 for default webcam + capture, err := gocv.OpenVideoCapture(deviceId) if err != nil { - return fmt.Errorf("error opening video capture device: %v", err) + return fmt.Errorf("error opening webcam: %v", err) } - - wc.Capture = capture - wc.isOpen = true + w.capture = capture + w.isOpen = true return nil } -// Stop stops the webcam capture. -func (wc *WebCamSingleton) Stop() { - if !wc.isOpen { - fmt.Println("webcam is not open") - return +// Close closes the webcam and releases the resource. +func (w *webCamSingleton) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.isOpen && w.capture != nil { + w.capture.Close() + w.isOpen = false + w.capture = nil } - - wc.Capture.Close() - wc.isOpen = false + // TODO: Add error management later + return nil } diff --git a/templates/index.html.tpl b/templates/index.html.tpl index 4a6b36e..ff4d2bc 100644 --- a/templates/index.html.tpl +++ b/templates/index.html.tpl @@ -19,7 +19,7 @@