From f05a1f3c777518330b2fd79af5ef1404b918291a Mon Sep 17 00:00:00 2001 From: Benjamin <8467845+import-benjamin@users.noreply.github.com> Date: Sun, 18 May 2025 22:52:14 +0200 Subject: [PATCH 1/4] refactor: improve http routes structure --- cmd/main.go | 3 +- internal/adapter/http/controls/controls.go | 101 +++++++++++++++++++ internal/adapter/http/front/front.go | 25 +++++ internal/adapter/http/http.go | 49 +++++---- internal/app/http/http.go | 112 --------------------- 5 files changed, 158 insertions(+), 132 deletions(-) create mode 100644 internal/adapter/http/controls/controls.go create mode 100644 internal/adapter/http/front/front.go 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/http/controls/controls.go b/internal/adapter/http/controls/controls.go new file mode 100644 index 0000000..7f0e365 --- /dev/null +++ b/internal/adapter/http/controls/controls.go @@ -0,0 +1,101 @@ +package controls + +import ( + "fmt" + "image" + "log" + "mime/multipart" + "net/http" + "net/textproto" + "time" + + "github.com/bugbundle/phantom/internal/port/camera" + "gocv.io/x/gocv" +) + +func RegisterRoutes(router *http.ServeMux) { + router.HandleFunc("POST /cameras", startCameraHandler) + router.HandleFunc("DELETE /cameras", stopCameraHandler) + router.HandleFunc("GET /cameras/status", StreamStatus) + router.HandleFunc("GET /cameras", StreamVideo) +} + +func startCameraHandler(w http.ResponseWriter, _ *http.Request) { + // Trigger singleton to instanciate camera + camera.CreateOrGetCamera() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) +} + +func stopCameraHandler(w http.ResponseWriter, r *http.Request) { + camera.DeleteCamera() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) +} + +func StreamStatus(w http.ResponseWriter, r *http.Request) { + // If the camera is unavailable return 428 + _, err := camera.GetCamera() + if err != nil { + w.Write([]byte("false")) + return + } + w.Write([]byte("true")) +} + +// 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) { + // 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) + } + + mimeWriter := multipart.NewWriter(w) + w.Header().Set("Content-Type", fmt.Sprintf("multipart/x-mixed-replace; boundary=%s", mimeWriter.Boundary())) + partHeader := make(textproto.MIMEHeader) + 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() + + resizedFrame := gocv.NewMat() + + gocv.Resize(*img, &resizedFrame, image.Point{X: 256, Y: 144}, 0, 0, gocv.InterpolationDefault) + + buf, err := gocv.IMEncode(gocv.JPEGFileExt, resizedFrame) + if err != nil { + log.Println("Error encoding frame: ", err) + continue + } + + partWriter, _ := mimeWriter.CreatePart(partHeader) + if _, err := partWriter.Write(buf.GetBytes()); err != nil { + log.Println("Error while processing buffer") + } + + // we want to record around 10 fps + // this mean every second send 10 images + // Let's assume reading, encoding and writing do not consume any resources + // If we sleep for 1/10 of a second we roughly approximate 10 fps + time.Sleep(200 * time.Millisecond) + } +} 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..5029b40 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%w\n", 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/app/http/http.go b/internal/app/http/http.go index 78eb1c8..d02cfda 100644 --- a/internal/app/http/http.go +++ b/internal/app/http/http.go @@ -1,113 +1 @@ package http - -import ( - "fmt" - "html/template" - "image" - "log" - "mime/multipart" - "net/http" - "net/textproto" - "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 StreamStatus(w http.ResponseWriter, r *http.Request) { - // If the camera is unavailable return 428 - _, err := camera.GetCamera() - if err != nil { - w.Write([]byte("false")) - return - } - w.Write([]byte("true")) -} - -// 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) { - // 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) - } - - mimeWriter := multipart.NewWriter(w) - w.Header().Set("Content-Type", fmt.Sprintf("multipart/x-mixed-replace; boundary=%s", mimeWriter.Boundary())) - partHeader := make(textproto.MIMEHeader) - 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() - - resizedFrame := gocv.NewMat() - - gocv.Resize(*img, &resizedFrame, image.Point{X: 256, Y: 144}, 0, 0, gocv.InterpolationDefault) - - buf, err := gocv.IMEncode(gocv.JPEGFileExt, resizedFrame) - if err != nil { - log.Println("Error encoding frame: ", err) - continue - } - - partWriter, _ := mimeWriter.CreatePart(partHeader) - if _, err := partWriter.Write(buf.GetBytes()); err != nil { - log.Println("Error while processing buffer") - } - - // we want to record around 10 fps - // this mean every second send 10 images - // Let's assume reading, encoding and writing do not consume any resources - // If we sleep for 1/10 of a second we roughly approximate 10 fps - 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) -} From 8ad57b095490f154a02131b4ec30490608b738e3 Mon Sep 17 00:00:00 2001 From: Benjamin <8467845+import-benjamin@users.noreply.github.com> Date: Sun, 18 May 2025 23:23:03 +0200 Subject: [PATCH 2/4] refactor: improve device singleton management Added back mutex lock in order to improve singleton resiliency --- internal/adapter/camera/camera.go | 8 ++ internal/adapter/http/controls/controls.go | 43 +++------- internal/adapter/http/http.go | 2 +- internal/port/camera/camera.go | 94 +++++++++------------- 4 files changed, 59 insertions(+), 88 deletions(-) diff --git a/internal/adapter/camera/camera.go b/internal/adapter/camera/camera.go index 522ea51..117394d 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() error + Close() error + CaptureImage() (*gocv.Mat, error) +} diff --git a/internal/adapter/http/controls/controls.go b/internal/adapter/http/controls/controls.go index 7f0e365..c499c24 100644 --- a/internal/adapter/http/controls/controls.go +++ b/internal/adapter/http/controls/controls.go @@ -16,49 +16,31 @@ import ( func RegisterRoutes(router *http.ServeMux) { router.HandleFunc("POST /cameras", startCameraHandler) router.HandleFunc("DELETE /cameras", stopCameraHandler) - router.HandleFunc("GET /cameras/status", StreamStatus) - router.HandleFunc("GET /cameras", StreamVideo) + router.HandleFunc("GET /cameras", StreamVideoHandler) } +// If not yet, create a camera entity func startCameraHandler(w http.ResponseWriter, _ *http.Request) { // Trigger singleton to instanciate camera - camera.CreateOrGetCamera() + device := camera.GetInstance() + device.Open() 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) { - camera.DeleteCamera() + device := camera.GetInstance() + device.Close() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) } -func StreamStatus(w http.ResponseWriter, r *http.Request) { - // If the camera is unavailable return 428 - _, err := camera.GetCamera() - if err != nil { - w.Write([]byte("false")) - return - } - w.Write([]byte("true")) -} - // 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())) @@ -66,16 +48,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() diff --git a/internal/adapter/http/http.go b/internal/adapter/http/http.go index 5029b40..c56a5cd 100644 --- a/internal/adapter/http/http.go +++ b/internal/adapter/http/http.go @@ -23,7 +23,7 @@ func panicHandler(next http.Handler) http.Handler { defer func() { err := recover() if err != nil { - log.Print(fmt.Errorf("an error occured:\n%w\n", err)) + log.Print(fmt.Errorf("an error occured:\n%s", err)) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) diff --git a/internal/port/camera/camera.go b/internal/port/camera/camera.go index 8f9df94..154afb4 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() 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(0) 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 } From f93c16c9a1634941031a8d9f028ea01977606559 Mon Sep 17 00:00:00 2001 From: Benjamin <8467845+import-benjamin@users.noreply.github.com> Date: Sun, 18 May 2025 23:40:54 +0200 Subject: [PATCH 3/4] feat(camera): add support for device Id --- internal/adapter/camera/camera.go | 2 +- internal/adapter/http/controls/controls.go | 11 ++++++++--- internal/app/http/http.go | 1 - internal/port/camera/camera.go | 4 ++-- templates/index.html.tpl | 2 +- 5 files changed, 12 insertions(+), 8 deletions(-) delete mode 100644 internal/app/http/http.go diff --git a/internal/adapter/camera/camera.go b/internal/adapter/camera/camera.go index 117394d..f6221da 100644 --- a/internal/adapter/camera/camera.go +++ b/internal/adapter/camera/camera.go @@ -3,7 +3,7 @@ package camera import "gocv.io/x/gocv" type CameraService interface { - Open() error + Open(deviceId int) error Close() error CaptureImage() (*gocv.Mat, error) } diff --git a/internal/adapter/http/controls/controls.go b/internal/adapter/http/controls/controls.go index c499c24..1218dae 100644 --- a/internal/adapter/http/controls/controls.go +++ b/internal/adapter/http/controls/controls.go @@ -7,6 +7,7 @@ import ( "mime/multipart" "net/http" "net/textproto" + "strconv" "time" "github.com/bugbundle/phantom/internal/port/camera" @@ -14,16 +15,20 @@ import ( ) func RegisterRoutes(router *http.ServeMux) { - router.HandleFunc("POST /cameras", startCameraHandler) + router.HandleFunc("POST /cameras/{deviceId}", startCameraHandler) router.HandleFunc("DELETE /cameras", stopCameraHandler) router.HandleFunc("GET /cameras", StreamVideoHandler) } // If not yet, create a camera entity -func startCameraHandler(w http.ResponseWriter, _ *http.Request) { +func startCameraHandler(w http.ResponseWriter, r *http.Request) { + g, err := strconv.Atoi(r.PathValue("deviceId")) + if err != nil { + http.Error(w, "Invalid deviceId", http.StatusNotFound) + } // Trigger singleton to instanciate camera device := camera.GetInstance() - device.Open() + device.Open(g) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) } diff --git a/internal/app/http/http.go b/internal/app/http/http.go deleted file mode 100644 index d02cfda..0000000 --- a/internal/app/http/http.go +++ /dev/null @@ -1 +0,0 @@ -package http diff --git a/internal/port/camera/camera.go b/internal/port/camera/camera.go index 154afb4..e4becec 100644 --- a/internal/port/camera/camera.go +++ b/internal/port/camera/camera.go @@ -41,13 +41,13 @@ func GetInstance() camera.CameraService { } // Open opens the webcam if not already open. -func (w *webCamSingleton) Open() error { +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) + capture, err := gocv.OpenVideoCapture(deviceId) if err != nil { return fmt.Errorf("error opening webcam: %v", err) } 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 @@