From 3daee1984c1ca2fd68c1d98d0d1e00e996d32a42 Mon Sep 17 00:00:00 2001 From: blazSmehov Date: Mon, 23 Feb 2026 14:11:01 +0100 Subject: [PATCH] chore: big refactoring job --- .woodpecker.yml | 6 + CODE_GRADE_AND_REFACTOR.md | 138 +++ CODE_REVIEW_REPORT.md | 288 ----- build/docker-compose.dev.yml | 19 +- build/docker-compose.woodpecker.yml | 27 + build/docker-compose.yaml | 13 +- cmd/bridge/main.go | 195 +--- cmd/decoder/main.go | 125 +- cmd/location/main.go | 195 +--- cmd/server/main.go | 187 +-- docs/API.md | 748 ------------ docs/DEPLOYMENT.md | 1039 ----------------- docs/Frame definition- B7,MWB01,MWC01.pdf | Bin 357203 -> 0 bytes docs/README.md | 9 - docs/REFACTORING_OVERVIEW.md | 319 +++++ go.mod | 2 + go.sum | 4 + internal/README.md | 21 - internal/app/_your_app_/.keep | 0 internal/app/bridge/app.go | 122 ++ internal/app/decoder/app.go | 91 ++ internal/app/location/app.go | 100 ++ internal/app/server/app.go | 145 +++ internal/app/server/events.go | 51 + internal/app/server/routes.go | 59 + internal/pkg/api/handler/health.go | 29 + internal/pkg/api/middleware/cors.go | 26 + internal/pkg/api/middleware/logging.go | 41 + internal/pkg/api/middleware/recovery.go | 22 + internal/pkg/api/middleware/requestid.go | 22 + internal/pkg/api/response/response.go | 55 + internal/pkg/apiclient/auth.go | 16 +- internal/pkg/apiclient/data.go | 55 +- internal/pkg/apiclient/updatedb.go | 31 +- internal/pkg/apiclient/utils.go | 28 + internal/pkg/bridge/handler.go | 76 ++ internal/pkg/bridge/mqtt.go | 61 + internal/pkg/common/appcontext/context.go | 51 +- internal/pkg/config/config.go | 104 +- internal/pkg/controller/parser_controller.go | 10 +- .../pkg/controller/settings_controller.go | 20 +- .../pkg/controller/trackers_controller.go | 18 +- internal/pkg/database/database.go | 6 +- internal/pkg/decoder/process.go | 71 ++ internal/pkg/kafkaclient/consumer.go | 10 +- internal/pkg/kafkaclient/manager.go | 24 +- internal/pkg/location/assign.go | 51 + internal/pkg/location/filter.go | 96 ++ internal/pkg/location/inference.go | 41 + internal/pkg/logger/logger.go | 12 +- internal/pkg/model/parser.go | 3 + internal/pkg/model/position.go | 13 + internal/pkg/model/trackers.go | 4 +- internal/pkg/model/types.go | 10 +- internal/pkg/service/beacon_service.go | 31 +- internal/pkg/service/parser_service.go | 7 +- internal/structure.md | 40 - scripts/README.md | 53 +- scripts/_common.sh | 2 + scripts/adddecoder.sh | 16 - scripts/api.sh | 246 ---- scripts/api/smoke_test.sh | 129 ++ scripts/api/tracks.sh | 33 + scripts/auth/token.sh | 21 + scripts/config/add_parser.sh | 19 + scripts/config/settings.sh | 18 + scripts/gatewayApi.sh | 46 - scripts/seed/seed_trackers.sh | 47 + scripts/settingsApi.sh | 13 - scripts/testAPI.sh | 19 - scripts/testalltrackers.sh | 248 ---- scripts/token.sh | 15 - scripts/trackerApi.sh | 58 - scripts/trackerzonesApi.sh | 46 - scripts/tracks.sh | 61 - scripts/zonesApi.sh | 44 - tests/TEST_SUMMARY.md | 195 +--- tests/Untitled | 1 + tests/appcontext/appcontext_test.go | 148 +++ tests/bridge/integration_test.go | 8 +- tests/bridge/mqtt_handler_test.go | 11 - tests/bridge/testutil.go | 12 + tests/config/config_test.go | 59 + tests/controller/controller_test.go | 141 +++ tests/decoder/decode_test.go | 103 +- tests/decoder/event_loop_test.go | 70 +- tests/decoder/integration_test.go | 105 +- tests/decoder/parser_registry_test.go | 215 +--- tests/e2e/e2e_test.go | 17 + tests/kafkaclient/manager_test.go | 87 ++ tests/location/location_test.go | 41 + tests/logger/logger_test.go | 28 + tests/model/model_test.go | 106 ++ tests/service/service_test.go | 99 ++ tests/utils/utils_test.go | 110 ++ 95 files changed, 3411 insertions(+), 4266 deletions(-) create mode 100644 .woodpecker.yml create mode 100644 CODE_GRADE_AND_REFACTOR.md delete mode 100644 CODE_REVIEW_REPORT.md create mode 100644 build/docker-compose.woodpecker.yml delete mode 100644 docs/API.md delete mode 100644 docs/DEPLOYMENT.md delete mode 100644 docs/Frame definition- B7,MWB01,MWC01.pdf delete mode 100644 docs/README.md create mode 100644 docs/REFACTORING_OVERVIEW.md delete mode 100644 internal/README.md delete mode 100644 internal/app/_your_app_/.keep create mode 100644 internal/app/bridge/app.go create mode 100644 internal/app/decoder/app.go create mode 100644 internal/app/location/app.go create mode 100644 internal/app/server/app.go create mode 100644 internal/app/server/events.go create mode 100644 internal/app/server/routes.go create mode 100644 internal/pkg/api/handler/health.go create mode 100644 internal/pkg/api/middleware/cors.go create mode 100644 internal/pkg/api/middleware/logging.go create mode 100644 internal/pkg/api/middleware/recovery.go create mode 100644 internal/pkg/api/middleware/requestid.go create mode 100644 internal/pkg/api/response/response.go create mode 100644 internal/pkg/apiclient/utils.go create mode 100644 internal/pkg/bridge/handler.go create mode 100644 internal/pkg/bridge/mqtt.go create mode 100644 internal/pkg/decoder/process.go create mode 100644 internal/pkg/location/assign.go create mode 100644 internal/pkg/location/filter.go create mode 100644 internal/pkg/location/inference.go create mode 100644 internal/pkg/model/position.go delete mode 100644 internal/structure.md create mode 100755 scripts/_common.sh delete mode 100755 scripts/adddecoder.sh delete mode 100644 scripts/api.sh create mode 100755 scripts/api/smoke_test.sh create mode 100755 scripts/api/tracks.sh create mode 100755 scripts/auth/token.sh create mode 100755 scripts/config/add_parser.sh create mode 100755 scripts/config/settings.sh delete mode 100755 scripts/gatewayApi.sh create mode 100755 scripts/seed/seed_trackers.sh delete mode 100755 scripts/settingsApi.sh delete mode 100755 scripts/testAPI.sh delete mode 100755 scripts/testalltrackers.sh delete mode 100755 scripts/token.sh delete mode 100755 scripts/trackerApi.sh delete mode 100755 scripts/trackerzonesApi.sh delete mode 100755 scripts/tracks.sh delete mode 100755 scripts/zonesApi.sh create mode 100644 tests/Untitled create mode 100644 tests/appcontext/appcontext_test.go create mode 100644 tests/config/config_test.go create mode 100644 tests/controller/controller_test.go create mode 100644 tests/e2e/e2e_test.go create mode 100644 tests/kafkaclient/manager_test.go create mode 100644 tests/location/location_test.go create mode 100644 tests/logger/logger_test.go create mode 100644 tests/model/model_test.go create mode 100644 tests/service/service_test.go create mode 100644 tests/utils/utils_test.go diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..405c433 --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,6 @@ +pipeline: + test: + image: golang:1.24.0 + commands: + - go mod download + - go test ./... diff --git a/CODE_GRADE_AND_REFACTOR.md b/CODE_GRADE_AND_REFACTOR.md new file mode 100644 index 0000000..8f58b4a --- /dev/null +++ b/CODE_GRADE_AND_REFACTOR.md @@ -0,0 +1,138 @@ +# Code Grade & Production Readiness Report (Updated) + +## Overall grade: **7.0 / 10** + +The codebase has been refactored into a clear app/service layout with thin `cmd` entrypoints, shared `internal/pkg` libraries, health/readiness endpoints, structured middleware, and addressed reliability/security items. It is suitable for development and staging; production use still requires CORS restriction, optional metrics/tracing, and (if desired) request validation and OpenAPI. + +--- + +## 1. What’s working well + +| Area | Notes | +|------|--------| +| **Structure** | `cmd//main.go` is thin (~25 lines); `internal/app/*` holds per-service composition; `internal/pkg` has api (response, middleware, handler), location, bridge, decoder, config, kafkaclient, logger, model, controller, service, database, apiclient, appcontext. | +| **Concurrency** | Channels, `sync.WaitGroup`, and `AppState` with RWMutex; event loops live in app layer, not in main. | +| **Shutdown** | `signal.NotifyContext` + app `Run`/`Shutdown`; Kafka and MQTT cleanup in app. | +| **Kafka** | `KafkaManager`, generic `Consume[T]`, graceful close. | +| **Observability** | `/health` and `/ready` (DB ping); middleware: logging, recovery, request ID, CORS; logging to file with fallback to stderr if file open fails. | +| **Reliability** | No panics in library code for logger (fallback to stderr); MQTT connect returns error; server init returns error; `WriteMessages` errors checked in parser service and settings controller. | +| **Security** | TLS skip verify is configurable via `TLS_INSECURE_SKIP_VERIFY` (default false). | +| **Testing** | Unit tests for appcontext, utils, model, controller, service, config; integration tests for bridge/decoder. | +| **Dependencies** | Modern stack (slog, segmentio/kafka-go, gorilla/mux, gorm). | + +--- + +## 2. Fixes applied since last report + +### 2.1 Startup and library behavior + +- **Bridge:** MQTT connect failure no longer panics; `internal/pkg/bridge/mqtt.go` returns error from `NewMQTTClient`, `cmd/bridge/main.go` exits with `log.Fatalf` on error. +- **Server:** DB and config init live in `internal/app/server`; `New`/`Init` return errors; `cmd/server/main.go` uses `log.Fatalf` on error (no panic in library). +- **Logger:** `CreateLogger` no longer uses `log.Fatalf`; on log file open failure it returns a logger that writes only to stderr and a no-op cleanup. + +### 2.2 Ignored errors + +- **parser_service.go:** `writer.WriteMessages(ctx, msg)` return value is checked and propagated. +- **settings_controller.go:** `writer.WriteMessages` error is checked; on failure returns 500 and logs; response sets `Content-Type: application/json`. +- **database:** Unused global `var DB *gorm.DB` removed. + +### 2.3 Security and configuration + +- **TLS:** `config.Config` has `TLSInsecureSkipVerify bool` (env `TLS_INSECURE_SKIP_VERIFY`, default false). Used in `apiclient.UpdateDB` and in location inference (`NewDefaultInferencer(cfg.TLSInsecureSkipVerify)`). +- **CORS:** Not changed (origin policy left to operator; middleware supports configurable origins). + +### 2.4 Observability + +- **Health/readiness:** Server exposes `/health` (liveness) and `/ready` (DB ping) via `internal/pkg/api/handler/health.go`. +- **Middleware:** Recovery (panic → 500), logging (method, path, status, duration), request ID (`X-Request-ID`), CORS. + +### 2.5 Code quality + +- **Bridge:** MQTT topic parsing uses `strings.SplitN(topic, "/", 2)` to avoid panic; CSV branch validates and logs (no writer usage yet). +- **Location:** Magic numbers moved to named constants in `internal/pkg/location/filter.go` (e.g. `SeenWeight`, `RSSIWeight`, `DefaultDistance`). +- **Duplication:** Bootstrap removed; each service uses `internal/app/` for init, run, and shutdown. + +--- + +## 3. Remaining / known limitations + +### 3.1 Config and env + +- **`getEnvPanic`** in `config` still panics on missing required env. To avoid panics in library, consider a `LoadServerSafe` (or similar) that returns `(*Config, error)` and use it only from `main` with explicit exit. Not changed in this pass. + +### 3.2 Security + +- **CORS:** Defaults remain permissive (e.g. `*`). Restrict to known frontend origins when deploying (e.g. via env or config). +- **Secrets:** Still loaded from env only; ensure no secrets in logs; consider a secret manager for production. + +### 3.3 API and validation + +- No OpenAPI/Swagger; no formal request/response contracts. +- Many handlers still use `http.Error` or `w.Write` without a single response helper; `api/response` exists for new/consistent endpoints. +- No request body validation (e.g. go-playground/validator); no idempotency keys. + +### 3.4 Resilience and operations + +- Kafka consumer: on `ReadMessage`/unmarshal error, logs and continues; no dead-letter or backoff yet. +- DB: no documented pool tuning; readiness only checks DB ping. +- No metrics (Prometheus/OpenTelemetry). No distributed tracing. + +--- + +## 4. Grade breakdown (updated) + +| Criterion | Score | Comment | +|---------------------|-------|--------| +| Architecture | 8/10 | Clear app layer, thin main, pkg separation; handlers still take concrete DB/writer (can be abstracted later). | +| Reliability | 7/10 | No panics in logger/bridge init; WriteMessages errors handled; health/ready; logger fallback. | +| Security | 6/10 | TLS skip verify configurable (default off); CORS still broad; secrets in env. | +| Observability | 7/10 | Health/ready, request logging, request ID, recovery; no metrics/tracing. | +| API design | 6/10 | Response helpers and middleware in place; many handlers still ad-hoc; no spec/validation. | +| Testing | 6/10 | Good unit coverage; more integration/E2E would help. | +| Code quality | 8/10 | Clear structure, constants for magic numbers, dead code removed, duplication reduced. | +| Production readiness | 6/10 | Health/ready and error handling in place; CORS, metrics, and validation still to do. | + +**Average ≈ 6.75; grade 7.0/10** – Refactor and applied fixes significantly improve structure, reliability, and observability; remaining work is mostly CORS, validation, and metrics/tracing. + +--- + +## 5. Checklist (updated) + +### 5.1 Reliability + +- [x] Remove panics / `log.Fatalf` from library where possible (logger fallback; bridge returns error). +- [x] Check and handle `WriteMessages` in parser service and settings controller. +- [x] Add `/health` and `/ready` on server. +- [ ] Document or add Kafka consumer retry/backoff and dead-letter if needed. +- [x] Make TLS skip verify configurable; default false. + +### 5.2 Observability + +- [x] Structured logging and request ID middleware. +- [ ] Add metrics (e.g. Prometheus) and optional tracing. + +### 5.3 API and validation + +- [ ] OpenAPI spec and validation. +- [ ] Consistent use of `api/response` and JSON error body across handlers. +- [ ] Restrict CORS to specific origins (operator-defined). + +### 5.4 Operations + +- [ ] Document env vars and deployment topology. +- [ ] Configurable timeouts; rate limiting if required. + +### 5.5 Code and structure + +- [x] Bridge topic parsing and CSV branch behavior clarified. +- [x] Unused `database.DB` global removed. +- [x] Location magic numbers moved to constants. +- [x] App layer and api/middleware/response in place. + +--- + +## 6. Summary + +- **Grade: 7.0/10** – Refactor and targeted fixes improve structure, reliability, and observability. Server has health/ready, middleware, and no panics in logger/bridge init; TLS skip verify is configurable; WriteMessages and logger errors are handled. +- **Still to do for production:** Restrict CORS, add metrics (and optionally tracing), validate requests and adopt consistent API responses, and document operations. Config loading can be made panic-free by adding safe loaders that return errors. +- **Not changed by design:** CORS policy left for operator to configure (e.g. via env or config). diff --git a/CODE_REVIEW_REPORT.md b/CODE_REVIEW_REPORT.md deleted file mode 100644 index 63d3d9c..0000000 --- a/CODE_REVIEW_REPORT.md +++ /dev/null @@ -1,288 +0,0 @@ -# Code Review Report: AFASystems Presence Services - -**Review Date:** February 11, 2025 -**Scope:** Four services (bridge, server, location, decoder) and their internal packages -**Reviewer:** Automated Code Review - ---- - -## Executive Summary - -This report reviews the custom packages used across the four main services of the presence detection system. The codebase demonstrates a coherent architecture with clear separation of concerns, but several reliability issues, unused code paths, and refactoring opportunities were identified. - -**Overall Rating: 6.5/10** - ---- - -## 1. Package Inventory by Service - -### Bridge (`cmd/bridge/main.go`) - -| Package | Purpose | -| -------------------------------- | -------------------------------------------------- | -| `internal/pkg/common/appcontext` | Shared application state (beacon lookup, settings) | -| `internal/pkg/config` | Environment-based configuration | -| `internal/pkg/kafkaclient` | Kafka consumer/producer management | -| `internal/pkg/logger` | Structured logging setup | -| `internal/pkg/model` | Data structures | - -### Server (`cmd/server/main.go`) - -| Package | Purpose | -| -------------------------------- | --------------------------------------------- | -| `internal/pkg/apiclient` | External API authentication and data fetching | -| `internal/pkg/common/appcontext` | Shared application state | -| `internal/pkg/config` | Configuration | -| `internal/pkg/controller` | HTTP handlers for REST API | -| `internal/pkg/database` | PostgreSQL connection (GORM) | -| `internal/pkg/kafkaclient` | Kafka management | -| `internal/pkg/logger` | Logging | -| `internal/pkg/model` | Data structures | -| `internal/pkg/service` | Business logic (location, parser) | - -### Location (`cmd/location/main.go`) - -| Package | Purpose | -| -------------------------------- | ---------------------- | -| `internal/pkg/common/appcontext` | Beacon state, settings | -| `internal/pkg/common/utils` | Distance calculation | -| `internal/pkg/config` | Configuration | -| `internal/pkg/kafkaclient` | Kafka management | -| `internal/pkg/logger` | Logging | -| `internal/pkg/model` | Data structures | - -### Decoder (`cmd/decoder/main.go`) - -| Package | Purpose | -| -------------------------------- | ---------------------------------- | -| `internal/pkg/common/appcontext` | Beacon events state | -| `internal/pkg/common/utils` | AD structure parsing, flag removal | -| `internal/pkg/config` | Configuration | -| `internal/pkg/kafkaclient` | Kafka management | -| `internal/pkg/logger` | Logging | -| `internal/pkg/model` | Data structures, parser registry | - ---- - -## 2. Critical Issues (Must Fix) - -### 2.1 Tracker Delete Method Case Mismatch (Bug) - -Resolved - -### 2.2 Potential Panic in Location Algorithm - -**Location:** `cmd/location/main.go:99-101` - -Resolved - -### 2.3 Hardcoded Config Path - -**Location:** `cmd/server/main.go:60` - -```go -configFile, err := os.Open("/app/cmd/server/config.json") -``` - -This path is Docker-specific and fails in local development or other deployment environments. - -**Fix:** Use configurable path (e.g., `CONFIG_PATH` env var) or relative path based on executable location. - ---- - -## 3. Security Concerns - -### 3.1 Hardcoded Credentials - -**Locations:** - -- `internal/pkg/config/config.go`: Default values include `ClientSecret`, `HTTPPassword` with production-like strings -- `internal/pkg/apiclient/auth.go`: GetToken() hardcodes credentials in formData (lines 21-24) instead of using `cfg.HTTPClientID`, `cfg.ClientSecret`, etc. -- Config struct has `HTTPClientID`, `ClientSecret`, `HTTPUsername`, etc., but `auth.go` ignores them - -**Recommendation:** Wire config values into auth; never commit production credentials. - -### 3.2 TLS Verification Disabled - -**Location:** `internal/pkg/apiclient/updatedb.go:21-22` - -```go -TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, -``` - -**Recommendation:** Use proper certificates or make this configurable for dev only. - ---- - -## 4. Unused Code & Directories - -### 4.1 Orphaned Packages (Not Imported) - -Resolved - -### 4.2 Unused Functions / Methods - -Resolved - -### 4.3 Dead Code in bridge/mqtthandler - -**Location:** `internal/pkg/bridge/mqtthandler/mqtthandler.go:75-83` - -Resolved - -### 4.4 Unnecessary Compile-Time Assertion - -**Location:** `cmd/server/main.go:31` - -```go -var _ io.Writer = (*os.File)(nil) -``` - -Redundant; `*os.File` implements `io.Writer`. Safe to remove. - -### 4.5 Unused go.mod Dependencies - -| Package | Notes | -| ------------------------------ | ------------------------------- | -| `github.com/boltdb/bolt` | Not imported in any source file | -| `github.com/yosssi/gmq` | Not imported | -| `github.com/gorilla/websocket` | Not imported | - -**Recommendation:** Run `go mod tidy` after removing dead imports, or explicitly remove if kept for future use. - ---- - -## 5. Reliability & Error Handling - -### 5.1 Kafka Consumer Error Handling - -**Location:** `internal/pkg/kafkaclient/consumer.go:20-23` - -On `ReadMessage` or `Unmarshal` error, the consumer logs and continues. For `context.Canceled` or partition errors, this may cause tight loops. Consider backoff or bounded retries. - -### 5.2 KafkaManager GetReader/GetWriter Lock Usage - -**Location:** `internal/pkg/kafkaclient/manager.go:101-111` - -`GetReader` and `GetWriter` hold the lock for the entire call including return. If the returned pointer is used after the lock is released, that's fine, but the pattern holds the lock longer than necessary. Prefer: - -```go -func (m *KafkaManager) GetReader(topic string) *kafka.Reader { - m.kafkaReadersMap.KafkaReadersLock.RLock() - defer m.kafkaReadersMap.KafkaReadersLock.RUnlock() - return m.kafkaReadersMap.KafkaReaders[topic] -} -``` - -Use `RLock` for read-only access. - -### 5.3 Logger File Handle Leak - -**Location:** `internal/pkg/logger/logger.go` - -The opened file `f` is never closed. For long-running processes this is usually acceptable (log files stay open), but worth documenting. If multiple loggers are created, each holds a file descriptor. - -### 5.4 Silent JSON Unmarshal - -**Location:** `cmd/server/main.go:68` - -```go -json.Unmarshal(b, &configs) -``` - -Error is ignored. Invalid JSON would leave `configs` empty without feedback. - ---- - -## 6. Code Quality & Maintainability - -### 6.1 Inconsistent Logging - -- Mix of `log.Printf`, `fmt.Println`, `fmt.Printf`, and `slog.Info/Error` -- Italian message: "Messaggio CSV non valido" in bridge -- Typo: "Beggining" → "Beginning" (bridge, location, decoder, server) - -### 6.2 Magic Numbers - -- Channel sizes: 200, 500, 2000 without named constants -- RSSI weights in location: `seenW := 1.5`, `rssiW := 0.75` -- Ticker intervals: 1s, 2s without configuration - -### 6.3 Duplication - -- Bridge defines `mqtthandler` inline while `internal/pkg/bridge/mqtthandler` exists with similar logic -- Both use `appcontext.BeaconExists` for lookup; bridge version also sets `adv.ID` from lookup - -### 6.4 Parser ID Inconsistency - -**Decoder** expects `msg.ID` values: `"add"`, `"delete"`, `"update"`. -**ParserDeleteController** sends `ID: "delete"` ✓ -**ParserAddController** sends `ID: "add"` ✓ -**ParserUpdateController** sends `ID: "update"` ✓ - -Decoder’s update case re-registers; add and update are effectively the same. - ---- - -## 7. AppContext Thread Safety - -`AppState` mixes safe and unsafe access: - -- `beaconsLookup` (map) has no mutex; `AddBeaconToLookup`, `RemoveBeaconFromLookup`, `CleanLookup`, `BeaconExists` are not thread-safe -- Bridge goroutines (Kafka consumers + event loop) and MQTT handler may access it concurrently - -**Recommendation:** Protect `beaconsLookup` with `sync.RWMutex` or use `sync.Map`. - ---- - -## 8. Refactoring Suggestions - -1. **Unify config loading:** Support JSON config file path via env; keep env overrides for sensitivity. -2. **Extract constants:** Kafka topic names, channel sizes, ticker intervals. -3. **Consolidate MQTT handling:** Use `internal/pkg/bridge/mqtthandler` and fix it, or remove the package and keep logic in bridge. -4. **API client:** Use config for URLs and credentials; add timeouts to HTTP client. -5. **Controllers:** Add request validation, consistent error responses, and structured error types. -6. **Service layer:** `formatMac` in `beacon_service.go` could move to `internal/pkg/common/utils` for reuse and testing. - ---- - -## 9. Directory Structure Notes - -| Directory | Status | -| ------------------------------------------ | ----------------------------------------------------- | -| `internal/app/_your_app_/` | Placeholder with `.keep`; safe to remove or repurpose | -| `internal/pkg/model/.keep` | Placeholder; low impact | -| `web/app/`, `web/static/`, `web/template/` | Empty except `.keep`; clarify if planned | -| `build/package/` | Contains Dockerfiles; structure is reasonable | - ---- - -## 10. Summary of Recommendations - -| Priority | Action | -| -------- | ------------------------------------------------------------------ | -| **P0** | Fix TrackerDelete `"Delete"` → `"DELETE"` | -| **P0** | Guard empty `BeaconMetrics` in location | -| **P1** | Make config.json path configurable | -| **P1** | Fix `beaconsLookup` concurrency in AppState | -| **P2** | Remove or integrate `internal/pkg/redis` and `internal/pkg/bridge` | -| **P2** | Remove unused functions (ValidateRSSI, EventToBeaconService, etc.) | -| **P2** | Replace hardcoded credentials in apiclient with config | -| **P3** | Unify logging (slog), fix typos, extract constants | -| **P3** | Run `go mod tidy` and drop unused dependencies | - ---- - -## 11. Rating Breakdown - -| Category | Score | Notes | -| ---------------- | ----- | ----------------------------------------------------------------------- | -| Architecture | 7/10 | Clear service boundaries; some shared-state issues | -| Reliability | 5/10 | Critical bugs (case mismatch, panic risk); error handling could improve | -| Security | 4/10 | Hardcoded credentials; disabled TLS verification | -| Maintainability | 6/10 | Duplication, magic numbers, inconsistent logging | -| Code Cleanliness | 5/10 | Unused code, dead packages, redundant assertions | - -**Overall: 6.5/10** - -The system has a solid foundation and sensible separation of concerns. Addressing the critical bugs, security issues, and removing dead code would materially improve reliability and maintainability. diff --git a/build/docker-compose.dev.yml b/build/docker-compose.dev.yml index c683b11..2c73db6 100644 --- a/build/docker-compose.dev.yml +++ b/build/docker-compose.dev.yml @@ -5,7 +5,7 @@ services: container_name: db restart: always ports: - - "127.0.0.1:5432:5432" + - "127.0.0.1:5432:5432" environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres @@ -85,7 +85,7 @@ services: condition: service_healthy restart: always volumes: - - ../:/app + - ../:/app command: air --build.cmd "go build -buildvcs=false -o /tmp/decoder ./cmd/decoder" --build.bin "/tmp/decoder" presense-server: @@ -101,6 +101,14 @@ services: - DBUser=postgres - DBPass=postgres - DBName=postgres + - HTTPClientID=Fastapi + - ClientSecret=wojuoB7Z5xhlPFrF2lIxJSSdVHCApEgC + - HTTPUsername=core + - HTTPPassword=C0r3_us3r_Cr3d3nt14ls + - HTTPAudience=Fastapi + - HTTPADDR=0.0.0.0:1902 + - CONFIG_PATH=/app/cmd/server/config.json + - API_BASE_URL=https://10.251.0.30:5050 ports: - "127.0.0.1:1902:1902" depends_on: @@ -112,7 +120,7 @@ services: condition: service_healthy restart: always volumes: - - ../:/app + - ../:/app command: air --build.cmd "go build -buildvcs=false -o /tmp/server ./cmd/server" --build.bin "/tmp/server" presense-bridge: @@ -126,6 +134,7 @@ services: - MQTT_HOST=192.168.1.101 - MQTT_USERNAME=user - MQTT_PASSWORD=pass + - MQTT_CLIENT_ID=bridge depends_on: kafka-init: condition: service_completed_successfully @@ -133,7 +142,7 @@ services: condition: service_healthy restart: always volumes: - - ../:/app + - ../:/app command: air --build.cmd "go build -buildvcs=false -o /tmp/bridge ./cmd/bridge" --build.bin "/tmp/bridge" presense-location: @@ -151,7 +160,7 @@ services: condition: service_healthy restart: always volumes: - - ../:/app + - ../:/app command: air --build.cmd "go build -buildvcs=false -o /tmp/location ./cmd/location" --build.bin "/tmp/location" diff --git a/build/docker-compose.woodpecker.yml b/build/docker-compose.woodpecker.yml new file mode 100644 index 0000000..bf2c876 --- /dev/null +++ b/build/docker-compose.woodpecker.yml @@ -0,0 +1,27 @@ +services: + woodpecker-server: + image: woodpeckerci/woodpecker-server:v1.0.2 + ports: + - 8000:8000 + volumes: + - woodpecker-data:/var/lib/woodpecker + environment: + - WOODPECKER_GITEA=true + - WOODPECKER_OPEN=true + - WOODPECKER_ADMIN=Smehov + - WOODPECKER_GITEA_URL=https://git.afasystems.it/ + - WOODPECKER_GITEA_CLIENT=005e1420-b635-4d82-ac4b-70bfd61746ed + - WOODPECKER_GITEA_SECRET=rsfZ5jD4UcmrSl9mqutnHgO2eXBN-i8qNa-hil2SuMw= + - WOODPECKER_AGENT_SECRET=agent-secret + - WOODPECKER_HOST=http://10.8.0.53:8000 + + woodpecker-agent: + image: woodpeckerci/woodpecker-agent:v1.0.2 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + - WOODPECKER_SERVER=woodpecker-server:9000 + - WOODPECKER_AGENT_SECRET=agent-secret + +volumes: + woodpecker-data: \ No newline at end of file diff --git a/build/docker-compose.yaml b/build/docker-compose.yaml index 05cd347..0f85a0d 100644 --- a/build/docker-compose.yaml +++ b/build/docker-compose.yaml @@ -5,7 +5,7 @@ services: container_name: db restart: always ports: - - "127.0.0.1:5432:5432" + - "127.0.0.1:5432:5432" environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres @@ -92,12 +92,20 @@ services: image: presense-server container_name: presense-server environment: - - VALKEY_URL=valkey:6379 - KAFKA_URL=kafka:29092 - DBHost=db - DBUser=postgres - DBPass=postgres - DBName=postgres + - HTTPClientID=Fastapi + - ClientSecret=wojuoB7Z5xhlPFrF2lIxJSSdVHCApEgC + - HTTPUsername=core + - HTTPPassword=C0r3_us3r_Cr3d3nt14ls + - HTTPAudience=Fastapi + - HTTPADDR=0.0.0.0:1902 + - CONFIG_PATH=/app/cmd/server/config.json + - API_BASE_URL=https://10.251.0.30:5050 + - API_AUTH_URL=https://10.251.0.30:10002 ports: - "127.0.0.1:1902:1902" depends_on: @@ -120,6 +128,7 @@ services: - MQTT_HOST=192.168.1.101 - MQTT_USERNAME=user - MQTT_PASSWORD=pass + - MQTT_CLIENT_ID=bridge depends_on: kafka-init: condition: service_completed_successfully diff --git a/cmd/bridge/main.go b/cmd/bridge/main.go index 7a6d7d3..ab04557 100644 --- a/cmd/bridge/main.go +++ b/cmd/bridge/main.go @@ -2,205 +2,24 @@ package main import ( "context" - "encoding/json" - "fmt" "log" - "log/slog" "os/signal" - "strings" - "sync" "syscall" - "time" - "github.com/AFASystems/presence/internal/pkg/common/appcontext" + "github.com/AFASystems/presence/internal/app/bridge" "github.com/AFASystems/presence/internal/pkg/config" - "github.com/AFASystems/presence/internal/pkg/kafkaclient" - "github.com/AFASystems/presence/internal/pkg/logger" - "github.com/AFASystems/presence/internal/pkg/model" - mqtt "github.com/eclipse/paho.mqtt.golang" - "github.com/google/uuid" - "github.com/segmentio/kafka-go" ) -var wg sync.WaitGroup - -func mqtthandler(writer *kafka.Writer, topic string, message []byte, appState *appcontext.AppState) { - hostname := strings.Split(topic, "/")[1] - msgStr := string(message) - - if strings.HasPrefix(msgStr, "[") { - var readings []model.RawReading - err := json.Unmarshal(message, &readings) - if err != nil { - log.Printf("Error parsing JSON: %v", err) - return - } - - for _, reading := range readings { - if reading.Type == "Gateway" { - continue - } - - val, ok := appState.BeaconExists(reading.MAC) - // fmt.Printf("reading: %+v\n", reading) - if !ok { - continue - } - - adv := model.BeaconAdvertisement{ - ID: val, - Hostname: hostname, - MAC: reading.MAC, - RSSI: int64(reading.RSSI), - Data: reading.RawData, - } - - encodedMsg, err := json.Marshal(adv) - if err != nil { - fmt.Println("Error in marshaling: ", err) - break - } - - msg := kafka.Message{ - Value: encodedMsg, - } - - err = writer.WriteMessages(context.Background(), msg) - if err != nil { - fmt.Println("Error in writing to Kafka: ", err) - time.Sleep(1 * time.Second) - break - } - } - } else { - s := strings.Split(string(message), ",") - if len(s) < 6 { - log.Printf("Messaggio CSV non valido: %s", msgStr) - return - } - - fmt.Println("this gateway is also sending data: ", s) - } -} - -var messagePubHandler = func(msg mqtt.Message, writer *kafka.Writer, appState *appcontext.AppState) { - mqtthandler(writer, msg.Topic(), msg.Payload(), appState) -} - -var connectHandler mqtt.OnConnectHandler = func(client mqtt.Client) { - fmt.Println("Connected") -} - -var connectLostHandler mqtt.ConnectionLostHandler = func(client mqtt.Client, err error) { - fmt.Printf("Connect lost: %v", err) -} - func main() { - // Load global context to init beacons and latest list - appState := appcontext.NewAppState() - cfg := config.Load() - kafkaManager := kafkaclient.InitKafkaManager() - - // Set logger -> terminal and log file - slog.SetDefault(logger.CreateLogger("bridge.log")) - - // define context ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer stop() - readerTopics := []string{"apibeacons", "alert", "mqtt"} - kafkaManager.PopulateKafkaManager(cfg.KafkaURL, "bridge", readerTopics) - - writerTopics := []string{"rawbeacons"} - kafkaManager.PopulateKafkaManager(cfg.KafkaURL, "", writerTopics) - - slog.Info("Bridge initialized, subscribed to kafka topics") - - chApi := make(chan model.ApiUpdate, 200) - chAlert := make(chan model.Alert, 200) - chMqtt := make(chan []model.Tracker, 200) - - wg.Add(3) - go kafkaclient.Consume(kafkaManager.GetReader("apibeacons"), chApi, ctx, &wg) - go kafkaclient.Consume(kafkaManager.GetReader("alert"), chAlert, ctx, &wg) - go kafkaclient.Consume(kafkaManager.GetReader("mqtt"), chMqtt, ctx, &wg) - - opts := mqtt.NewClientOptions() - opts.AddBroker(fmt.Sprintf("tcp://%s:%d", cfg.MQTTHost, 1883)) - - cId := fmt.Sprintf("bridge-%s", uuid.New().String()) - - opts.SetClientID(cId) - opts.SetAutoReconnect(true) - opts.SetConnectRetry(true) - opts.SetConnectRetryInterval(1 * time.Second) - opts.SetMaxReconnectInterval(600 * time.Second) - opts.SetCleanSession(false) - - opts.SetDefaultPublishHandler(func(c mqtt.Client, m mqtt.Message) { - messagePubHandler(m, kafkaManager.GetWriter("rawbeacons"), appState) - }) - opts.OnConnect = connectHandler - opts.OnConnectionLost = connectLostHandler - client := mqtt.NewClient(opts) - if token := client.Connect(); token.Wait() && token.Error() != nil { - panic(token.Error()) - } - - sub(client) - -eventloop: - for { - select { - case <-ctx.Done(): - break eventloop - case msg := <-chApi: - switch msg.Method { - case "POST": - id := msg.ID - appState.AddBeaconToLookup(msg.MAC, id) - lMsg := fmt.Sprintf("Beacon added to lookup: %s", id) - slog.Info(lMsg) - case "DELETE": - id := msg.MAC - if id == "all" { - appState.CleanLookup() - fmt.Println("cleaned up lookup map") - continue - } - appState.RemoveBeaconFromLookup(id) - lMsg := fmt.Sprintf("Beacon removed from lookup: %s", id) - slog.Info(lMsg) - } - case msg := <-chAlert: - p, err := json.Marshal(msg) - if err != nil { - continue - } - client.Publish("/alerts", 0, true, p) - case msg := <-chMqtt: - p, err := json.Marshal(msg) - if err != nil { - continue - } - client.Publish("/trackers", 0, true, p) - } + cfg := config.LoadBridge() + app, err := bridge.New(cfg) + if err != nil { + log.Fatalf("bridge: %v", err) } - slog.Info("broken out of the main event loop") - wg.Wait() - - slog.Info("All go routines have stopped, Beggining to close Kafka connections") - kafkaManager.CleanKafkaReaders() - kafkaManager.CleanKafkaWriters() - - client.Disconnect(250) - slog.Info("Closing connection to MQTT broker") -} - -func sub(client mqtt.Client) { - topic := "publish_out/#" - token := client.Subscribe(topic, 1, nil) - token.Wait() - fmt.Printf("Subscribed to topic: %s\n", topic) + app.Run(ctx) + app.Shutdown() } diff --git a/cmd/decoder/main.go b/cmd/decoder/main.go index fc296d6..1dd6386 100644 --- a/cmd/decoder/main.go +++ b/cmd/decoder/main.go @@ -1,136 +1,25 @@ package main import ( - "bytes" "context" - "encoding/hex" - "fmt" - "log/slog" + "log" "os/signal" - "strings" - "sync" "syscall" - "github.com/AFASystems/presence/internal/pkg/common/appcontext" - "github.com/AFASystems/presence/internal/pkg/common/utils" + "github.com/AFASystems/presence/internal/app/decoder" "github.com/AFASystems/presence/internal/pkg/config" - "github.com/AFASystems/presence/internal/pkg/kafkaclient" - "github.com/AFASystems/presence/internal/pkg/logger" - "github.com/AFASystems/presence/internal/pkg/model" - "github.com/segmentio/kafka-go" ) -var wg sync.WaitGroup - func main() { - // Load global context to init beacons and latest list - appState := appcontext.NewAppState() - cfg := config.Load() - kafkaManager := kafkaclient.InitKafkaManager() - - parserRegistry := model.ParserRegistry{ - ParserList: make(map[string]model.BeaconParser), - } - - // Set logger -> terminal and log file - slog.SetDefault(logger.CreateLogger("decoder.log")) - - // define context ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer stop() - readerTopics := []string{"rawbeacons", "parser"} - kafkaManager.PopulateKafkaManager(cfg.KafkaURL, "decoder", readerTopics) - - writerTopics := []string{"alertbeacons"} - kafkaManager.PopulateKafkaManager(cfg.KafkaURL, "", writerTopics) - - slog.Info("Decoder initialized, subscribed to Kafka topics") - - chRaw := make(chan model.BeaconAdvertisement, 2000) - chParser := make(chan model.KafkaParser, 200) - - wg.Add(3) - go kafkaclient.Consume(kafkaManager.GetReader("rawbeacons"), chRaw, ctx, &wg) - go kafkaclient.Consume(kafkaManager.GetReader("parser"), chParser, ctx, &wg) - -eventloop: - for { - select { - case <-ctx.Done(): - break eventloop - case msg := <-chRaw: - processIncoming(msg, appState, kafkaManager.GetWriter("alertbeacons"), &parserRegistry) - case msg := <-chParser: - switch msg.ID { - case "add": - config := msg.Config - parserRegistry.Register(config.Name, config) - case "delete": - parserRegistry.Unregister(msg.Name) - case "update": - config := msg.Config - parserRegistry.Register(config.Name, config) - } - } - } - - slog.Info("broken out of the main event loop") - wg.Wait() - - slog.Info("All go routines have stopped, Beggining to close Kafka connections") - kafkaManager.CleanKafkaReaders() - kafkaManager.CleanKafkaWriters() -} - -func processIncoming(adv model.BeaconAdvertisement, appState *appcontext.AppState, writer *kafka.Writer, parserRegistry *model.ParserRegistry) { - err := decodeBeacon(adv, appState, writer, parserRegistry) - if err != nil { - eMsg := fmt.Sprintf("Error in decoding: %v", err) - fmt.Println(eMsg) - return - } -} - -func decodeBeacon(adv model.BeaconAdvertisement, appState *appcontext.AppState, writer *kafka.Writer, parserRegistry *model.ParserRegistry) error { - beacon := strings.TrimSpace(adv.Data) - id := adv.ID - if beacon == "" { - return nil - } - - b, err := hex.DecodeString(beacon) + cfg := config.LoadDecoder() + app, err := decoder.New(cfg) if err != nil { - return err - } - - b = utils.RemoveFlagBytes(b) - - indeces := utils.ParseADFast(b) - event := utils.LoopADStructures(b, indeces, id, parserRegistry) - - if event.ID == "" { - return nil - } - prevEvent, ok := appState.GetBeaconEvent(id) - appState.UpdateBeaconEvent(id, event) - - if event.Type == "iBeacon" { - event.BtnPressed = true - } - - if ok && bytes.Equal(prevEvent.Hash(), event.Hash()) { - return nil - } - - eMsg, err := event.ToJSON() - if err != nil { - return err - } - - if err := writer.WriteMessages(context.Background(), kafka.Message{Value: eMsg}); err != nil { - return err + log.Fatalf("decoder: %v", err) } - return nil + app.Run(ctx) + app.Shutdown() } diff --git a/cmd/location/main.go b/cmd/location/main.go index b98ecbd..018d879 100644 --- a/cmd/location/main.go +++ b/cmd/location/main.go @@ -2,203 +2,24 @@ package main import ( "context" - "encoding/json" - "fmt" - "log/slog" + "log" "os/signal" - "sync" "syscall" - "time" - "github.com/AFASystems/presence/internal/pkg/common/appcontext" - "github.com/AFASystems/presence/internal/pkg/common/utils" + "github.com/AFASystems/presence/internal/app/location" "github.com/AFASystems/presence/internal/pkg/config" - "github.com/AFASystems/presence/internal/pkg/kafkaclient" - "github.com/AFASystems/presence/internal/pkg/logger" - "github.com/AFASystems/presence/internal/pkg/model" - "github.com/segmentio/kafka-go" ) -var wg sync.WaitGroup - func main() { - // Load global context to init beacons and latest list - appState := appcontext.NewAppState() - cfg := config.Load() - kafkaManager := kafkaclient.InitKafkaManager() - - // Set logger -> terminal and log file - slog.SetDefault(logger.CreateLogger("location.log")) - - // Define context ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer stop() - readerTopics := []string{"rawbeacons", "settings"} - kafkaManager.PopulateKafkaManager(cfg.KafkaURL, "location", readerTopics) - - writerTopics := []string{"locevents"} - kafkaManager.PopulateKafkaManager(cfg.KafkaURL, "", writerTopics) - - slog.Info("Locations algorithm initialized, subscribed to Kafka topics") - - locTicker := time.NewTicker(1 * time.Second) - defer locTicker.Stop() - - chRaw := make(chan model.BeaconAdvertisement, 2000) - chSettings := make(chan map[string]any, 5) - - wg.Add(3) - go kafkaclient.Consume(kafkaManager.GetReader("rawbeacons"), chRaw, ctx, &wg) - go kafkaclient.Consume(kafkaManager.GetReader("settings"), chSettings, ctx, &wg) - -eventLoop: - for { - select { - case <-ctx.Done(): - break eventLoop - case <-locTicker.C: - settings := appState.GetSettings() - fmt.Printf("Settings: %+v\n", settings) - switch settings.CurrentAlgorithm { - case "filter": - getLikelyLocations(appState, kafkaManager.GetWriter("locevents")) - case "ai": - fmt.Println("AI algorithm selected") - } - case msg := <-chRaw: - assignBeaconToList(msg, appState) - case msg := <-chSettings: - fmt.Printf("settings msg: %+v\n", msg) - appState.UpdateSettings(msg) - } - } - - slog.Info("broken out of the main event loop") - wg.Wait() - - slog.Info("All go routines have stopped, Beggining to close Kafka connections") - kafkaManager.CleanKafkaReaders() - kafkaManager.CleanKafkaWriters() -} - -func getLikelyLocations(appState *appcontext.AppState, writer *kafka.Writer) { - beacons := appState.GetAllBeacons() - settings := appState.GetSettingsValue() - - for _, beacon := range beacons { - // Shrinking the model because other properties have nothing to do with the location - r := model.HTTPLocation{ - Method: "Standard", - Distance: 999, - ID: beacon.ID, - Location: "", - LastSeen: 999, - } - - mSize := len(beacon.BeaconMetrics) - - if mSize == 0 { - continue - } - - if (int64(time.Now().Unix()) - (beacon.BeaconMetrics[mSize-1].Timestamp)) > settings.LastSeenThreshold { - slog.Warn("beacon is too old") - continue - } - - locList := make(map[string]float64) - seenW := 1.5 - rssiW := 0.75 - for _, metric := range beacon.BeaconMetrics { - res := seenW + (rssiW * (1.0 - (float64(metric.RSSI) / -100.0))) - locList[metric.Location] += res - } - - bestLocName := "" - maxScore := 0.0 - for locName, score := range locList { - if score > maxScore { - maxScore = score - bestLocName = locName - } - } - - if bestLocName == beacon.PreviousLocation { - beacon.LocationConfidence++ - } else { - beacon.LocationConfidence = 0 - } - - r.Distance = beacon.BeaconMetrics[mSize-1].Distance - r.Location = bestLocName - r.LastSeen = beacon.BeaconMetrics[mSize-1].Timestamp - r.RSSI = beacon.BeaconMetrics[mSize-1].RSSI - - if beacon.LocationConfidence == settings.LocationConfidence && beacon.PreviousConfidentLocation != bestLocName { - beacon.LocationConfidence = 0 - } - - beacon.PreviousLocation = bestLocName - appState.UpdateBeacon(beacon.ID, beacon) - - js, err := json.Marshal(r) - if err != nil { - eMsg := fmt.Sprintf("Error in marshaling location: %v", err) - slog.Error(eMsg) - continue - } - - msg := kafka.Message{ - Value: js, - } - - err = writer.WriteMessages(context.Background(), msg) - if err != nil { - eMsg := fmt.Sprintf("Error in sending Kafka message: %v", err) - slog.Error(eMsg) - } - } -} - -func assignBeaconToList(adv model.BeaconAdvertisement, appState *appcontext.AppState) { - id := adv.ID - now := time.Now().Unix() - - settings := appState.GetSettingsValue() - - if settings.RSSIEnforceThreshold && (int64(adv.RSSI) < settings.RSSIMinThreshold) { - slog.Info("Settings returns") - return - } - - beacon, ok := appState.GetBeacon(id) - if !ok { - beacon = model.Beacon{ - ID: id, - } - } - - beacon.IncomingJSON = adv - beacon.LastSeen = now - - if beacon.BeaconMetrics == nil { - beacon.BeaconMetrics = make([]model.BeaconMetric, 0, settings.BeaconMetricSize) - } - - metric := model.BeaconMetric{ - Distance: utils.CalculateDistance(adv), - Timestamp: now, - RSSI: int64(adv.RSSI), - Location: adv.Hostname, - } - - if len(beacon.BeaconMetrics) >= settings.BeaconMetricSize { - copy(beacon.BeaconMetrics, beacon.BeaconMetrics[1:]) - beacon.BeaconMetrics[settings.BeaconMetricSize-1] = metric - } else { - beacon.BeaconMetrics = append(beacon.BeaconMetrics, metric) + cfg := config.LoadLocation() + app, err := location.New(cfg) + if err != nil { + log.Fatalf("location: %v", err) } - appState.UpdateBeacon(id, beacon) + app.Run(ctx) + app.Shutdown() } diff --git a/cmd/server/main.go b/cmd/server/main.go index 1b1314d..e93498b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,198 +2,27 @@ package main import ( "context" - "encoding/json" - "fmt" - "io" "log" - "log/slog" - "net/http" - "os" "os/signal" - "sync" "syscall" - "time" - "github.com/AFASystems/presence/internal/pkg/apiclient" - "github.com/AFASystems/presence/internal/pkg/common/appcontext" + "github.com/AFASystems/presence/internal/app/server" "github.com/AFASystems/presence/internal/pkg/config" - "github.com/AFASystems/presence/internal/pkg/controller" - "github.com/AFASystems/presence/internal/pkg/database" - "github.com/AFASystems/presence/internal/pkg/kafkaclient" - "github.com/AFASystems/presence/internal/pkg/logger" - "github.com/AFASystems/presence/internal/pkg/model" - "github.com/AFASystems/presence/internal/pkg/service" - "github.com/gorilla/handlers" - "github.com/gorilla/mux" - "github.com/segmentio/kafka-go" ) -var _ io.Writer = (*os.File)(nil) -var wg sync.WaitGroup - func main() { - cfg := config.Load() - appState := appcontext.NewAppState() - kafkaManager := kafkaclient.InitKafkaManager() - - // Set logger -> terminal and log file - slog.SetDefault(logger.CreateLogger("server.log")) - - // define context ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer stop() - db, err := database.Connect(cfg) + cfg := config.LoadServer() + app, err := server.New(cfg) if err != nil { - log.Fatalf("Failed to open database connection: %v\n", err) - } - - headersOk := handlers.AllowedHeaders([]string{"X-Requested-With"}) - originsOk := handlers.AllowedOrigins([]string{"*"}) - methodsOk := handlers.AllowedMethods([]string{"GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"}) - - writerTopics := []string{"apibeacons", "alert", "mqtt", "settings", "parser"} - kafkaManager.PopulateKafkaManager(cfg.KafkaURL, "", writerTopics) - - slog.Info("Kafka writers topics: apibeacons, settings initialized") - - configFile, err := os.Open("/app/cmd/server/config.json") - if err != nil { - panic(err) - } - - b, _ := io.ReadAll(configFile) - - var configs []model.Config - json.Unmarshal(b, &configs) - - for _, config := range configs { - // persist read configs in database - db.Create(&config) - } - - db.Find(&configs) - for _, config := range configs { - kp := model.KafkaParser{ - ID: "add", - Config: config, - } - - if err := service.SendParserConfig(kp, kafkaManager.GetWriter("parser"), ctx); err != nil { - fmt.Printf("Unable to send parser config to kafka broker %v\n", err) - } - } - - if err := apiclient.UpdateDB(db, ctx, cfg, kafkaManager.GetWriter("apibeacons"), appState); err != nil { - fmt.Printf("Error in getting token: %v\n", err) - } - - readerTopics := []string{"locevents", "alertbeacons"} - kafkaManager.PopulateKafkaManager(cfg.KafkaURL, "server", readerTopics) - slog.Info("Kafka readers topics: locevents, alertbeacons initialized") - - chLoc := make(chan model.HTTPLocation, 200) - chEvents := make(chan model.BeaconEvent, 500) - - wg.Add(2) - go kafkaclient.Consume(kafkaManager.GetReader("locevents"), chLoc, ctx, &wg) - go kafkaclient.Consume(kafkaManager.GetReader("alertbeacons"), chEvents, ctx, &wg) - - r := mux.NewRouter() - - r.HandleFunc("/reslevis/getGateways", controller.GatewayListController(db)).Methods("GET") - r.HandleFunc("/reslevis/postGateway", controller.GatewayAddController(db)).Methods("POST") - r.HandleFunc("/reslevis/removeGateway/{id}", controller.GatewayDeleteController(db)).Methods("DELETE") - r.HandleFunc("/reslevis/updateGateway/{id}", controller.GatewayUpdateController(db)).Methods("PUT") - - r.HandleFunc("/reslevis/getZones", controller.ZoneListController(db)).Methods("GET") - r.HandleFunc("/reslevis/postZone", controller.ZoneAddController(db)).Methods("POST") - r.HandleFunc("/reslevis/removeZone/{id}", controller.ZoneDeleteController(db)).Methods("DELETE") - r.HandleFunc("/reslevis/updateZone", controller.ZoneUpdateController(db)).Methods("PUT") - - r.HandleFunc("/reslevis/getTrackerZones", controller.TrackerZoneListController(db)).Methods("GET") - r.HandleFunc("/reslevis/postTrackerZone", controller.TrackerZoneAddController(db)).Methods("POST") - r.HandleFunc("/reslevis/removeTrackerZone/{id}", controller.TrackerZoneDeleteController(db)).Methods("DELETE") - r.HandleFunc("/reslevis/updateTrackerZone", controller.TrackerZoneUpdateController(db)).Methods("PUT") - - r.HandleFunc("/reslevis/getTrackers", controller.TrackerList(db)).Methods("GET") - r.HandleFunc("/reslevis/postTracker", controller.TrackerAdd(db, kafkaManager.GetWriter("apibeacons"), ctx)).Methods("POST") - r.HandleFunc("/reslevis/removeTracker/{id}", controller.TrackerDelete(db, kafkaManager.GetWriter("apibeacons"), ctx)).Methods("DELETE") - r.HandleFunc("/reslevis/updateTracker", controller.TrackerUpdate(db)).Methods("PUT") - - r.HandleFunc("/configs/beacons", controller.ParserListController(db)).Methods("GET") - r.HandleFunc("/configs/beacons", controller.ParserAddController(db, kafkaManager.GetWriter("parser"), ctx)).Methods("POST") - r.HandleFunc("/configs/beacons/{id}", controller.ParserUpdateController(db, kafkaManager.GetWriter("parser"), ctx)).Methods("PUT") - r.HandleFunc("/configs/beacons/{id}", controller.ParserDeleteController(db, kafkaManager.GetWriter("parser"), ctx)).Methods("DELETE") - - r.HandleFunc("/reslevis/settings", controller.SettingsUpdateController(db, kafkaManager.GetWriter("settings"), ctx)).Methods("PATCH") - r.HandleFunc("/reslevis/settings", controller.SettingsListController(db)).Methods("GET") - - r.HandleFunc("/reslevis/getTracks/{id}", controller.TracksListController(db)).Methods("GET") - - beaconTicker := time.NewTicker(2 * time.Second) - - restApiHandler := handlers.CORS(originsOk, headersOk, methodsOk)(r) - mainHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - restApiHandler.ServeHTTP(w, r) - }) - - server := http.Server{ - Addr: cfg.HTTPAddr, - Handler: mainHandler, - } - - go server.ListenAndServe() - -eventLoop: - for { - select { - case <-ctx.Done(): - break eventLoop - case msg := <-chLoc: - service.LocationToBeaconService(msg, db, kafkaManager.GetWriter("alert"), ctx) - case msg := <-chEvents: - fmt.Printf("event: %+v\n", msg) - id := msg.ID - if err := db.First(&model.Tracker{}, "id = ?", id).Error; err != nil { - fmt.Printf("Decoder event for untracked beacon: %s\n", id) - continue - } - - if err := db.Updates(&model.Tracker{ID: id, Battery: msg.Battery, Temperature: msg.Temperature}).Error; err != nil { - fmt.Printf("Error in saving decoder event for beacon: %s\n", id) - continue - } - case <-beaconTicker.C: - var list []model.Tracker - db.Find(&list) - eMsg, err := json.Marshal(list) - if err != nil { - fmt.Printf("Error in marshaling trackers list: %v\n", err) - continue - } - - msg := kafka.Message{ - Value: eMsg, - } - - kafkaManager.GetWriter("mqtt").WriteMessages(ctx, msg) - } + log.Fatalf("server: %v", err) } - - if err := server.Shutdown(context.Background()); err != nil { - eMsg := fmt.Sprintf("could not shutdown: %v\n", err) - slog.Error(eMsg) + if err := app.Init(ctx); err != nil { + log.Fatalf("server init: %v", err) } - slog.Info("API SERVER: \n") - slog.Warn("broken out of the main event loop and HTTP server shutdown\n") - wg.Wait() - - slog.Info("All go routines have stopped, Beggining to close Kafka connections\n") - kafkaManager.CleanKafkaReaders() - kafkaManager.CleanKafkaWriters() - - slog.Info("All kafka clients shutdown, starting shutdown of valkey client") - slog.Info("API server shutting down") + app.Run(ctx) + app.Shutdown() } diff --git a/docs/API.md b/docs/API.md deleted file mode 100644 index 0310d38..0000000 --- a/docs/API.md +++ /dev/null @@ -1,748 +0,0 @@ -# API Documentation - -## Overview - -The AFA Systems Presence Detection API provides RESTful endpoints for managing beacons, settings, and real-time WebSocket communication for live updates. - -## Base URL - -``` -http://localhost:8080 -``` - -## Authentication - -Currently, the API does not implement authentication. This should be added for production deployments. - -## REST API Endpoints - -### Beacon Management - -#### Get All Beacons -Retrieves a list of all registered beacons with their current status and location information. - -```http -GET /api/beacons -``` - -**Response:** -```json -{ - "beacons": [ - { - "name": "Conference Room Beacon", - "beacon_id": "beacon_001", - "beacon_type": "ingics", - "beacon_location": "conference_room", - "last_seen": 1703078400, - "distance": 2.5, - "location_confidence": 85, - "hs_button_counter": 42, - "hs_button_battery": 85, - "hs_button_random": "abc123", - "hs_button_mode": "normal" - } - ] -} -``` - -#### Create Beacon -Registers a new beacon in the system. - -```http -POST /api/beacons -Content-Type: application/json -``` - -**Request Body:** -```json -{ - "name": "Meeting Room Beacon", - "beacon_id": "beacon_002", - "beacon_type": "eddystone", - "beacon_location": "meeting_room", - "hs_button_counter": 0, - "hs_button_battery": 100 -} -``` - -**Response:** -```json -{ - "message": "Beacon created successfully", - "beacon": { - "name": "Meeting Room Beacon", - "beacon_id": "beacon_002", - "beacon_type": "eddystone", - "beacon_location": "meeting_room", - "last_seen": 0, - "distance": 0, - "location_confidence": 0, - "hs_button_counter": 0, - "hs_button_battery": 100 - } -} -``` - -#### Update Beacon -Updates an existing beacon's information. - -```http -PUT /api/beacons/{id} -Content-Type: application/json -``` - -**Path Parameters:** -- `id` (string): The beacon ID to update - -**Request Body:** -```json -{ - "name": "Updated Conference Room Beacon", - "beacon_location": "main_conference", - "location_confidence": 90 -} -``` - -**Response:** -```json -{ - "message": "Beacon updated successfully", - "beacon": { - "name": "Updated Conference Room Beacon", - "beacon_id": "beacon_001", - "beacon_type": "ingics", - "beacon_location": "main_conference", - "last_seen": 1703078400, - "distance": 2.5, - "location_confidence": 90, - "hs_button_counter": 42, - "hs_button_battery": 85 - } -} -``` - -#### Delete Beacon -Removes a beacon from the system. - -```http -DELETE /api/beacons/{id} -``` - -**Path Parameters:** -- `id` (string): The beacon ID to delete - -**Response:** -```json -{ - "message": "Beacon deleted successfully" -} -``` - -### Settings Management - -#### Get System Settings -Retrieves current system configuration settings. - -```http -GET /api/settings -``` - -**Response:** -```json -{ - "settings": { - "location_confidence": 80, - "last_seen_threshold": 300, - "beacon_metrics_size": 10, - "ha_send_interval": 60, - "ha_send_changes_only": true, - "rssi_min_threshold": -90, - "enforce_rssi_threshold": true - } -} -``` - -#### Update System Settings -Updates system configuration settings. - -```http -POST /api/settings -Content-Type: application/json -``` - -**Request Body:** -```json -{ - "location_confidence": 85, - "last_seen_threshold": 600, - "beacon_metrics_size": 15, - "ha_send_interval": 30, - "rssi_min_threshold": -85 -} -``` - -**Response:** -```json -{ - "message": "Settings updated successfully", - "settings": { - "location_confidence": 85, - "last_seen_threshold": 600, - "beacon_metrics_size": 15, - "ha_send_interval": 30, - "ha_send_changes_only": true, - "rssi_min_threshold": -85, - "enforce_rssi_threshold": true - } -} -``` - -### Location Information - -#### Get Beacon Locations -Retrieves current location information for all beacons. - -```http -GET /api/locations -``` - -**Response:** -```json -{ - "beacons": [ - { - "method": "location_update", - "previous_confident_location": "reception", - "distance": 3.2, - "id": "beacon_001", - "location": "conference_room", - "last_seen": 1703078450 - }, - { - "method": "location_update", - "previous_confident_location": "office_a", - "distance": 1.8, - "id": "beacon_002", - "location": "meeting_room", - "last_seen": 1703078440 - } - ] -} -``` - -#### Get Specific Beacon Location -Retrieves location information for a specific beacon. - -```http -GET /api/locations/{id} -``` - -**Path Parameters:** -- `id` (string): The beacon ID - -**Response:** -```json -{ - "method": "location_update", - "previous_confident_location": "reception", - "distance": 3.2, - "id": "beacon_001", - "location": "conference_room", - "last_seen": 1703078450 -} -``` - -### Health Check - -#### System Health -Check if the API server is running and basic systems are operational. - -```http -GET /api/health -``` - -**Response:** -```json -{ - "status": "healthy", - "timestamp": "2024-12-20T10:30:00Z", - "services": { - "database": "connected", - "kafka": "connected", - "redis": "connected" - } -} -``` - -## WebSocket API - -### WebSocket Connection -Connect to the WebSocket endpoint for real-time updates. - -``` -ws://localhost:8080/ws/broadcast -``` - -### WebSocket Message Format - -#### Beacon Update Notification -```json -{ - "type": "beacon_update", - "data": { - "method": "location_update", - "beacon_info": { - "name": "Conference Room Beacon", - "beacon_id": "beacon_001", - "beacon_type": "ingics", - "distance": 2.5 - }, - "name": "conference_room", - "beacon_name": "Conference Room Beacon", - "previous_location": "reception", - "new_location": "conference_room", - "timestamp": 1703078450 - } -} -``` - -#### Button Press Event -```json -{ - "type": "button_event", - "data": { - "beacon_id": "beacon_001", - "button_counter": 43, - "button_mode": "normal", - "timestamp": 1703078460 - } -} -``` - -#### Battery Alert -```json -{ - "type": "battery_alert", - "data": { - "beacon_id": "beacon_002", - "battery_level": 15, - "alert_level": "warning", - "timestamp": 1703078470 - } -} -``` - -#### Fall Detection Event -```json -{ - "type": "fall_detection", - "data": { - "beacon_id": "beacon_001", - "event_type": "fall_detected", - "confidence": 92, - "timestamp": 1703078480 - } -} -``` - -#### System Status Update -```json -{ - "type": "system_status", - "data": { - "active_beacons": 12, - "total_locations": 8, - "kafka_status": "connected", - "redis_status": "connected", - "timestamp": 1703078490 - } -} -``` - -## Data Models - -### Beacon Model -```typescript -interface Beacon { - name: string; - beacon_id: string; - beacon_type: "ingics" | "eddystone" | "minew_b7" | "ibeacon"; - beacon_location: string; - last_seen: number; // Unix timestamp - distance: number; // Distance in meters - previous_location?: string; - previous_confident_location?: string; - expired_location?: string; - location_confidence: number; // 0-100 - location_history: string[]; - beacon_metrics: BeaconMetric[]; - - // Handshake/Button specific fields - hs_button_counter: number; - hs_button_prev: number; - hs_button_battery: number; - hs_button_random: string; - hs_button_mode: string; -} -``` - -### BeaconMetric Model -```typescript -interface BeaconMetric { - location: string; - distance: number; - rssi: number; - timestamp: number; -} -``` - -### Settings Model -```typescript -interface Settings { - location_confidence: number; // Minimum confidence level (0-100) - last_seen_threshold: number; // Seconds before beacon considered offline - beacon_metrics_size: number; // Number of RSSI measurements to keep - ha_send_interval: number; // Home Assistant update interval (seconds) - ha_send_changes_only: boolean; // Only send updates on changes - rssi_min_threshold: number; // Minimum RSSI for detection - enforce_rssi_threshold: boolean; // Filter weak signals -} -``` - -### LocationChange Model -```typescript -interface LocationChange { - method: string; // "location_update" | "beacon_added" | "beacon_removed" - beacon_ref: Beacon; // Complete beacon information - name: string; // Beacon name - beacon_name: string; // Beacon name (duplicate) - previous_location: string; // Previous location - new_location: string; // New location - timestamp: number; // Unix timestamp -} -``` - -## Error Responses - -### Standard Error Format -```json -{ - "error": { - "code": "VALIDATION_ERROR", - "message": "Invalid request data", - "details": { - "field": "beacon_id", - "reason": "Beacon ID is required" - } - } -} -``` - -### Common Error Codes - -| Code | HTTP Status | Description | -|------|-------------|-------------| -| `VALIDATION_ERROR` | 400 | Request data validation failed | -| `NOT_FOUND` | 404 | Resource not found | -| `CONFLICT` | 409 | Resource already exists | -| `INTERNAL_ERROR` | 500 | Internal server error | -| `SERVICE_UNAVAILABLE` | 503 | Required service is unavailable | - -### Validation Error Example -```http -POST /api/beacons -Content-Type: application/json -``` - -**Invalid Request:** -```json -{ - "name": "", - "beacon_type": "invalid_type" -} -``` - -**Response:** -```json -{ - "error": { - "code": "VALIDATION_ERROR", - "message": "Invalid request data", - "details": { - "name": "Name cannot be empty", - "beacon_type": "Invalid beacon type. Must be one of: ingics, eddystone, minew_b7, ibeacon" - } - } -} -``` - -## Rate Limiting - -Currently, the API does not implement rate limiting. Consider implementing rate limiting for production deployments: - -- Suggested limits: 100 requests per minute per IP address -- WebSocket connections: Maximum 50 concurrent connections -- Consider authentication-based rate limiting - -## CORS Configuration - -The API server is configured with CORS enabled for development. Production deployments should restrict CORS origins to specific domains. - -## Integration Examples - -### JavaScript/TypeScript Client - -```typescript -class PresenceAPIClient { - private baseURL: string; - - constructor(baseURL: string = 'http://localhost:8080') { - this.baseURL = baseURL; - } - - async getBeacons(): Promise { - const response = await fetch(`${this.baseURL}/api/beacons`); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data = await response.json(); - return data.beacons; - } - - async createBeacon(beacon: Partial): Promise { - const response = await fetch(`${this.baseURL}/api/beacons`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(beacon), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error?.message || 'Failed to create beacon'); - } - - const data = await response.json(); - return data.beacon; - } - - connectWebSocket(onMessage: (message: any) => void): WebSocket { - const ws = new WebSocket(`${this.baseURL.replace('http', 'ws')}/ws/broadcast`); - - ws.onmessage = (event) => { - try { - const message = JSON.parse(event.data); - onMessage(message); - } catch (error) { - console.error('Failed to parse WebSocket message:', error); - } - }; - - ws.onerror = (error) => { - console.error('WebSocket error:', error); - }; - - ws.onclose = () => { - console.log('WebSocket connection closed'); - }; - - return ws; - } -} - -// Usage example -const client = new PresenceAPIClient(); - -// Get all beacons -const beacons = await client.getBeacons(); -console.log('Active beacons:', beacons); - -// Create a new beacon -const newBeacon = await client.createBeacon({ - name: 'Test Beacon', - beacon_id: 'test_beacon_001', - beacon_type: 'eddystone', - beacon_location: 'test_room' -}); - -// Connect to WebSocket for real-time updates -const ws = client.connectWebSocket((message) => { - switch (message.type) { - case 'beacon_update': - console.log('Beacon location updated:', message.data); - break; - case 'button_event': - console.log('Button pressed:', message.data); - break; - case 'battery_alert': - console.log('Low battery warning:', message.data); - break; - } -}); -``` - -### Python Client - -```python -import requests -import websocket -import json -from typing import List, Dict, Any - -class PresenceAPIClient: - def __init__(self, base_url: str = "http://localhost:8080"): - self.base_url = base_url - self.ws_url = base_url.replace("http", "ws") - - def get_beacons(self) -> List[Dict[str, Any]]: - """Get all registered beacons.""" - response = requests.get(f"{self.base_url}/api/beacons") - response.raise_for_status() - data = response.json() - return data["beacons"] - - def create_beacon(self, beacon_data: Dict[str, Any]) -> Dict[str, Any]: - """Create a new beacon.""" - response = requests.post( - f"{self.base_url}/api/beacons", - json=beacon_data - ) - response.raise_for_status() - data = response.json() - return data["beacon"] - - def update_beacon(self, beacon_id: str, beacon_data: Dict[str, Any]) -> Dict[str, Any]: - """Update an existing beacon.""" - response = requests.put( - f"{self.base_url}/api/beacons/{beacon_id}", - json=beacon_data - ) - response.raise_for_status() - data = response.json() - return data["beacon"] - - def delete_beacon(self, beacon_id: str) -> None: - """Delete a beacon.""" - response = requests.delete(f"{self.base_url}/api/beacons/{beacon_id}") - response.raise_for_status() - - def get_settings(self) -> Dict[str, Any]: - """Get system settings.""" - response = requests.get(f"{self.base_url}/api/settings") - response.raise_for_status() - return response.json()["settings"] - - def update_settings(self, settings_data: Dict[str, Any]) -> Dict[str, Any]: - """Update system settings.""" - response = requests.post( - f"{self.base_url}/api/settings", - json=settings_data - ) - response.raise_for_status() - return response.json()["settings"] - -# Usage example -client = PresenceAPIClient() - -# Get all beacons -beacons = client.get_beacons() -print(f"Found {len(beacons)} beacons") - -# Create a new beacon -new_beacon = client.create_beacon({ - "name": "Python Test Beacon", - "beacon_id": "python_test_001", - "beacon_type": "eddystone", - "beacon_location": "python_room" -}) -print(f"Created beacon: {new_beacon['name']}") - -# Update settings -settings = client.update_settings({ - "location_confidence": 85, - "ha_send_interval": 30 -}) -print(f"Updated settings: {settings}") -``` - -## Testing - -### Unit Testing Example (Go) - -```go -package api_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "github.com/stretchr/testify/assert" -) - -func TestGetBeacons(t *testing.T) { - // Setup test server - router := setupTestRouter() - - req, _ := http.NewRequest("GET", "/api/beacons", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - assert.Contains(t, response, "beacons") -} - -func TestCreateBeacon(t *testing.T) { - router := setupTestRouter() - - beaconData := map[string]interface{}{ - "name": "Test Beacon", - "beacon_id": "test_001", - "beacon_type": "eddystone", - "beacon_location": "test_room", - } - - jsonData, _ := json.Marshal(beaconData) - req, _ := http.NewRequest("POST", "/api/beacons", bytes.NewBuffer(jsonData)) - req.Header.Set("Content-Type", "application/json") - - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusCreated, w.Code) - - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - assert.Contains(t, response, "beacon") -} -``` - -## Security Considerations - -For production deployments, consider implementing: - -1. **Authentication**: JWT tokens or API key authentication -2. **Authorization**: Role-based access control (RBAC) -3. **Rate Limiting**: Prevent API abuse -4. **Input Validation**: Comprehensive input sanitization -5. **HTTPS**: TLS encryption for all API communications -6. **CORS**: Restrict origins to trusted domains -7. **Logging**: Comprehensive audit logging -8. **Security Headers**: Implement security HTTP headers - -## API Versioning - -The current API is version 1. Future versions will be: - -- Version 1: `/api/v1/...` (current, implied) -- Version 2: `/api/v2/...` (future breaking changes) - -Backward compatibility will be maintained within major versions. \ No newline at end of file diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md deleted file mode 100644 index f8259a1..0000000 --- a/docs/DEPLOYMENT.md +++ /dev/null @@ -1,1039 +0,0 @@ -# Deployment Guide - -## Overview - -This guide covers the deployment of the AFA Systems Presence Detection system in various environments, from development setups to production deployments. - -## System Requirements - -### Minimum Requirements - -- **CPU**: 2 cores -- **Memory**: 4GB RAM -- **Storage**: 20GB available space -- **Network**: Reliable MQTT broker connection -- **Operating System**: Linux (Ubuntu 20.04+, CentOS 8+, RHEL 8+), macOS, or Windows 10+ - -### Recommended Requirements - -- **CPU**: 4 cores -- **Memory**: 8GB RAM -- **Storage**: 50GB SSD -- **Network**: Gigabit ethernet, low-latency MQTT connection -- **Operating System**: Ubuntu 22.04 LTS or RHEL 9+ - -### Software Dependencies - -- **Docker**: 20.10+ with Docker Compose v2.0+ -- **Git**: For source code management -- **Go**: 1.24+ (for local development) -- **MQTT Broker**: Compatible with BLE gateway devices (Mosquitto, EMQX, HiveMQ) - -## Deployment Options - -### 1. Docker Compose (Recommended) - -The Docker Compose deployment provides a complete, production-ready environment with all services included. - -#### Quick Start - -1. **Clone the repository**: - ```bash - git clone https://github.com/AFASystems/presence.git - cd presence - ``` - -2. **Create environment configuration**: - ```bash - cp .env.example .env - # Edit .env with your configuration - ``` - -3. **Start all services**: - ```bash - cd build - docker-compose up -d - ``` - -4. **Verify deployment**: - ```bash - docker-compose ps - docker-compose logs -f - ``` - -#### Environment Configuration - -Create a `.env` file in the project root: - -```bash -# Web Server Configuration -HTTP_HOST_PATH=:8080 -HTTP_WS_HOST_PATH=:8081 - -# MQTT Configuration -MQTT_HOST=tcp://your-mqtt-broker:1883 -MQTT_USERNAME=your_mqtt_username -MQTT_PASSWORD=your_mqtt_password -MQTT_CLIENT_ID=presence_system - -# Kafka Configuration -KAFKA_URL=kafka:29092 -KAFKA_BOOTSTRAP_SERVERS=kafka:29092 -KAFKA_GROUP_ID=presence_group - -# Database Configuration -DB_PATH=./volumes/presence.db - -# Redis Configuration -REDIS_URL=redis:6379 -REDIS_PASSWORD= - -# Security -CORS_ORIGINS=http://localhost:3000,http://localhost:8080 -API_SECRET_KEY=your-secret-key-here - -# Logging -LOG_LEVEL=info -LOG_FORMAT=json -``` - -#### Docker Compose Services - -The default `docker-compose.yaml` includes: - -| Service | Description | Ports | Resources | -|---------|-------------|-------|------------| -| **kafka** | Apache Kafka message broker | 9092, 9093 | 2GB RAM | -| **kafdrop** | Kafka monitoring UI | 9000 | 512MB RAM | -| **presence-bridge** | MQTT to Kafka bridge | - | 256MB RAM | -| **presence-decoder** | BLE beacon decoder | - | 512MB RAM | -| **presence-location** | Location calculation service | - | 512MB RAM | -| **presence-server** | HTTP API & WebSocket server | 8080, 8081 | 1GB RAM | -| **redis** | Caching and session storage | 6379 | 512MB RAM | - -#### Production Docker Compose - -For production deployment, create `docker-compose.prod.yaml`: - -```yaml -version: "3.8" - -services: - kafka: - image: apache/kafka:3.9.0 - restart: always - environment: - KAFKA_NODE_ID: 1 - KAFKA_PROCESS_ROLES: broker,controller - KAFKA_LISTENERS: INTERNAL://:29092,EXTERNAL://:9092,CONTROLLER://:9093 - KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:29092,EXTERNAL://kafka.example.com:9092 - KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT - KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093 - KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL - KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 3 - KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 3 - KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 2 - KAFKA_NUM_PARTITIONS: 6 - KAFKA_DEFAULT_REPLICATION_FACTOR: 3 - KAFKA_MIN_INSYNC_REPLICAS: 2 - KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" - KAFKA_DELETE_TOPIC_ENABLE: "true" - KAFKA_LOG_RETENTION_HOURS: 168 - KAFKA_LOG_RETENTION_BYTES: 1073741824 - volumes: - - kafka-data:/var/lib/kafka/data - deploy: - resources: - limits: - memory: 4G - reservations: - memory: 2G - - presence-bridge: - build: - context: .. - dockerfile: build/package/Dockerfile.bridge - restart: always - environment: - - HTTP_HOST_PATH=${HTTP_HOST_PATH} - - MQTT_HOST=${MQTT_HOST} - - MQTT_USERNAME=${MQTT_USERNAME} - - MQTT_PASSWORD=${MQTT_PASSWORD} - - KAFKA_URL=${KAFKA_URL} - - LOG_LEVEL=${LOG_LEVEL} - volumes: - - ../volumes:/app/volumes - depends_on: - kafka: - condition: service_healthy - deploy: - resources: - limits: - memory: 512M - reservations: - memory: 256M - - presence-decoder: - build: - context: .. - dockerfile: build/package/Dockerfile.decoder - restart: always - environment: - - HTTP_HOST_PATH=${HTTP_HOST_PATH} - - KAFKA_URL=${KAFKA_URL} - - REDIS_URL=${REDIS_URL} - - LOG_LEVEL=${LOG_LEVEL} - volumes: - - ../volumes:/app/volumes - depends_on: - kafka: - condition: service_healthy - redis: - condition: service_healthy - deploy: - resources: - limits: - memory: 1G - reservations: - memory: 512M - - presence-location: - build: - context: .. - dockerfile: build/package/Dockerfile.location - restart: always - environment: - - HTTP_HOST_PATH=${HTTP_HOST_PATH} - - KAFKA_URL=${KAFKA_URL} - - REDIS_URL=${REDIS_URL} - - LOG_LEVEL=${LOG_LEVEL} - volumes: - - ../volumes:/app/volumes - depends_on: - kafka: - condition: service_healthy - redis: - condition: service_healthy - deploy: - resources: - limits: - memory: 1G - reservations: - memory: 512M - - presence-server: - build: - context: .. - dockerfile: build/package/Dockerfile.server - restart: always - environment: - - HTTP_HOST_PATH=${HTTP_HOST_PATH} - - HTTP_WS_HOST_PATH=${HTTP_WS_HOST_PATH} - - KAFKA_URL=${KAFKA_URL} - - REDIS_URL=${REDIS_URL} - - DB_PATH=/app/volumes/presence.db - - CORS_ORIGINS=${CORS_ORIGINS} - - API_SECRET_KEY=${API_SECRET_KEY} - - LOG_LEVEL=${LOG_LEVEL} - ports: - - "8080:8080" - - "8081:8081" - volumes: - - ../volumes:/app/volumes - - ../web:/app/web - depends_on: - kafka: - condition: service_healthy - redis: - condition: service_healthy - deploy: - resources: - limits: - memory: 2G - reservations: - memory: 1G - - redis: - image: redis:7-alpine - restart: always - command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} - volumes: - - redis-data:/data - deploy: - resources: - limits: - memory: 1G - reservations: - memory: 512M - - kafdrop: - image: obsidiandynamics/kafdrop - restart: always - environment: - KAFKA_BROKERCONNECT: "kafka:29092" - JVM_OPTS: "-Xms256M -Xmx512M" - ports: - - "9000:9000" - depends_on: - kafka: - condition: service_healthy - - nginx: - image: nginx:alpine - restart: always - ports: - - "80:80" - - "443:443" - volumes: - - ./nginx.conf:/etc/nginx/nginx.conf - - ./ssl:/etc/nginx/ssl - depends_on: - - presence-server - deploy: - resources: - limits: - memory: 256M - reservations: - memory: 128M - -volumes: - kafka-data: - redis-data: -``` - -### 2. Kubernetes Deployment - -For larger-scale deployments, use Kubernetes with Helm charts. - -#### Prerequisites - -- Kubernetes cluster (v1.24+) -- Helm 3.0+ -- kubectl configured -- Persistent storage provisioner - -#### Installation - -1. **Create namespace**: - ```bash - kubectl create namespace presence-system - ``` - -2. **Add Helm repository**: - ```bash - helm repo add presence-system https://charts.afasystems.com/presence - helm repo update - ``` - -3. **Install with custom values**: - ```bash - helm install presence presence-system/presence-system \ - --namespace presence-system \ - --values values.prod.yaml - ``` - -#### Custom Values (`values.prod.yaml`) - -```yaml -global: - imageRegistry: your-registry.com - imagePullSecrets: [your-registry-secret] - - mqtt: - host: "tcp://mqtt-broker:1883" - username: "your_username" - password: "your_password" - -kafka: - enabled: true - replicaCount: 3 - persistence: - enabled: true - size: 100Gi - resources: - requests: - memory: "2Gi" - cpu: "1" - limits: - memory: "4Gi" - cpu: "2" - -redis: - enabled: true - auth: - enabled: true - password: "your-redis-password" - master: - persistence: - enabled: true - size: 20Gi - resources: - requests: - memory: "512Mi" - cpu: "0.5" - limits: - memory: "1Gi" - cpu: "1" - -bridge: - replicaCount: 2 - resources: - requests: - memory: "256Mi" - cpu: "0.25" - limits: - memory: "512Mi" - cpu: "0.5" - -decoder: - replicaCount: 3 - resources: - requests: - memory: "512Mi" - cpu: "0.5" - limits: - memory: "1Gi" - cpu: "1" - -location: - replicaCount: 3 - resources: - requests: - memory: "512Mi" - cpu: "0.5" - limits: - memory: "1Gi" - cpu: "1" - -server: - replicaCount: 2 - service: - type: LoadBalancer - ports: - http: 80 - https: 443 - websocket: 8081 - ingress: - enabled: true - className: "nginx" - annotations: - nginx.ingress.kubernetes.io/rewrite-target: / - hosts: - - host: presence.example.com - paths: - - path: / - pathType: Prefix - tls: - - secretName: presence-tls - hosts: - - presence.example.com - resources: - requests: - memory: "1Gi" - cpu: "0.5" - limits: - memory: "2Gi" - cpu: "1" - -monitoring: - enabled: true - prometheus: - enabled: true - grafana: - enabled: true - -autoscaling: - enabled: true - bridge: - minReplicas: 2 - maxReplicas: 10 - targetCPUUtilizationPercentage: 70 - decoder: - minReplicas: 3 - maxReplicas: 20 - targetCPUUtilizationPercentage: 70 - location: - minReplicas: 3 - maxReplicas: 20 - targetCPUUtilizationPercentage: 70 - server: - minReplicas: 2 - maxReplicas: 10 - targetCPUUtilizationPercentage: 70 -``` - -### 3. Manual Deployment - -For custom environments or specific requirements. - -#### Building from Source - -1. **Prerequisites**: - ```bash - # Install Go 1.24+ - wget https://go.dev/dl/go1.24.linux-amd64.tar.gz - sudo tar -C /usr/local -xzf go1.24.linux-amd64.tar.gz - export PATH=$PATH:/usr/local/go/bin - - # Install Node.js (for web assets) - curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - - sudo apt-get install -y nodejs - ``` - -2. **Clone and build**: - ```bash - git clone https://github.com/AFASystems/presence.git - cd presence - - # Build web assets - cd web - npm install - npm run build - - # Build Go applications - cd .. - go mod download - go build -o bin/bridge ./cmd/bridge - go build -o bin/decoder ./cmd/decoder - go build -o bin/location ./cmd/location - go build -o bin/server ./cmd/server - ``` - -3. **System service files** (`/etc/systemd/system/presence-bridge.service`): - ```ini - [Unit] - Description=Presence Bridge Service - After=network.target - - [Service] - Type=simple - User=presence - Group=presence - WorkingDirectory=/opt/presence - ExecStart=/opt/presence/bin/bridge - Restart=always - RestartSec=10 - Environment=HTTP_HOST_PATH=:8080 - Environment=MQTT_HOST=tcp://mqtt-broker:1883 - Environment=MQTT_USERNAME=your_username - Environment=MQTT_PASSWORD=your_password - Environment=KAFKA_URL=kafka:29092 - Environment=LOG_LEVEL=info - - [Install] - WantedBy=multi-user.target - ``` - - Create similar service files for decoder, location, and server services. - -4. **Enable and start services**: - ```bash - sudo systemctl daemon-reload - sudo systemctl enable presence-bridge - sudo systemctl enable presence-decoder - sudo systemctl enable presence-location - sudo systemctl enable presence-server - - sudo systemctl start presence-bridge - sudo systemctl start presence-decoder - sudo systemctl start presence-location - sudo systemctl start presence-server - ``` - -## Network Configuration - -### Firewall Rules - -Ensure these ports are open: - -| Port | Service | Description | -|------|---------|-------------| -| 80 | HTTP | Web interface (if using nginx) | -| 443 | HTTPS | Secure web interface | -| 8080 | HTTP API | REST API server | -| 8081 | WebSocket | Real-time updates | -| 9000 | Kafdrop | Kafka monitoring UI | -| 1883 | MQTT | MQTT broker (if external) | -| 9092 | Kafka | Kafka broker | - -### Load Balancer Configuration - -#### HAProxy Example - -``` -frontend presence_frontend - bind *:80 - default_backend presence_backend - -backend presence_backend - balance roundrobin - server presence1 10.0.1.10:8080 check - server presence2 10.0.1.11:8080 check - -frontend presence_ws_frontend - bind *:8081 - default_backend presence_ws_backend - -backend presence_ws_backend - balance roundrobin - server presence1 10.0.1.10:8081 check - server presence2 10.0.1.11:8081 check -``` - -#### Nginx Example - -```nginx -upstream presence_backend { - server 10.0.1.10:8080; - server 10.0.1.11:8080; -} - -upstream presence_ws_backend { - server 10.0.1.10:8081; - server 10.0.1.11:8081; -} - -server { - listen 80; - server_name presence.example.com; - - location / { - proxy_pass http://presence_backend; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location /ws { - proxy_pass http://presence_ws_backend; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} -``` - -## SSL/TLS Configuration - -### Let's Encrypt with Certbot - -1. **Install Certbot**: - ```bash - sudo apt-get update - sudo apt-get install certbot python3-certbot-nginx - ``` - -2. **Generate certificates**: - ```bash - sudo certbot --nginx -d presence.example.com - ``` - -3. **Auto-renewal**: - ```bash - sudo crontab -e - # Add: 0 12 * * * /usr/bin/certbot renew --quiet - ``` - -### Nginx SSL Configuration - -```nginx -server { - listen 443 ssl http2; - server_name presence.example.com; - - ssl_certificate /etc/letsencrypt/live/presence.example.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/presence.example.com/privkey.pem; - - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512; - ssl_prefer_server_ciphers off; - ssl_session_cache shared:SSL:10m; - - location / { - proxy_pass http://presence_backend; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location /ws { - proxy_pass http://presence_ws_backend; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} -``` - -## Monitoring and Logging - -### Prometheus Configuration - -Create `prometheus.yml`: - -```yaml -global: - scrape_interval: 15s - evaluation_interval: 15s - -scrape_configs: - - job_name: 'presence-server' - static_configs: - - targets: ['presence-server:8080'] - metrics_path: /metrics - scrape_interval: 5s - - - job_name: 'kafka' - static_configs: - - targets: ['kafka:9092'] - - - job_name: 'redis' - static_configs: - - targets: ['redis:6379'] -``` - -### Grafana Dashboard - -Import the provided Grafana dashboard or create custom visualizations: - -1. **System Metrics**: CPU, Memory, Disk usage -2. **Kafka Metrics**: Throughput, lag, topic sizes -3. **Beacon Metrics**: Active beacons, location updates, battery levels -4. **API Metrics**: Request rates, response times, error rates -5. **WebSocket Metrics**: Active connections, message rates - -### Log Aggregation with ELK Stack - -#### Elasticsearch Configuration - -```yaml -version: '3.8' -services: - elasticsearch: - image: docker.elastic.co/elasticsearch/elasticsearch:8.5.0 - environment: - - discovery.type=single-node - - "ES_JAVA_OPTS=-Xms1g -Xmx1g" - ports: - - "9200:9200" - volumes: - - elasticsearch-data:/usr/share/elasticsearch/data - - logstash: - image: docker.elastic.co/logstash/logstash:8.5.0 - ports: - - "5044:5044" - volumes: - - ./logstash.conf:/usr/share/logstash/pipeline/logstash.conf - depends_on: - - elasticsearch - - kibana: - image: docker.elastic.co/kibana/kibana:8.5.0 - ports: - - "5601:5601" - environment: - - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 - depends_on: - - elasticsearch - -volumes: - elasticsearch-data: -``` - -## Backup and Recovery - -### Database Backup - -```bash -#!/bin/bash -# backup.sh - -BACKUP_DIR="/backups/presence" -DATE=$(date +%Y%m%d_%H%M%S) -DB_FILE="/opt/presence/volumes/presence.db" - -mkdir -p $BACKUP_DIR - -# Backup BoltDB -cp $DB_FILE $BACKUP_DIR/presence_$DATE.db - -# Compress old backups -find $BACKUP_DIR -name "presence_*.db" -mtime +7 -exec gzip {} \; - -# Keep only last 30 days -find $BACKUP_DIR -name "presence_*.db.gz" -mtime +30 -delete -``` - -### Restore from Backup - -```bash -#!/bin/bash -# restore.sh - -BACKUP_FILE=$1 -DB_FILE="/opt/presence/volumes/presence.db" - -if [ -z "$BACKUP_FILE" ]; then - echo "Usage: $0 " - exit 1 -fi - -# Stop services -sudo systemctl stop presence-server presence-decoder presence-location - -# Restore database -sudo cp $BACKUP_FILE $DB_FILE -sudo chown presence:presence $DB_FILE - -# Start services -sudo systemctl start presence-server presence-decoder presence-location -``` - -### Kafka Backup - -```bash -#!/bin/bin/bash -# kafka_backup.sh - -BACKUP_DIR="/backups/kafka" -DATE=$(date +%Y%m%d_%H%M%S) - -mkdir -p $BACKUP_DIR - -# Backup Kafka topics -kafka-topics.sh --bootstrap-server localhost:9092 --list > $BACKUP_DIR/topics_$DATE.txt - -# Backup topic data (example) -for topic in rawbeacons alertbeacons locevents; do - kafka-console-consumer.sh --bootstrap-server localhost:9092 \ - --topic $topic --from-beginning > $BACKUP_DIR/${topic}_$DATE.json -done -``` - -## Performance Tuning - -### Kafka Optimization - -```bash -# Kafka server.properties -num.network.threads=8 -num.io.threads=16 -socket.send.buffer.bytes=102400 -socket.receive.buffer.bytes=102400 -socket.request.max.bytes=104857600 -log.retention.hours=168 -log.segment.bytes=1073741824 -log.retention.check.interval.ms=300000 -``` - -### Redis Optimization - -```bash -# redis.conf -maxmemory 2gb -maxmemory-policy allkeys-lru -save 900 1 -save 300 10 -save 60 10000 -``` - -### Application Tuning - -Environment variables for performance: - -```bash -# Go runtime -GOMAXPROCS=4 -GOGC=100 - -# Application settings -BEACON_METRICS_SIZE=50 -LOCATION_CONFIDENCE=90 -KAFKA_CONSUMER_FETCH_MAX_BYTES=1048576 -REDIS_POOL_SIZE=50 -``` - -## Security Hardening - -### Network Security - -1. **Network Segmentation**: - ```bash - # Create dedicated network for presence system - docker network create --driver bridge presence-network - docker network connect presence-network presence-server - ``` - -2. **Port Security**: - ```bash - # Only expose necessary ports - ufw allow 80/tcp - ufw allow 443/tcp - ufw allow 8080/tcp - ufw deny 9092/tcp # Kafka internal only - ``` - -### Application Security - -1. **Environment Variable Security**: - ```bash - # Use secrets management - export API_SECRET_KEY=$(openssl rand -base64 32) - export MQTT_PASSWORD_FILE=/run/secrets/mqtt_password - ``` - -2. **Container Security**: - ```yaml - # Docker Compose security settings - services: - presence-server: - security_opt: - - no-new-privileges:true - read_only: true - tmpfs: - - /tmp - user: "1000:1000" - cap_drop: - - ALL - cap_add: - - NET_BIND_SERVICE - ``` - -## Troubleshooting - -### Common Issues - -#### Service Won't Start - -```bash -# Check logs -docker-compose logs presence-server -sudo journalctl -u presence-bridge -f - -# Check configuration -docker-compose config -``` - -#### Kafka Connection Issues - -```bash -# Check Kafka health -docker-compose exec kafka kafka-topics.sh --bootstrap-server localhost:9092 --list - -# Check network connectivity -docker network ls -docker network inspect presence_build -``` - -#### High Memory Usage - -```bash -# Monitor memory usage -docker stats - -# Check Go garbage collection -docker-compose exec presence-server pprof http://localhost:6060/debug/pprof/heap -``` - -#### WebSocket Connection Issues - -```bash -# Test WebSocket connection -wscat -c ws://localhost:8080/ws/broadcast - -# Check logs for connection issues -docker-compose logs presence-server | grep websocket -``` - -### Performance Diagnostics - -#### System Monitoring - -```bash -# System resources -htop -iostat -x 1 -netstat -i - -# Application performance -curl http://localhost:8080/api/health -docker-compose exec presence-server curl http://localhost:8080/api/health -``` - -#### Database Performance - -```bash -# BoltDB inspection -boltstats ./volumes/presence.db - -# Redis monitoring -redis-cli info memory -redis-cli info stats -``` - -## Migration and Upgrades - -### Version Upgrade Procedure - -1. **Backup current system**: - ```bash - ./scripts/backup.sh - ``` - -2. **Update source code**: - ```bash - git fetch origin - git checkout v2.0.0 - ``` - -3. **Build new images**: - ```bash - docker-compose build --no-cache - ``` - -4. **Run database migrations**: - ```bash - docker-compose run --rm presence-server ./migrate up - ``` - -5. **Restart services with zero downtime**: - ```bash - docker-compose up -d --scale presence-server=2 - # Wait for health checks - docker-compose up -d --scale presence-server=1 - ``` - -### Configuration Migration - -Create migration scripts for configuration changes: - -```bash -#!/bin/bash -# migrate_v1_to_v2.sh - -# Backup old config -cp .env .env.backup - -# Add new configuration variables -echo "NEW_FEATURE_ENABLED=true" >> .env -echo "API_RATE_LIMIT=100" >> .env - -# Update deprecated settings -sed -i 's/OLD_SETTING/NEW_SETTING/g' .env - -echo "Migration completed. Please review .env file." -``` - -This comprehensive deployment guide should help you successfully deploy and manage the AFA Systems Presence Detection system in various environments. \ No newline at end of file diff --git a/docs/Frame definition- B7,MWB01,MWC01.pdf b/docs/Frame definition- B7,MWB01,MWC01.pdf deleted file mode 100644 index ea9277498618b17a2e7f6194e1f7ac7ae98dcb59..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 357203 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

x=8Eg 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%0ApP9 zTRa&I)yVDsLRK?6Qu_#1kB7xsB#4q6y31Az`qq2xh1guRz)Gz7M3>gR_a{^f^q*I6 z4v2+0h7Y*&T;n->la*m=+&S#}-BuIV--%BrC!sWfuVjKBh~RhgkZGT7H&uc{vw)WV z8+GSb6im)tO0|Y3ht0enON^g~3|Bv(1VAp+5_S6HDk+1^R5Myjqw@4(!Fm8u$(Or~$cqM^+fGr@82y(@!ID?3H zrD#Lk=qew?jWLAeW>6x2KEoK3G_=>JBvOR@lOURgv9OAN%PN1@O@iiEBqd{5$qp%* zTPd3#+9+_fMb&w=kLL8Wcud2->JGSk_RXGso2hSBbI(~W-ZI9yvZ(@Kx;-UUouP;A z=Ur61JR-4oR;44`Ogwo%k!;OhpGV*JiQJSg@AS}KLgDz&)!|>{909i4)*f6UG~vs? zE$81N_&0krW4j80HbN?#(7K&oT&5kpY@5vKl=X%u5c25kB6W9tN=SU_H{!&|Z5&U| zJ9isDbsT@tSHSz=Z3eG7Q6poBg&g>Z{q)3uAhd4d_D}^Z@8_i!(n()euA_s9&}^>L z&=JCM5}Zd-7i;)K6OIHEV0leUs&q_ns|DmP?0S@MN0e7 zz>+A%p}bfzxlh@V+L62j9C63EFLXTTuN2vp{soAkaB$2p?fJrvTVCG?WrN@&LkbmX zR>0K9kb7LKE(X(W_U;-mi9X^t$zf%Xh^2*{W{6Pff~WTyA26}!O&$Z}TL`ZAbQ3%KZ~7|dVI4p4e=!0<53+xRYu}AK`~Pb7{jCF}{3%{3(pZ?1_dQR& zV6On_GHL&{%4RHpe};EvvxH31u4J}EOi4!Z%Ioo@auxYG$L8rD*Z=P*|JPb)dncjC zPj^441RsJWt}W?bC}{S23BLD33mfB4DT2Ez1^{Uo$b+DM1BgfRz{?B~bU$E=q0_wr&1bhn$EKIezOc7$*eR7P^_98iFCP)2)ElqoDrP1+X<%yAdfWUB$ z(PSN2qNuX8Ut%`~Nhx{bw}b}eJdA$&v60Yvesx&s+w7I7^T*d+rZM=OZgk2Ylkkn- zUhhz0x5*|FqPkI*2bcz``?t5|x45>Nw_q<#*GB{fk^UUePprl4d z_enJgnTd{ph7r%4i5`d7^dj%MBd`_1cJ8`C)x|lHhDDH;UK9J9S~>XhOA_2?44iMt z86!tA@-Ce!teGRD(_U*c0imHpq29%)a%P>!+%Y%K946-(?iD5})Zju)5SyhSCiCPnuEMMaT7@~`m*kq#R)g9>FQpR%^ zm9-6mUbSb-j8!gO)x7Z<%4X73O-9nKt*y;Up=9M@N{fQEp)ThCjPefb{0_C6V3})_cEt9tn9zT{@-5L1> zIkB-uTFpx4#}oWpl%dr4A5YF-G6}yM54Lu+rTmdhEAt3kH7|96yXn{oXeM9jkxB89 zY0H?;M7vi_p7ZJ2pNw{9%(a$P=sOr)AHI*-l`OWb%3b3AT5seXRr(FMrLJ<^kZRbw zwt;J7WfO{c%r&!3!nkKtQsuTJD>uQcf~qDQoG{rlux-6dnPWatV@x%}<*m(QS47ca zF!(#=5$%ZN&aA9z6@KxItdd9arONhaAnzKog*c0RWNSl_`x1hMZ>Jw#)gYg^S$`o|IW`Z zS8&aCagVXSLb_o~k!L!b>d}|S%t7+*$nMMqj|~noV~^TpXJDQ+ug7dZoiNp{ga&rJiFqNn3iDT=<87ga-f?)rp&rGjEtK zah|QtBcwawVK(}Nq2U=7!|?ij#ED_0b$A;k+7o&+FL^IA^!Q5D`C^W~Z*^RC&aii1 zQAOluSQd)o1w#+svc zcDB;J$$RB~sgw7^_ECm*>JQ-7V?o;-Ieq!6O@F6e6Wq#348O?^?wJ+6`Sb4MW9R*W zHe;%EoyY`N6HC0^t3H14AZMU0Z?&9orZ{K2?RL;t3=VX$=w-W7C%oJo3O6>LqvQK= zC{*s``|+$X)erJLs=r1civ~+s<(QnyY36#Ue~TV6TKSOSVY-nK#NTB9os&GSOgx7^VselqOb)IFZ!Uuo^A4ZMROXzZvo zaF#G3WYnn^t5DuV5Jm8g|?NA`(M;HZVc_Xbw04CuUmqK~vbBZeY)9kF${_aX+4xFc5 zwn~fu>m5u{F_P`8nIz^a^e;0obj4uWpQQu}XOLx(M#gYSc|;PJwevKZ zVf0su)gqm+H_S({s{SY5q~2pabgZ^E{Y#P+cH(iMuUP$9l8SIbZCePJcD=||-0)jI z8oGE}e7&E0VQHUIoYzshC<&;1CGHcgo6x&Rw)cUPFhel8Xo3J=*?X?d5<12Ym#Djf zlxJv8Nc*`j3pVr z=z^GMT1{q6)COron{hpge6PHQE$0@X7pd31m$TPEuQoj#8Wvfx{R#FC^^9UgtR=H1 zIb&U7L3Ba3<*Os~rn~bO{7rRhKln{1pd2s;m|40i2Sfzo!*Zd_q0gbd%2v%QUvU7B zVHtqOFy*B^jX*VEGSCLbpR~g9&;>;UJq0}lwThIUf*zpzSyNV1TvJL@I8DKla*S+@ zVho@wVo5sYK7b&sE1xFO675K{iPk#+Ob156u)$2jPQ%0iOMxFn1&rW~SdGw(NR7aa z`12<)1yGb^TH+nSdPGV9(PYsSs_26#lIW7CWiVv0$v{*foG6bGcYd^@G&KMNJrbr5 z#$6PUAHbZ%3}jAY4v#;N9Qm|^`Vq|;76OI`Xe){Z_!H1A1qq)o zluryuju&M{Pt;2YM*R%=l`fI#KwM*oL>wG92IwhD&?6j9aRAE#v=qevB%+AG6w?8x zFhz%PL}4foU_^kzK`D8F5?ET%4-eN=_X$d$6;a58q)=d@Kd`1{{`!1C@+=N|3|Hbl z;R0gQ=b`R|&rAG-99IFQi^5mzKEpBV0&`PFly4Kh_y1;4{_&TSNM^vR9QRS=52!&w zQs0BfpJKkk+*^^-_{ca|(%*Tseg!j#@Z^YbHA@pw%fEF)wqbQfmz!u`kYO zRytEJ^8zt##t_}8kj@uj%iP)z-3Ve+^5{3Bs*Av^?x*{Dx(>YB6zeL0u7agg$Si4< zOUNuhy}g%`w8||+PPD=~Bu2QxHAGHWSIo@6AcL-CWcD`u=e9`ECFoV6L&Q_jk^XQu zpJyOS6iS%FJ@T6hc$GW~1c7Z3ZhS|aGu;J(;g27VUP|ur*P^_Uk5)@`l+Hz0gW7O! zv*4ST42Z9xyeGsIegeu<~awPZ0zIpS_A_fh}>z#c}zAretRKpb;? zybO-;Pk=&^V z`v`TkUy_?o)OYAhd?PU9p|mdY1^%1{B}XhYER;R;4A=o6X=ERBP`08JH3>oyAPl`1CZE7ZLJ33n_TGmG8_Ks8 zeS@To8vJHgx|ciSrgo;@x)-O{vX@#E(}+AjkvT|FgqjM0DhX8@mI5d)3huTh6;9QU ziU8CM%0Uqerxc%JCWA%2U5+O#5z5bz6bN}FlfOVNSugYp^G3UYJZk1HU(fT4d4xS0 zDtVI>h#mnyf-TI7z3rQ6{nk~-B`0-8A1&^ zog+mi!j1;#(h~i$Pr9wH%fFjbX;YJ!+;v;+YQ?k(nlu#|XbmfsQn~o7 zGvYXNEwTDCY%+>DCT+S(ADHo;&#cx~eQUIAU5IPK*!K|`b=Eq%v|#$2!7{W^xTx66 zAx8I-C!NMtIfnq_!|7xQf-GT#>7dt2@Ys%JJ@SChEpbrMG)B)8F;kI#m*t`|iTx7m zDe++YX}W1KcBV(RiE@fPwOJ^a1rbm!ZSjXM#fqv+z9_IvmB~MI%q(!g5 z`c^O>9mki#b!qiP8Q}*7PIy!$HjlY|`%jiW-f@;M%j};D-O37kw9nIwvv<-u>=I21 z+&WBpn=WH{Z7R1#EBpf+2R9egw{a{g-J@64PjT!BcX2{)X>E#3cABJzU~F0ojdfa0 zWO?xI8C;SgTQBb+1G%5=BXx*8_~n)7@iP!azix(awusn93J}rJPuJ(AnXw!j_me!M z9FXw}dh1>99b`{MlJEemGelFuVB~s6gF~VXeJ<#FJ_S+0tfA3ER>Ch4G_Xk#5VBJo zl#_VGH?W1DBKVSC>XS}m%^`ZL~gbk0C3vnyEmxo7Mr8ZtV))A&8rsBBUV!9=BIc%r`I1 zwoIbm3QNIpbdWhxMmUA;7Nm>c(qQGAmeToS{g=vz_>#-BH*kdVo04mV<{Me_TLy6l z{hJu~HLCRBLkpyfjdUwzR_X>6RW%NEC#K!ov)r%P4U>6CcUs3?_9yDc8aJSic3Lgd z=cCtIar{U#_b(H@T)@gTCjcFTy0PZ1zgPBX#t$IifAMQ@Uagq6j9)eZM$+2GB_=P;{#j)L~{0C%-NxXgsn*q%SFK_m+OwE5YSNI%3O z9q!18f**{y$KnDpYw&3wr@?AD@T2 z{n>0bWg5hI4GLC|aSK{XuUZYlD`feGwkG1d5q*l$qqio6&E{kc(p(>+BbjYL=7#wK zOy&lYBm4?nvk}300H0BParFLqe|3yA4bvjrT~BZgp%>Q*#9i;p#fQDO$f+sF) zO%ong3<28HSfXktjN=Px7vfHnCqPrt!Bx}BT0J6@5B$}TqsKis+4w^%KYDH-mENHO z|L*Apg)bD98Hk5JbEqh8Z1{@selevC$+L-dWTBtzT&M+-VHX(3B}aNPeC0R`K(%qnnWL zU?UvHZjKW9>v;Je;rP~k?r@*EF%lj4`fbmfy4R|XJt?`@=log($i6~RL5|{KQQtq% zB!3q#XC@iQ%FNDIBSHAdl8FWZwV3jiuSR%mA`-oyxw=Sr!;7-(W!kc~f$Xzp60@i7 z_aLoAvTd=Kmw|h3JLe{rlDd(kOuWMQ=j4Lkbta1DpF5h&q?Z(Gs?rqz3h5B`ep0?1 zWyqXINmS2A(bAo3zs>3Ar6BTM(n@Vb$ousKJnWn#T)qZn{BLTIM`R|(FVDdcYA>Vs zy!H{FV^vE@znU0-ZLQvk?ZHWqx}!OCh-8+6Wa}86{29B{8Nc)D+RPgs!`|2`)QIm% zt5wJ$VFd_+l1!VWRVjcYM~CN3|9V+DO=O`F92ydiCl?7AHjPxVBKwhqvx{wvgTJN7 zO;T1;T`r)q}3+QG5;_g zhgGp&;4PX^jTdO4r_g-B@BxnJWXuTZ0zMuz$R5EUsL71{e5JJd?AW4>F^if%>Dm!u z~|B;c0@;Wb4T-Nk~-n@zo*LYR~qy+VLn2!}oG6IsGtoW)If`t*c@g z!gZEMi9fZ^;P}*!gS#uuUB;*#3VJ?g()LuV$z3-W@X#W7Q`tQ(BWX^^Xf>P^Z%Krb z^))j)01S5DhF{*4N4>`sZV#TnfsbZ4BZ`WH0e|*ESk&7aoKDa298;{IVjv~(tF#JV z^CvS^1ie;3H@QFoxBajCKg-0#nmo)odMZn1ytpJVKFKgY@^b3CHm z`bmp)hszhQsV`ibJYGbnP240L4m>v-e`*&>Wrsswo1Ry?)~z-4()Gjw2SjoRgQp1; zvR~zP0As%{E!=pTHe=_fucr79*Kdc}qsEhsQydMvty`R*#HA@oMGBL|SH&pV};) z71jx4;XM_|5gbEX%DSnZyvJUH^((<}--b`1zC2VBJS#-)S@{6T7nG ztS%FeEks9;P>0rvMb+W;rL-3Cm14 zS!Bd-<1k~Q0S}n09|GFyW}I||opgfQXH8AK5@+skZxQKwi#7u~_m-C_!_AAXz7D_6 zACNu26#=rG(9_X^p2(dX%klzZANW~&%L0UxMFZgr*rN3CQTnGvz_OzjguEU}SFUF< z$kkk>3G82vCW4Qod~wpq)UxqslxUtAwT=ZTIE_1$H&7Kio<)~|nJ1{)5dFB%%E6k+ z21$~ml8=WoX}(E%F!^WOKFox8c$J_(K^H?flKvP%EJ~T-XVxs|Xc96w6mg4A-qZ7& zLkC~&1}p}@x!AYQDNS;FijkO{w=n9JyOES1mnOrms(}QN|Ds_lU6Pj`a0}A-5cJjlK(=~t z=EhMSnXjN5q5Z#p^IuqC+edz*^7&fr4d z{zT0$%c4@7DB%kp-I*Y6)TyD72eQD6a*_SV*j8RVCsd+8_nB})@&@1#`*>0>3 zdlTI6LL0{g2ZAovWe?deUiS=2?^NOoE~w5vgw$Gled&v2fgMa3#pe!*@o;OB$LqYA zY2uUTql#O4e96JRW5Q9dy+4CZ2Bqn}k`LP1Xj?t@cA2h#;qED-{wf=19 z?`9#fr5I+hqQ-ruOC7juxzg@7x8YazrI(Gn8zN*`_XRF^cYUz_s6%gl6W23!m567? z1}Ac8L*N#8Bz4YvhxAEMk$BK*$Ro_xFYi@O-g||Lra+CKj2kt_Ais3r(Mt#=K@j= z`|3qh>vun@Fy^CmFBMHLSC)EMtYPEy0vAopHB6%kP%@P`-1cV5qJQs=yUj(I!1X?3 z1RWHsm9vddA~h_4c2J(0HkZdw?x{+5I+ox;y^LTIEm0V+ozXk+TgFUDhbmZ)HRfKkjbiXkNTc-0b@q$s#hJn%wllVeAFa7UoA-UqF5RG~l4zX-go31X z5MK&7ikI85luIbvasgHT4H?8GHRm=fL*EGR`I`cQAG?U7d%Zib$u4}qDWwk`4Z3#< z%bu{)T}m2$qcZ;fG<#GJKx9Ff!X( z-0OpS(nvzktMiCha8q!VQqzc*;?&U9CM|16!r?A}Sur{5L#61Up46cd-|lO-_E2u1 zveO{oJ2;Ub*Pc-;hV5byj;wW;wHh@epNA`vGLQcfbSejW`4(wBz zj;{Ny@Lyln5k3T|)pdH@T@bMl1g1Usy}drYzD)@rSRL8Mm{qVxxaV2I_bg~{*EUFw z^^=SSpn={pPId-lhZM2G)EK{aP~#2V2N)uJ`+?UM0i!AQ0k_SP{T^3cy=O3oqhlHVqcqa!-EG?X#D(><|2sFY>J;abD8$O2BXC?N}qR z`?GP_S4&o!+a7t+UDdWA>4~U2fPjW~fbACgr)t@qygO7+bitF#3I7AEOXB-;0RjAC z2f`Bl8t7oqOQ{~GRVv$rC-8|+9W}Zu&Fdq=h@&i4%90p6zHgVNd-^EG-FlpDC^NRE z4iKAl-gb3n`P(t}OFawVPx0BY6#prkK(9V?|K6E##Aoe+cCa~T^M?M$Jr&qMqK6$F z{>F2n1{Arr9IXJo3Ub9)VP3x?sEldgtH5V^+|oR*Y;0m-?>+s@mfp9LT0+5=riUz` zW<<>oA;!j{Ry&5FZkJd(q3ceQ%}deDWB>&#?6#dj>hap*F%p8jhsB7UpQLQW1rszn zXSq`CUQa5K=Y>Ta!U&?f4Xc?+OwxeWXZz0fhMO*@?qT!9gJ4CnK#T}?5{aKG_W-d= zik$owXjTl%tsq~x1;0Q1G*YE|`0BgZOG7#L`wD978x+4@e~!jsl>56n4Y{wmPVecA z((n0gCC9nJ817F#Z{!Dl)lqh7C(S&AY7zYsiRQ@L7G;5T=B0i#!_5&sqbstTnnXV` z|5|bsWDBPmcGOXl&-1A#rgJ3^QJwnEEo$_I8t8ZieIZ%v?kI(_@#zMkO`S9q4XDak zDj2o~EuYX%F9<=gQm;Cx&7co)L&;r}_Ql((k(T2=&x04*7z*laE%@8ySCZ+6-g@YvrIrBn7hfAJDYR6yH^>*O7Uj<>un( zy*(IYF|5{dz!MzfhOK|V^`V)gNDNhsQaq;N&k>yoA*orqSr;K?1jPMK2N;}fjkC7Mn`XuL! zZW)a4=A)`d$idX32HNJqsqSM2DE>OM!FYhL`t6}OJ$HhVeuyRs-O%3|do{^OHHmK- zTQX4Pr>sasjThM!16RC)n%VMo??eRjl;cg#t!G@+d=xe7f<@+oY|y3`ahNl(k2Jcx z(^)@SACnqDru+_dtVh+GK~8d#(LYKVD7Bo)1_LxZ=Hd@u0EYpzvkdKt-ZG@PY_ z`GhEKm6s@~snHjd*ZN#J(6c*YAgA4z+U-sFKx+5-egT-=TUL!`CooZ>7O3GL8(2?| z->k@Ti$CQ%*7F>6-#CV9fA^Lx#ENYG=SV>n4&{Kt^LX}ch1&;!qtxaS*gC4oICG{X z9_03N3{~PzQhUY7L68gDFzq5G6)-nv#cLXX1h!69qHiC#7r{>w`Q(&x5+y*b*Sl6VTeYz6Dy5X#(L8(KNU4g411 z&|FDvHm-817SulKIPd0n-%D0VZ9n%p9G>!Nrl|W`Ja6CPufxyh#T0Gv16c^ZVf15N z;*J;A%-UVh68_2xlsHsxsBI`YF0t>t9E)7leWPSoZG&Z~8(>ivVQbQz;AO-P+Dk@Z zISnUD5}d+e-EYF+MU3@3Y&sH=g2zYp1^lnMS+c4ImXd1Z-`U8Zxy-{b$+bEyV{b?r zef;28T=4GqyBq)*w>OL5SSv<;1oAeCL9M26RDge;mi-h4fVO`nRmJb2u43oRy01IE zp`72iV^Z1SRHn}F&z{U)GtN4lavY=?8gc@ycPfC196^>QYIJcs|MeP1anrX!5j86b1z7w&p*y11?s4D#xg2}`Ks)%Xn zari=?O!fmLRIuA#-GGhUwm6rrLueba5YJSd4|{P!IJ0iAqHIS^{qj5!E9B#xgZ?`H z!NMJOVQmZqO;K9E6AMF;m|h{Q#!@-bt;0fHK2x|m!g?c375m~0T(;Ob3=U2Ca)GOO z#o4!YOeyT!Ys@&%3qg6mD%(3|jxkpD8*)-t`gQX4w7rz}G6sZ09rViQ!OQnM5dnlt zGN$NY%%H|6G`~~p8y!PYj^So}JJwPZJX58q?qS|gSTda_XQf6>ha4i0PWsWkj@9{S zEGDhj%gDJBcpH4Yj%>^KfnOcxvN!UQ-GF7yC&9Qlc{;_b{!Ghut~@y~@C<#u<>uFV z_JOf24e_^z)7Avdimb-pw~+YJ%a+&a+qvDX0-blmYzsZ!9cfQT_m7}!IfO*L-;ehp zePQTYgt!tr0&s*+evY>xCW&bD$nVoE8D`FXZDiJ}e%*@_GRVfW!|)ar-+&i5DgM^G zntg_6m~47OY^j_oN2zVceo1vAAZk7;UO*EAM51fNh@Ix;c-2x+g!jn6)bI}*O~T7S zISwLn^xw2OkD}~(aU;dQ`prE+k{tp0wajqatL!Taj+tOwm=i?po|+^jA7O^Pr)38Z z7#N_+Y56;M#aXoUre^l`aP=dpBXZYoXDr5O0xHa$GuQn91$jTjH#lmGtMnAV+{lVo zrO3=>wyB1YUjKj>vK~$4P4PK-bfH0Wr=^Toalb9Tjr%*fl18d5 zTbB_D%!~|*-6kxDhUJeVUxta<8}j+^9o&BQ8zfIp#yi`vCQevc&GJpdR!fUZsj)Vg zHmY0TP)LNU<}iEXzSMD*0ANcKOJ7&)KXp2D++*AQ(#wXVEAAOF*g1Z_Z$^P|ABDm- z33aK`C!>x$hYIO5S)x`}h&uAd%N@Nu4j!jRjMCPZvoRW?E!Us>*^s@cIR_x~2l`{H zULQKzw8ttH?w94-e9L&M(RcU|E?r<))*OtX1nBw9@JkheM%M?woA8Y%nzy8TutW-x zo%wLqV>*7@l9%l(xsE1Y zcO^c>_!=#Q_R)Y9`(E|d+suwEr96sErHB-}^P$N2@8dE(OnMG`>8yb`+Z>64^tM@Y zva<5RDP%;Uu0NDE%owjI(7xIKiG1tk>bn|J+|+C;Dw;Eg{G!`dlrlOqfzmOL4(}v^ zJWBfPZpV#*SXP(2pvhfJGvzjP5+O(H?@(Z&KvVfmts%S)>4ba5X(J*#;m+ON&Xxny zTf3>Bz`01XA_<%K(KndOrLP&$OGiuqP&sRtoT(MuXLGf{(Sx|(^rn0&*S_?p5;sj| z<;$rZI3SJ$@D1LpgHml|_K~0o9RtniN!9-*Q>cVrO_+PWkUIjm~J_bF%A-fBVc{nkIfohxN1RxDjwv&tGcjYlo#CNyVx$0b~SIz7#d+LsjNl!&c()v$CNP^<)!f{Wo};wU#M0^bAOjot746INh9uF$bI;Q}?W%KB?M)MWah=YSD6OwcSFy|6(B0Te zBpMtYT=p?T)56Ec{VpXTF@9kFcf|?R;|scLA{n2R6J~hUnx~Tno)cCd1h~%8pEAcM z9`buqzmwvw*P2*9D@SWnNQL`m^GB}4WaUW66X5jFE!=7`@{vMEOq=G&dlY^8aSTjZ z)$WPiEk!T7o3ryS_RQdeEMg0hEr4KaPMIcIb|3rM&P_+49-Z<;S8~%~8c==a{aZsB zk>zp+g}C5>!C@CUu)C!qsKEL|)S)Z*h3{FJ0~(}~g2`ft{7q<4m_7$C>r?Iu*Uf?H z2{ejm<=%j1*qoJWltdCRPi;bTLccA<1@sz2lvM#d(9^G0SH}qYG!a(G(jLZg&ItnS zIts5FS|no6iKSe>eO&ob*S+5cjYd#v%(iC>(SwakH7l;PLHA%~q1Ka8-4$SOYw7$D zHY>0p^X?SdhxPEm{Al?Zkp?bh$GVA!WPlU(dRvTyw_BV@R8tX5K>bRrf#QzfqXqxg zLZz4VHeQ~KA3Okgwv!nqFxacKcEOUCAZvVd)U>qeki5kZ;DQZab}i;7;W8xRLJmne z@tz8w?Cdl9I#MuPAv!Ac3Rz^D3+lo1~V2)>cIB;E9 zSC3rhS8sE+csEM#ggw?~17BrU!zRt<9EaVe8SJ2kRNy=~KPb_G&oBmNQAV38>I&OQ z7CK36xCr#(D$0Yc=z5By_s}O`!=?%0m|Efjk|5RbDBRH&*!sC(qe&{9KET*A1)Y0Q z-OE19HCaBBk!p;vU^1rS;Wp571R+bkB{k1=xZHEZv+0%=A)|ltkOfwsqy;4NjOlFi zue~WSjYI1s3^f)E2duwTb|m%R7v*`JHK)WztaX+40KxB>2*}9mZYzf#<;Sv44!T|( zkchmz9k%p*mrV;$xQq^-YNhN8<||n&`>j(ypZ}uUkqj=onrS}gy+J7-#;L5hV#js_rB6R~5fgksZ4cX)Kw9iig3_&hwzb)mux zt}8#OCvULFCscy}l=+m{7=KB%e{L!Dk4tG>dMNE6aZ42+iGN(9Mpzy}A`={k6HUeukFIV#~&?A!9qx z-qvlmO$U&|rg%INE4=t{haFBZpJJE%AUh^w4_#Y3G2<(yerB8zxLyBr59)yZrdi(} zi4T2(QezC5Lz>Gc~=nyYq zQS>X*kR^jVbb52|rnKP|uT;{TJS_DzVu(G6#EEf_1BrqR2HA5TW zQo2Tr4vx#^!u0&CziGBW7S2xb>*5e^U|?t=>zD4}yi|}SHY~WFh+!LY1$Y%^qljge z70@!fr2uX&@p9}e1rlW@w>_xCv>vrET9_imG%um^{Pq63u#4Y$&Ae*};ze75XHeqw zH~E)z?>)6nm{{L&?QJ?@;uwT99K$Hq5KTK*t7Vk~CTv(VwisKVcZZzTC>lkh{nSMCf*zgp&_|jh;cy=P%G>!## z=EUSHs~$bo_Z+2tuHHT?#Yp&77;{k@XJ2wdWHR6rF?8D>+gSLN3W`v%JxpOoU}`@2 zw};N443Wvgw*>M13?aE^yns?x9=B7g4TyZ0t`+hiiO}{6n9zkkm$i;P;&kMYm-gTR zq+5eR>}ehMKki0jz`!)7NG|fi;!77@dHE_s{9MXc*A&D8kR|FjPL*4K301Pth^Vo& zcXRV>gypz&#Gi(_OC`5fDJQWd3p-CwpRl>`Sek<8BC+h5G&}5)Zj1R$Xupu+?HI`| zST!Q2;4Jcv1oa`{V{wm3lyztIo%k3!J{{IAcS;*w8fAY|tS13~O;iBu^aFpGISEES z^YVpz>>=CUUfq>rPCWH8RtUWdTa~>dy5L0%{o|O$>%QZ3rlL4>PMEgj3OOR;6v?NV zL7mJK%%kx~gfgGshOz7;+%LpLH!c{hy5y`4W$YIIU*#B)Z$?#gt+hRs%{=BX>KNV`kFc!0cn=5^An)FbYICNecJpNhgig zC8_(tY@Pbj(2pc-NLdGy^P$|avQSAL7UzKAYO25Zm=Y=pbXYIV@0XHccaz(qMfMQN zM*Owgyu#N5OtBP6c^6`ZH&`t=aco_NO{z{e9DsF#$jryDfZ++xjw=+|uS{8KvOmxv z9wv0W;?3`{C|m6=^4Q}qT>o5p1=uif0Ok~TycU4>2tQOxg-`;BRip4fL@nVlW+p0g z1UM|1Jz;sD7z(&@S@aW+GmNGjodyN`klFVjAr492-+Ayw^~N+`+7@A*o*w(L#}01N ztUrvP@k=x(gMPo2eSWPfPIOokUIG{>o0o&09G}7e;X=Syek5v#H7UGpZMuneWL&Ma zT;!8(7=d_q*Ecw(Rlz;tKR+qB>6avEzm9$gQ~l*8DsN_TBx~4EKL{E5S$jLSSsQ>J z-q|ji&FC4hSNHlU2@R=*rj&S=yE~%CYeJziPvYd~u-ic#6mWq4*P8->IQR676IrZa zyTJd+eR16RV11lykMAtF<=8aL)coSldN6t4D_=`y>$1JfS%{PaHG&HPcS{;!%OvTY zm_6BWtMt-u2x0pDfkx8Y+?P_XTOt6JHK_qMl|+pFG``3FXv9JAXZbPFiuyS~fZGi| z-7@r!a!?kwGtKx6Jns(OYF8}bh1g}J6^VYKj6=v7hF`YgTv*GMXF1&&b@xAI{z zEqL{5@?%ji&<9>2D-Siht$N%?ZqbC_^SDbjb#Nry#-)Ox{;2+#hmxTQpD{|u(_^U7 zj623vWU&qCw_-`PpHJg(XMX05#SJMvaa$978hX2^L*h746fd4Jg3GqIz<({4XWWUt z7FZ7he6d^~wUZd&RE==(p*bhm^q8jfkiFJTPy2w&(#->VN7GY{UVY{8Oqog1o&V75 zirV?wxX-k@c&AJ(d7U3Q%h+x5`VmXlzo2Urnq8zCyCLKzN|-c{Yi7^pNJ|py46eca z*ayNJLJH*RGV(Ew9Pe$^?}%E*r&znNEW^I;O)*T18)@A$@)H@~ID)tOBh3?uQ5U4GB*!QneML@8;c z`X_GG6wW!;j2J`NxsA~TMru4xyE2tAuyLNb2b%B_{&S=YvrUE=ZdK4oA>PE#c|IN1oalq19TM(%{h9S)EvIQ0r4;xsC3WLos~zo4bxbe}6&g zZ4XV>&9C~#nnH4HD${mwETwR_^3&3HgIwxohHB0Z}G|<-IF4?KY_pRw(zoDaR!}`v~o#Uq{1-wtNhD52oZLrjl6C{M5!hcxrN8UM0 z@z-6sA9=}@<}Yx%epLTnAbB0qUGtg>nGU6Cl%`M_g3<&?N_N7rpHr-k}~UR4Mp{QR?1jm8?=p9d0KqY z?X5gP0bo+OTfMRE4Hc)%L5DMBpo));W;d@*sRHGpA^(36EQVD;%9TOP3d%vnDO`qAa4(sLPs>2Z^He@uWEDE+pspkgKk_O+3HPWk z*KCEl?#CBxsV-+6JUSxmIYJ_%BwCYQtqVngi7YY=Pj_3JRc3b@X_CP3A#cn~!Tj6X z8JT-+sc{@m^|A_FETtG7d|t@lwYRZ%z}g3qx8~=eV~u&}HF-C}5HU3NW)=hy>Md0^ z4?RHEnZ5tO|$w1ntUC82+(t`YEA9>OT|1m9#nIT2Pu zigIGXm~`IxfA?Y^VtN{TC%6BR@zgdhp=SsQBc;Rn4Xy4Lzt-1P9xHeGQac`49~#L= zG!g>G#f(JC=7tjPlvBxekBp6Uv#7mvY}l_b7}I(+V77;yR(B-7wI_d5 zDa5FC3Wd&Lu(-8iolz$@d$f*_)9Q-kw+J#KpCXSUk0PJ>F4Bg@kZEK)wg%}%j$r+> z-k|2rvwRh-Fewi8PWNgQ3Qg}cQ9gr|?}QJww&tzJ2imqD9Pqyt8+vP947@g_-((LK zH@!1ZKC6Hob0+Q~SPx72dYC8F(({u5@JRtYR|JY;12_lUhJ~)AH=uV=3Mq?d<)$NS zF`~LvCD7D7wn8yXK81-S3U>@r4+YNwwT`3}()}8L&gW?l>P=QDE}@|qO3h+eU5G6k zZq91m3X`$rz~}ewd30xdxpLU**2(0Jg1Fw4v?i+8v8~Q%n>iC)Mt)tZOTYgB=OYPi zKbya0U97niYQ7`}8eq8gJM0nSJkpMQTCmb*RVrCW5b;1Z`+`9ht-IwYq?J6JO;$f4 zgYq?SN!W0f7Yo(_JoC$ZP)pCHE~f;_54f!8hxiU5$}jCWTTOh}BedF6<1%g%I>-(# zW+7m^8HAk{k*6YY%_7iO;SYGMNGEv`nL>74T-V#1no9H5F>Ga1NXiXfvdzO&+>RZh zG&>9zwwcR0u!@V+5p~eeAW(aaZ0IAD0KIrAzPBG;97cx1ul5ho@boBdshU=>%RK6e40F94$8~I$!?j;%;ndKEeC6%) z^dqliFQ<6o@iOFm8%>`)77N%${gUpp<#4zTl}JixjVG41K-#u?rU@pT6f51a<$*%) znYU1$qi?2xPeo!{Ia9-`2|}w&uaL=C0`a~2BPrfn!Q+Xa1XlbA^1y}N$NC|@JI?r_ zQuQ70Q<@>r2KHm@SYHdP*Rw5sVa_4^72A8KEES%wvas7Vt4hZ~yt^k4G zTn1t&Tr;hB*afbwieR3dW+x& zY8`YnEkD#G1xn*xPO6mJcU_sHk3qNN|N47#qLUB5dID^5z8{=l5DZxVUb>U!h-a_Sg+Be`T3q z`H*|wn=RUO5#)@$yTabd%BZjG?wYq=0P=Uhu4(*-ND4VntBGj8&@$}+LQrU;gMOMc z4c4CL2U|@RbT`=`?XHF2<~^1QWZ0I z)MUaw$*80xK`FqxJlEbQUj^%0i+rY*KU!_-SR|qhRj~d%ZBw?%l?2}2uI#K};k+!5 zD|-jigUST0;MyzKUYP;!5%8Xu!-IAuZtx8nO43sOS;XORxM%(WLA3$P`n09+TpdeZ z0l#vYKdwClNhGdMVb(_w;o*EK(1cXeA^ECE@^7Tl)s;+kdz8dyFzjJM;q6YQy1;M0 zlafF!e1Vje#?NEeU*j@!+~eI0|0oiA>o@MmbJeI7~7E|t#x_jz1xE}DX! zj1+{GVIZs!WCb;MGhO?QkSd_fIDz(j0-zj6&eoq4<;EUHjEENt<)twrcp(OwsbIVE z{DY=(Jsn3p9-69P7xQu?PX`A*w8}Q9ny1p5v1)Ib zUc)o1X`1M4n&&(#L9OkHXx5=1zVa19Zp(xs86zrv?@cLc&Ltx)b~*X4JdN<9ODq(M&9x;$DjK^t@zpT)Nr8L^o47%&`5;pgz} zLMs-A-dX*uNLx1#o}a@|Ccr#+uBcYn?I!JA(I{nl&^MKSfI2`-V-1yZJ1B$=wdDEg zCKM=dFMPz2$VIPs>4`{uI~NSQ9P0~_?mCCB=bD}*r4QJBg|LlMvC8K0;))%OwpWtQ zxJ~AB+T#ZN*K!%H;G8}k^jza@VXaAP(^4ue6AS1JR@RX0&)g+u3@VG&Y=*rXg1tLS z(in%OvD#V67CQ@0p>QgAxyWE@8zm1t@0>F1l~3245+10yO?={KHFCHlL;iB5UY-?E zB{-}t$NuoMj^c)#wKf)F-7!=bRM(0#%)bnO&-0J_+Z6! ze@M!@^hv0~#8MHVBq@?0;J4CmU5;4)c$+m9G}wa=y4{9AEtJFz{tviOXvSh#a#oLd z=E0ewoYH>v$EfDilZq+gDX8gQL%W6{-_xQ4{I21$YEyhAt%b>05-uXkGs1TYHxfdz zb6xiC`@+4G&FX;H!%EAg5X3Tj+>)u!=Upj|mPiDENU98sRBQh7J-yd-anKMcq>N4_ zH|rIoQPtJkyGrMf>Fj8Ks}9b^!=x0hp(K`=l^Bv}E-)+%=88(jw$~sbeBWfJ63@{~ zWNgVPrAlh^r#oi^1?ypFRBzLW!dvn!BUz)PxVa(H=|Sb1EF3HOM{CMv^68}#o!4y2 zxbS}~kA5l~i}&rx!#N2$96&27Plc$o(#;tIDU&rFFgb(OzT62{jR%WhjWZEQXs6~( z3}fQv!8eOK%p{jf1b3g}UgL0%)BEqYPe~rqA6P6?#V?$y%UG%Uv~W6y)h?T&#j`p1 zohDQ18wyv(?{BPeK*dFwxKvyC6I(TTkPkEnp%_3D94FE+pLz5RUx4%oy%>J z!+AUL(29e-L0|iptS(bf*#br=OJ@8#;0@A%gCSG4m5?lxwuvg1WOD*P*fH!cVNJu(Vj7~ug%N26jh=lc6EFPVt zNe*8&8fyE}hJ=wKap7wkgG@+?RTf>;8t^+xJ48|xO^}MfLk@qy8c7XgEFv+*m{9FG zJ@_MjP-w*RSSL1&9d49jec%h24^x5TSP*Lk_hNCZ0FDJ?negHQ*d zJ$Cy!NHPBjn~s4~FHyGtG$<$eCck|}siK9(rI&7?TP51E2t$<)pIV75(pU^f8ydrl zt->{83KEKl5`Qb{_tn(;eUW;F2k!U$Hnm(`3kg!#U%HuaC7Ut3wjX7gmk}KBe4e@Bgknf9YKYBhde~oM9 z@J}WdNd)vxozz|E&sHS!G1&hQ7M^i4i;^kF%<@y-DYwo(S?#ilU(J>6;g9L;V&{-l zK87m)nYpr5TA>m%mwOU*#kQsL0}0)-9;9K5zi~y2`L^O%7TS<=u&runMV#j_4LF1L zb)h|%P=?18Q?4hVJIww2Y_V8KF$6xq&%zcVT1wB_ViX!cEl*1t zOiRV0Hh~ZUlW9_Ua49h9t`1Dg#CTy0uLP!sO$9@MgLGPv3$fa!i@Bnsh2n6=$ODrp z<+LW@NF!kCnC=Tn7$ft0w5fs81o7XB;4*+C8;M2x_Ov5ja{9mI_LO-Et+ls#zU59k zjhF(ii6U*J{+D;ksnA*{wbNKd_(AmI>#FSXK-raYP%fnk-o}NLNJPmPnM`lAGD{_z z^?IBtxkV!)fZ$c513rZUvC77l{$aTwNvw57xH+=SH41&ca@q~OZKQzki-ScL;e-& zvkGvbuXS_QSs%C2u(e`RVhyBSu8=q1n)A7f>r=K&$O4C$6bng{ug1bT0)-U?KXEkL z9y3Vg3ff{->*YcPBiEaCMy<}@mNp495I%+_ zu+VADW%HsLQK}VE+l2QigY+r&MDQ6=S@O^GIo_{deEnBTz%_XpTr2^QGQpzXfj7i5 zy^~Y#*j$v$p=B^w%x*S=T1ccaS@gV%9 zGp4tz#F4RdzCWRp=y)wJ@dpfPE0lj8S*whijm6yc16k0Gq@IA^O8=D3xSe2a^);V+ zkH3N61zYUJ;*Y{ z`(bk^0eKSLzZ424tuo@INGR2LtyZs&BK8ObI|z#P2sOA!qSUjc5UQ0Vq?A(dQ&z8p z>y;pBN$CN*9gIt8l0cCM{0-u-u(i$D>X+<}I2(;>!U#pB4vi|O5sL~1O#|XJMx;qi zMhhCknhQk%X{Sg=*}(qk$A|$uh0hwsWHlVVp-%(5k*@?xN40vabl}4r1Ja`w{I+xTx>5>X9lgxsx z<45Q_i_^R$0t2dX7O$upXYq2CLD5w%N)w6jn@OAvbFQGC!dDLysLHrE!s6sZcauOH z;#>heMfCThiMM%zS|B`zzbA!G5W4d0g%?N(?3Gw11@ga83XuROC8DoBMoNV6mvZTU zp-4IS6Mi@Bp##e!BKqTLv6#|95twtZ5>_)0UMkWQYo63f6qAzu#5GvMm1W&{wO5Io zq?ZJW7At>&yUAkS>1T1_%73N^bS=Nc--b(ApVR5r01{^ynE+1AH*s8NQIfbs z#gtwLpc#eGgbEXjtRzOf#^-5DF&fjL49HjKNC|-s=e~Wu0&q(x5!_u(t1L|meptz( zEIo_fN5Fxj!f!0T&rkUG8U2X&IhyP3t;_fJW=ppU!|j<=EBsrUgYWZyFZ}_>ggepw zE|?86Ek2LgF)e=28L7}?>Qc7B!TQyYst{3?`^A*dk_CvdH9QOJM zF^d0Oc&IufTAYNF?7+!0exZqLSGL0$IiG-?e(S28ULYufD}xjMZHd-tG(p9pr%fTd zJ!GOvdn6i{(d5*MfexTwHpYp2s@o*s)#}zssd4%5v|8A@?o00oh6IJhs;x78KH(|8 zYHQQ1Vgs}i%`?JHpQ&o=Q1h|x+;rzZ9?dl0`a1tC{lQY$Sj=r`a9GPvJFY)`{!4@X z_kI5mdiLG_y<1iv8;XY4-Y~HGmf=Wf-3^rrA$5Wn)C0B|opc_5?li{G(s^*>Y4fmf zBOJGj=lR;WOY@9HBw6;V)1Hd+f!9?^Ez93zEsO{$8NfnQ&e^;XgUEqS_7f5XO^BwY zBopL}E}c>|BOnPth^45uWDi{9Ps0(a$5y-yW%kc!YCP}@%bvwQgcIoph0}I>)-(?e z7bO{nCCJDIWp*AMnjuFplz)VR1q@$J_WepV@`YmLl>-k@t6o+sj;z*V5Q+G@ollce zhPvox?P>|BFsk3nt_(72Z_ZaY)D)75WYFakNz}QKqaB-$Z;cyU4i3$L-=P`aZSfhU zVtvr*C!TNfpJ7Sy1_BVfl=5I!7E?puXfju6= zcD{_&;J+%$dRO~;n|!^!z9s_IVf|nhlS|Hm!%%A4!J#t+HT=?N=E1?)RjVWOK=Y#0 zDO|NdKM$^%5pF4$V})L%^0JDo<-JO%u;2fPBMW>T+J)e8^*2`=Caskq9;rWi_PVy| zwQ0EoajR4$jjh_=m>Q_Fc-nWij4Ra0$}+M&bwhbO8*GjwhuafUQYscgtE4S#IJ{!x z@vR9cay7;I0N#4KmCy0s#6qBY>mhYWU) zU8Ln~ItR;WJT60M;BZ%NtS2kS$ym=g(tFlRGC`9dgasfoW@jS~HgBUt5JBY$?`Jb#?ATKsvzk4i3x52zk!M~1-#9D*iR^(<|T zaVWXw;(sU&N|BJ($iZWzIo1-*cK8*~D)prpUb+`d@2p9Fuc{QjC(?)9_CzFP!heV^ zZz5@x?7~}d{OOCg^S3)<#Bl)!R{``%(zyFYU&6;{RsM1oqwVu>71Y%UQ}f`+Y-k9I z#u1Fc*vjVL0CmqCZ8Xv~^Z4`VR-JiJ ze>#&9y5~W|ta^RZJZP+{$i;p}m&UjRl?sr{PRfZc%NVYxPvWl)N1op|bjN6uPsv1j zub(;W>uZgxDWth5Z>}faJKf<1x?EdNXzad?!P1ACP)p2GmyT-R z&Hk=^cRoJc`L&ZL_7=r*m74QfeL9L(NDI4eTPZhdWT~+e6PeyxJ*6_pci+9nh=#-#(4$gv>m+_Ox+Gy17zvcmcH*wuB)a4!BR^g#QR#9FcLb65nQz%-Z-pD0pRh0CgeUsUQW{wy?HMf{s{R83A8oCoSz z!}{iVpk17pY^7Y_(qgeoO<6iZEO?Qc%7r3arMjFhl|omFRF9pdh(9(Qnc2VV=|hdK zmaPpn{W)7^;;B8m9~n*9>iTO7TU*?vU#uVK-@u03Vyo6T%$fdFq$6h7y<^L6FudW; zk*I&*`qk+z104=?v1>ThdE-bjGI*dhxuK`oY+o_35pQYk>}s*6;t@k&$HiGsA(=24 zYElJPcYhyWr4=K72{m*KYr=9fmN*nk{xDhYEdCrEs3H_kr&E+d;u%7ExN%0bWr=uJ zj*3;?=;f<0z5Jlqa^s#NeyNTQ-1y?P!F8+BS~y>FiuM=Rqz4aoalATp-Kc2$g3MLOX#&f@IhA3gvn_!(*OY!Z~QXv!I z^fp!@nuEFy?b(Shyi~b~bRBH>IXI?uSR>+^|2`9S!7nWN3ePnhwC+|oxQEZMQ8KVW znOurAD8%$hK(pk6?Mm(2MV>h)L^nF=S-VC;Dh--H=Acnp)twz~i^-rskuu|o(QAua zPi&4E+YXJq2tJ?~n!Jo zBhlql$E|1g=l85hF%lt;;Dz!xZ*H#J)a14n$2#gZ1$cDXUO|^yS1Y&ciHW*%yPn#g zXLNeG%D@@Xjlu?t-dNiU;ic9@TfBJ9BDxTGeA7LMHV2`ud=s)uEw%#t*XlW#m6u+7 zNr87^D?zYf9)EU@Hk;|>xA2=V4As9z{{qJeqk)2;>#C{pb%=6eF(FquWVf`gVnb1) zUf;r}z_rYS;%pJ>Uj33f*(f7_)5ejXjszD-Xe4NQ5q`dc_#gTJsNvx!tiKGf$WH@U zZ{`m?Gcok}t@R$cBG`TOg~MD|Q$zth0Rq`GF4Yn1n{2ZKHrw1C+IrXeK&hniHAT&- z+L%@!ZI4D;BYJRZ>(`I8E=4SfTqRSg%^I6uN6D0Qe#h;dJYG$F_uzjx5( ztRFyQ-3QmzS^h>6p0a)eDN73}>({3-tx$z1OaF0{6~Pf?EyN_?JBc7;twD_CP1aj* zG5G$)Uu(6jh5#zP{A!4m&gYW|un4Tc%M0SGH1y`oRf*}i^x7FD7absW_A0=Y`4$2$ zC_GeIcoYQIG>_jrM^}0ND=6HbAxlvIUT7K&Akh1Y|8BYk(vIBw-*40Z9-@0)TV?!VaXc=?dbrHK#07 z7=Ma*=^wy+2m7_5wpd4x)sXB>M%p5}?W0?_fZ=s_ZiTD(5=KKJjgWB=>4he!1z+Wvu?1`9nem%tW}L@wn)9gD z`qWuagnAyrBQ$`bz*25R@~5n1czrB8E=|X&Tr0a2xn5OpP3xGZA+s^QTnSswjvg73A1W{Q`DA9gu&0dpDE*`Ru{Bb zy#`9bC<jaP7<2-T2}}P3gaE>E2kXH)FPDdJ@ULv>7)Z z`^kw_?O*uevCseH_^S3h&)>82_I^ib?Xh(`?&x!d*4={kz%Km-Hw&T*ajr*CJNP)) z4X4fJOG{9k>sKEY=So-OT>Ks3=yI`_Sff&ii)7Nb6$T50qljK?mnbAcbYCi=+)4@5 zez(D8V947fD3MDf74sSi^5AMg9?K&u$>P6XjC1`7@?Ze$t;D$|&eX7EWDEtl#>?03 zB`VkL{2f6q7Jf9k-es@OsuGb(qxQ9$CQiXPldgDA&My^9#b}>EQ@?gRJAB*vu%U5! z-7Vnj8trzp4J^|7osO8#tv;9NZ>cjHf;PrrH9%H$YAjl%+F{iMR_!d*ZrOW7|L4-M zr(KucCE8$5*I@?{7d~|b7yb-!;aQ-f+d9zs`ER%2ZwR}FT7zZ(_XT8=!qsc!4aIHUXa6JC)pDx4`UHc$*n&;~g!q?q);o?`Ef>%zvwiZBYqg7n~Z1Ae*Iz^IHqbI_(W zYBjD}nC#wbmY!QvoXWA00T zg`5?{LQu!#XJZbeUdGN2NS+6NJ511k)2s75P~~fn zs8>jRm^1mDP8O6@MukWSSIEVOBsvc+D`n7n*mUVVC?y>9u=`%dvN*dyjbAHjFnRs& zl=f40eA0drJ_slUnR$?#4UIytA;I%wSs<$XI5PDqZlU6?41S>{R5@5qw6$IP#Kz=? zj+k5o5mF>2X-~E%neM99@0&1%{T`K3W3=Jcg_{FEN3%MEV&wj@6c}22$Hq9tXlc35 zZ8kd8q(ZIK2Xe0Vb^$3cI>0YoR1C3vITddmbof)nmnZAge0TN(Hvh8V#$Y!7Z@biTuQaL;crnsCO%s z{uL9C>~yu~LrMwSK$N=TMQ`Is!3xyze6xG)jjIC}R<2^BVZRdH>FllZs=?-UU)WsZ zY8;+AwzA>QV~2;5(3NJKZkv}Ciz#t%)wZ-$qmV$iXQZQxlqsp+-3@v}q#n+{=h9z@ z=pv#0qC#jtMT7=@`hBPMqtcz9n$V(Xx4v|eR5@$8+IkB?g3p%{j9R0_pI2z*B=L$l z;Ic3mF36Q)LMYQJ2y0EYEBf z87k9MNXC4mF!-)vto_}}gz%X;e@g_cZ1I*dO=WH#%1=GHWAxFzSz~5xF19jZOzry0 z=-B<6BKmk|P2HM0bLqX6ecn1wrQ{l;olQD*upo3{<6Qwy+tvnuM?*$us%z;2Z^a5qt547RTuzOjOTfS)ym>Q&=YUiSZ@dFWWLG1_NFy~-1tKyy`LLy z;Ft?+u;7J4s#&#?jfMSqL;m(#k8DWODitkL8yOp?70DE`z{=4K&5P)@J^goVO*tBd zrao`$+Fdjo!v%L|r-)`y|I@JlJ7NFPE~6J!3VIX@gB`ySqcIam%rax1d62j|-v~Z3 zLbfwH`qh11ht}6~Omy_oiRp(&yrl~&F6%{`yJ}Az7sxu~ID7Mtj(55mw_JPw;4MG8 zx3lZ`4{qOcSHB~;?v{1qVdZ`-vI@h0eZAVcuWtAon z?!3+$gO3Z5rI&@sib6^zy7&On_UNZ?zoa!m27*fBw_=SYXyrnB(Kp00iExw2jdC>I zCKJa}`1Cdkun`YyXBFgKLUd(3G?N#eqgW2@<@Ulh55h4ei8n8y*8^~jJNef?;cw#z zLk)4X5*x{vVeMNrZu42EN4r%;H5tS1qjAt&sHz|dRC+hd2M00jlrc_lLD1}q%R(ifKn+2=Vt3t zOP9lSF9vD@{C$g0@s{p=rSp^%pLCvt55nmb)*(w58KuqwnzwY!XSZ}$+vnj;T_sx5 zR@ndK&iv-~AdQ@I5hbGR>499lGilaA=gAxkcvVKN$%=ccml&1~=#%c%$G6sk^@F!< zu92zKQl;K)HaJxxMypbL^M1%d8i7DyaDbmfW2Z;P&LI5(j(fWgqPc9XtRH0S3NyhR zLKhSyfwpcC+B#_6<)u66Ij zD=#2SazhtvnB0%L2xFq(2VZkW2I zqyF|=4-corGNqJO8C7<#RzyjuaOY@>b%zEHZRWBbjZp(_oY5F5z?pA{5|&d2jvls#BfC((=!$*hP!9wSm7)_2r&Q} zF|@w}g_52Pp=(d&6-!p#-}!0w)9vmEF3TGxhULE2iO2T%x*NhO3dLR}?##+)%f`F~ zsG`|M*YNed?)2CfHx7JmDCd%W$og}fKj%@Rn|5l~;>E)4w_dv;MJZKE2WN-&i&oH) z&TUDlMkxvOAMRd#=Qbp#leZZAAZD4O1&2H1(-^R79*WR|um;&LE&dhwqYZxu`sYS4 zII9?dgt>Y~xaQ;hbL~|UQEvV2vD;rel4-j2ySLu)>XA(85B7%PT+_ya#Zi8m@!KB$ z@VSw7&;0jekN)L_k)db){8ia)b9*D%sb}`XXE?L}>1rm5P!BzH^UIh6zpp6KF;ZG; z?ia2`303F$O1FPc8j4G*pwRhNns|3VN<;COTsnn#jt~j}(g9dC4+V};J+=z@=l`+j zpZ|{c&v%?*GTE|=zKeI!2at=teuf-e>Y}5^-<68OZ{0YmsVKi`>JKV(p$30yT~nBr z$R$EtM5*gX4z+H7a5QdMb?e>-!T&?sH{%w+Q7YDjT#l&6rTu-|^rl|7BNsAQ-8QKy z>SFDBM#XuIHN)4fXt@5~XZAddws82nfZrh=fxTXba#7;=cdzi(e_Qs|*Ydvl#xqRS zOJ7~y%n|ULIRnr`UsI8uPs~O6``z9)%UjpV-L6? z7ut5)7%*Vs7#lEVOGq;C1{??>;mb0-kPyJZ8#KmX7PEwefj40Y3A{kQWF}-GB;h5@ zM`HO^U0uoDjqL!lxIXJj)mN%hr%s*oSDkZD>Et++rMpJEHZ86QB$ZvP#G!nk3R5N- zukoM#N|Oip*b{!6>Pe6W)cNN>+15?gW*AJ5M|;&jlnEw@?B? zEI2|Qa&5$N97#j(6bNIF4pNUB52VM?ihR9{Qa;qFitAXfyiXh}Tg}2E!(Co!=3h{%@J?0pZBafk3F4WS=Om?gPEx>h1l~>%CtsQ7B=J3psu;rAOsVH=xh9a( zURoE#C1D$?4a@$CFxFvpB(k!9LiT&Ly(LW#i2*f1`vA=VXaW%bymRW4{cbaFD*Dya zWqMRiGpQ=_)%>=@*MDZ;rdVOy;p;y0y-m@^`;6I}tOU9iqzsnKNKxued5ju((^o%u zaP7JGy?@tN{wiPZU48TU!wEz6vhQyD!r==O_Wb;fSGS@kC@a!P2bDapJRJjc6lf&? z3?M6?=8L#j&jON&O~JhfJvLT9b@FG2QKcR)A9vU3;CM{b0hZDsX*y6x22aVG*IY^_ zUOZmFN29M>lOSYIj>*l1v>ZbJy#XBLNIQT-(Bc3N84LXN zM>v2(iQm9@04yRqD1n)u;EjVJ1~1# zJ_4wj6_4^(|9Dz&j`ha7&Yhd)(dv>S)r`G={f;^YHMaR*xazxMh7$kQ1gJ5)gz0=Ad5IJBf8B^M0W(zgyrO)>OA_R#hI35-b#qKZG3mA zE(1I{Z4rieClbdIN=Tdnd3eMXPq?#QmP8<>fs_Cd7m##75bxywI))06NtFRrnxRrVrV#3EdW4R^<8mZ~ zJPytI*r`C!=_0@JKyr+p0_z_(+GO6?rU^PezFUq!$TVl#D~UEVZOt)bybR(UK%qp| zpZ|$gX6nl`!{5Ij(A1&VevE)7@V~cwYg@NywTena6As6=l@=r9TXf)vrc=20wb)`%ErcXWFa zE)D^0#1SQC2XW+wL^HPI*W@t;XTRFQp#w_H*8sr59=9IE!5&jHB6hl_;I4FKNpW!a|zm)1qbMAg_0hV|Gms@ftwCT*sM>8Kl!s{dS$kKdzf) z2hP?)vx#YOULP;qq&zL!BxhkrG|{c89n_ns zMy=HJ!XLoh@;w@*rh>k;O|H=@H9`0ce1ZB2s-cydess?z)GMflQEEm}%|7{CtWuLk z-?~)3hf`|0(LD#`dw8X$jB4(bYXqgHizMJ()G(?MaSf8Fo|9qt9$YNP%gFf%ekJE4 z_!Z7auut3-+(%tCeJd_JYuTWgwG#*5ORTtI<(liq!{~a>+Uv(6|Hm9E`a%Vt&J-$n zLj}L?v2|bGQmLN*r8R5sxS&$qa{Gpb>#G(gQYZ^IRM2%Tp84*RS#Sp&A^e2<5i}Ja zsWb9E+#g&!sVkK@8F?d)I!l}kxP!Jt<)@Q(>dhPl)eL<<#hJWzchI0Z)Ov0j_I7F^ z@39;02*cGg{l&~s0zZn6)TviHv!iGS1M zq2}zOAJkhOz(Q~;FuI+0;H+`c<=F_WJT3B}4bRjVoxyFTbFUmsk5>G&Mg>_;<4$#l zYyF03PojtA@bOO^T^}CI7X8(vSIaYyq_|}7Sf+2|9N+M0bRgq!*VpuTSe;1oMxWj5 z67)hkWeM9kwcc$pJ4AIM9g6ELI*+8ci412mi|*n`a%jB)lAcTt)~vykS@;4RnD*=z zIMnj--BcGXk}ds!;^5oKTzMu3-(mscnPc_G_&Lp zq66IAiemx4t22bq`K?<5-w40}vYZFgg<*WH>rK{S3@eVME~md|wB6qtWo#1@iEW=a z7TR3S%lX7E$)0oNXsEwK;?*jMQ%q=Vv95Tq6gKKZ6)D{9GKoezxE<$T5QWAsj7f9f z=D|p@J~=n6<#imVcNk4hy_yqw&KYyr!!}l9u!0GvMQik064G_hSv^NSa|!i)ACm1f zQ720KrH(1^&>_ZVV=_2Tnn*Ckd*X2_@E!fG?(ZntY_>0Nnp*o%yo&j>zC5@uv3T3S z+MNS-)>T|``IxnNmmJjJZTxd4pJ~>g%6EEUSD>!EX?2jZYUXzy6`>_A68*%&q;<%Sc65YhSqeKMkCv=1l?5{Hp z*6q-tqD?&taF*>3FjeQYi98wnx_MXUH^{5b@|ae*<+>}+^4wW0|94@R|oT{%dGi@9rX!G zjASZ-{NfIevoJ5yy~ZD&0EJL6;?ue9HX+k^+~{>VbvmcZtCyk?ENNxb?>kYyG5#vx z*jn(g9DUL?1rFB*seTWB8tdNVF59;?SDo%TtZ+Npq<=? zC$fxS64E}FW4wjA5v`8X_-p4@dnbpa&dJZkOT!7H-V?RDqAtOJHA&aW3*lYhAR1lo zewS(r++3$koXckVrWS#XOyR{7FBarH#BF`2o|U4dnKcK!z`Km>li_4K{3L4-`KQ9^ zWcVpw_m7hINkJ5aC%sZ6a16gY7Qo!ioO~C47Jm5@7C9tikweYgNPB|cpsxGqxslKm z{cLdLQ;U~fHsA`(`_$OVPxU#T9f^T}WWgP=GGN}KJ*)Ehl~;|9?p|FeufAfW zV?owp&Wx6O7p3&p>}WGS4BQRBh;xTwEOMmIS@qYj0J{bEP}eGM!8K>}3%23N-CAqZ z>xm&aVz6*3pk}oXs#t^1?g<$wlZMvF89=~4R}jW?+VwUAOKpG<0!XP9#?P(R{L z8OPO_VB{f;Mp77#bk%9vdS7%8U;HPM$l#MOX8Qd;1_tTpxkf))mQW)1^A|g#!GO^n5e%Zn;I%sKI5?m5G;dPvUb`z{u$S z^cY2TmTjc+Oa`6WL_#QKIK5>1U~H%oHE3CecE&p6-lUTk0v)j$!)xS&R0it3@w~g3 z@MsasNI2Dgc!1iR5_<5jVs zGc4*wO0XG4v!G?n2F{YGM_u`-N2{_+1vF1|CqIB&;mtBusm;PF|4=tsBmH=+lRyin;(cn4MBq*D#xTy>`mu5M@lh&K6>p)M&HqQ8s%1D5c3XyIqK zmb-t6JNtf#=dS;r9`4S95eA7}*^li+)g#U})-Q0^TK}pNw3E}(YMhN(l<7$^n8%G^ zczYNC&)rfiZlbepO2Y#Q#WIa||K00qX~>?5(I!h@aD176#B&9@7CW-GB}VVBWMoZ> z)vOq1zv0%nnuE~Xq#~#=JGua`7IV~Ev>V}x;>r6f+vbC}6xHK~M}P(P>GTpKdXCBp zt4oHQX}46L`br5}K17`_;$8>&*s)1Tkl8UQA{Y`H6?wp7nDi~vvH4Z9-w@9>gs3o{ zJIjmWv6h-;Sr|VlC&iB}VL@=?xr+@FFB|j-uz?qOq^P=@g)y8(yo7Op^{bzm%W^-4 zYm)_lL(uKw;&I*;L97VJqaq;ca0Na%sPpFQ%yTv<2xG3I+(E=6OBbGB)Ng!vE1DDf z(*MxawU~9Hu+CZ^5%(;{LQu|lTu1Ir`iflYJ?`HIDo{#lvv4MtGD2IhWpsqqxJ-Pb z4obVJN4Y->GL*~pcS`*I61qwrDvnfIO6=V5donxke#N*ZRoA{lDmD7e5(95aAUkxO zQ38#;tCc>}b}x&-fLG^m1~C1qC_M-sIGnq_OIP~Ihp_A((y;BJp$Um68o7OF>J4^l z>@pc}t%cC9l(;%%m2k&)-0x}3X-8Fwox_=4z(6m$X^dWL;gTZJ7>eHf+hwn`vYp2$ zj^D&Jq$FG9K6TSpWTYt+8lsu4~2)pvXwPV^FX**iL1j~GlXQ~AU4fH=>?_KGn9tZEpZj5 zlIo|82#nC=ANElgC7zv>^m7D-dcU`4<8#e zJrqpLRCd%x>38tAZ@F)e%t>6{_}B?35h}u;G>EI*7F4#_X9+6OAx!PGeO5%X)i|qf z>XN2jYPJ<#M^!@B&KNj2z8Z~IH0Lyj-5#bK)-`HuO%5%Jc#SS&*6M9%%0Dw0c;7Db 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 diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 66aaf4e..0000000 --- a/docs/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# `/docs` - -Design and user documents (in addition to your godoc generated documentation). - -Examples: - -* https://github.com/gohugoio/hugo/tree/master/docs -* https://github.com/openshift/origin/tree/master/docs -* https://github.com/dapr/dapr/tree/master/docs diff --git a/docs/REFACTORING_OVERVIEW.md b/docs/REFACTORING_OVERVIEW.md new file mode 100644 index 0000000..faa25c3 --- /dev/null +++ b/docs/REFACTORING_OVERVIEW.md @@ -0,0 +1,319 @@ +# Microservices Refactoring Overview + +This document provides an extended analysis of the four microservices (`server`, `location`, `bridge`, `decoder`), their current structure, and a concrete refactoring plan for better reusability, separation of concerns, and maintainability. + +--- + +## 1. Extended Overview of Each Service + +### 1.1 `cmd/server/main.go` (~211 lines) + +**Role:** HTTP API + Kafka consumers + event loop. Central API for gateways, zones, trackers, parser configs, settings, and tracks; consumes location events and alert beacons; runs a ticker to publish tracker list to MQTT topic. + +**What lives in `main()` today:** + +| Section | Lines (approx) | Responsibility | +|--------|-----------------|----------------| +| Bootstrap | 36–46 | Load config, create AppState, init Kafka manager, create logger, signal context | +| DB + CORS | 48–55 | Connect DB, build CORS options | +| Kafka writers | 56–59 | Populate writers: apibeacons, alert, mqtt, settings, parser | +| Config load & DB seed | 60–89 | Open config file, unmarshal JSON, create configs in DB, find configs, send each to parser topic, call `UpdateDB` | +| Kafka readers | 96–105 | Populate readers: locevents, alertbeacons; create channels; start 2 consumer goroutines | +| Router setup | 107–136 | Mux router + ~25 route registrations (handlers get `db`, `writer`, `ctx` directly) | +| HTTP server | 138–150 | CORS wrapper, handler, `http.Server`, `ListenAndServe` in goroutine | +| **Event loop** | 154–191 | `select`: ctx.Done, chLoc → `LocationToBeaconService`, chEvents → update tracker battery/temp in DB, beaconTicker → marshal trackers, write to mqtt topic | +| Shutdown | 193–210 | Server shutdown, wait group, clean Kafka, cleanup logger | + +**Pain points:** + +- **Heavy main:** Config loading, DB seeding, and “sync parser configs to Kafka” are one-off startup logic mixed with wiring. +- **Event loop in main:** Business logic (location→beacon, decoder event→DB update, ticker→mqtt) lives in `main` instead of a dedicated component. +- **Handlers take 3–4 args:** `db`, `*kafka.Writer`, `context.Context` passed into every handler; no shared “server” or “app” struct. +- **Global `wg`:** Package-level `sync.WaitGroup` instead of a scoped lifecycle object. + +--- + +### 1.2 `cmd/location/main.go` (~246 lines) + +**Role:** Location algorithm service. Consumes raw beacons and settings from Kafka; on a ticker runs either “filter” (score-based) or “ai” (HTTP inference) and writes location events to Kafka. + +**What lives in `main()` and adjacent:** + +| Section | Lines (approx) | Responsibility | +|--------|-----------------|----------------| +| Bootstrap | 26–38 | AppState, config, Kafka manager, logger, signal context | +| Kafka | 39–54 | Readers: rawbeacons, settings; writer: locevents; channels; 2 consumer goroutines | +| **Event loop** | 56–90 | ctx.Done, locTicker (get settings, branch filter vs ai), chRaw → assignBeaconToList, chSettings → UpdateSettings | +| Shutdown | 92–100 | Break, wg.Wait, clean Kafka, cleanup | + +**Logic outside main but still in this package:** + +- `getAI` (102–122): HTTP client, TLS skip verify, get token, infer position (API calls). +- `getLikelyLocations` (124–203): Full “filter” algorithm: iterate beacons, score by RSSI/seen, confidence, write `HTTPLocation` to Kafka. ~80 lines. +- `assignBeaconToList` (205–244): Append metric to beacon in AppState, sliding window. + +**Pain points:** + +- **Algorithm in cmd:** `getLikelyLocations` and `assignBeaconToList` are core domain logic but live under `cmd/location`; not reusable or testable in isolation. +- **getAI in main pkg:** HTTP and TLS setup and API calls are in `main`; should be behind an interface (e.g. “LocationInference”) for testing and reuse. +- **Magic numbers:** `999`, `1.5`, `0.75`, etc. in `getLikelyLocations`; should be config or named constants. +- **Duplicate bootstrap:** Same Kafka/logger/context pattern as server and bridge. + +--- + +### 1.3 `cmd/bridge/main.go` (~212 lines) + +**Role:** MQTT ↔ Kafka bridge. Subscribes to MQTT; converts messages to Kafka (rawbeacons); consumes apibeacons, alert, mqtt from Kafka and publishes to MQTT. + +**What lives in `main()` and adjacent:** + +| Section | Lines (approx) | Responsibility | +|--------|-----------------|----------------| +| Bootstrap | 99–118 | AppState, config, Kafka, logger, context | +| Kafka | 112–127 | Readers: apibeacons, alert, mqtt; writer: rawbeacons; channels; 3 consumer goroutines | +| MQTT client | 129–150 | Options, client ID, handlers, connect, `sub(client)` | +| **Event loop** | 152–188 | ctx.Done, chApi (POST/DELETE → lookup), chAlert → Publish /alerts, chMqtt → Publish /trackers | +| Shutdown | 190–203 | Break, wg.Wait, Kafka cleanup, MQTT disconnect, cleanup | + +**Logic in package:** + +- `mqtthandler` (27–84): Parse JSON array of `RawReading` or CSV; for JSON, map MAC→ID via AppState, build `BeaconAdvertisement`, write to Kafka. CSV branch does nothing useful after parse (dead code). +- `messagePubHandler`, `connectHandler`, `connectLostHandler`: MQTT callbacks. +- `sub(client)`: Subscribe to `publish_out/#`. + +**Pain points:** + +- **MQTT and Kafka logic in cmd:** `mqtthandler` is central to the bridge but lives in `main.go`; hard to unit test and reuse. +- **Handler signature:** `mqtthandler(writer, topic, message, appState)` and package-level `messagePubHandler` close over `writer` and `appState`; no injectable “BridgeHandler” or service. +- **Topic parsing:** `strings.Split(topic, "/")[1]` can panic if topic format changes. +- **Dead CSV branch:** Parses CSV but never produces Kafka messages; either implement or remove. + +--- + +### 1.4 `cmd/decoder/main.go` (~139 lines) + +**Role:** Decode raw beacon payloads using a parser registry; consume rawbeacons and parser config updates; produce alertbeacons. + +**What lives in `main()` and adjacent:** + +| Section | Lines (approx) | Responsibility | +|--------|-----------------|----------------| +| Bootstrap | 25–55 | AppState, config, parser registry, logger, context, Kafka readers/writers, channels, 2 consumers | +| **Event loop** | 57–76 | ctx.Done, chRaw → processIncoming (decodeBeacon), chParser → add/delete/update registry | +| Shutdown | 78–86 | Break, wg.Wait, Kafka cleanup, cleanup | + +**Logic in package:** + +- `processIncoming` (88–95): Wraps `decodeBeacon`, logs errors. +- `decodeBeacon` (97–138): Hex decode, remove flags, parse AD structures, run parser registry, dedupe by event hash, write to alertbeacons. This is core decoder logic. + +**Pain points:** + +- **Decode logic in cmd:** `decodeBeacon` belongs in a `decoder` or `parser` service/package under `internal`, not in `cmd`. +- **Parser registry in main:** Registry is created and updated in main; could be a component that main wires and passes into a “DecoderService” or “EventProcessor”. + +--- + +## 2. Cross-Cutting Observations + +### 2.1 Duplication Across All Four `main.go` + +- **Bootstrap:** Each service does: load service-specific config, create `AppState` (or not, server doesn’t use it for the same purpose), init `KafkaManager`, create logger, `signal.NotifyContext`. +- **Kafka pattern:** `PopulateKafkaManager` for readers/writers, create channels, `wg.Add(N)`, `go Consume(...)`. +- **Shutdown:** Break loop, `wg.Wait()`, `CleanKafkaReaders/Writers`, optional MQTT disconnect, logger cleanup. + +This suggests a small **runtime/bootstrap** package that returns config, logger, Kafka manager, and context (and optionally an “App” struct that owns lifecycle). + +### 2.2 Where Business Logic Lives + +- **Server:** Event loop in main (location→beacon, decoder event→DB, ticker→mqtt); handlers in `internal/pkg/controller` but take raw `*gorm.DB` and `*kafka.Writer`. +- **Location:** Filter algorithm and “assign beacon to list” in `cmd/location`; no `internal` location or algorithm package. +- **Bridge:** MQTT message handling in `cmd/bridge`; no `internal` bridge or mqtt handler package. +- **Decoder:** Decode and registry handling in `cmd/decoder`; parser registry is in `model`, but “process raw → alert” is in main. + +So today, a lot of “service logic” is either in `main` or in `cmd/` instead of in `internal` behind clear interfaces. + +### 2.3 Dependency Direction Today + +- All `cmd/*` import from `internal/pkg/*` (config, logger, kafkaclient, model, service, controller, database, apiclient, appcontext). +- Controllers and services take concrete types (`*gorm.DB`, `*kafka.Writer`). No interfaces for “store” or “message writer,” so testing and swapping implementations require mocks at the concrete type level. +- `model` is a single large namespace (beacons, parser, trackers, gateways, zones, settings, etc.); no split by bounded context (e.g. beacon vs parser vs location). + +--- + +## 3. Proposed Directory and Package Layout + +Goal: keep `cmd//main.go` as a **thin composition layer** that only wires config, infra, and “app” components, and runs the process. All reusable logic and “where things live” should be clear from the directory structure. + +### 3.1 Recommended Tree + +```text +internal/ +├── pkg/ +│ ├── config/ # Keep; optional: split LoadServer/LoadLocation into configs subpackage or env schema +│ ├── logger/ # Keep +│ │ +│ ├── domain/ # NEW: shared domain types and interfaces (no infra) +│ │ ├── beacon.go # Beacon, BeaconEvent, BeaconMetric, BeaconAdvertisement, BeaconsList, etc. +│ │ ├── parser.go # Config, KafkaParser, BeaconParser, ParserRegistry (or keep registry in service) +│ │ ├── location.go # HTTPLocation, location scoring constants +│ │ ├── trackers.go # Tracker, ApiUpdate, Alert, etc. +│ │ └── types.go # RawReading, Settings, and other shared DTOs +│ │ +│ ├── store/ # NEW: in-memory / app state (optional rename of appcontext) +│ │ └── appstate.go # AppState (move from common/appcontext), same API +│ │ +│ ├── messaging/ # NEW: Kafka (and optionally MQTT) behind interfaces +│ │ ├── kafka.go # Manager, Consume, Writer/Reader interfaces, implementation +│ │ └── interfaces.go # MessageWriter, MessageReader for tests +│ │ +│ ├── db/ # Rename from database; single place for GORM +│ │ ├── postgres.go # Connect(cfg) (*gorm.DB, error) +│ │ └── models.go # GORM model structs only (Tracker, Gateway, Zone, etc.) +│ │ +│ ├── client/ # Rename from apiclient; external HTTP (auth, infer, etc.) +│ │ ├── auth.go +│ │ ├── data.go +│ │ └── updatedb.go +│ │ +│ ├── api/ # NEW: HTTP surface for server only +│ │ ├── handler/ # Handlers (move from controller); receive a Server or deps struct +│ │ │ ├── gateways.go +│ │ │ ├── zones.go +│ │ │ ├── trackers.go +│ │ │ ├── trackerzones.go +│ │ │ ├── parser.go +│ │ │ ├── settings.go +│ │ │ ├── tracks.go +│ │ │ └── health.go # /health, /ready +│ │ ├── middleware/ # CORS, logging, recovery, request ID +│ │ └── response/ # JSON success/error helpers +│ │ +│ ├── service/ # Keep; make depend on interfaces +│ │ ├── beacon.go # LocationToBeaconService (depends on DB + Writer interfaces) +│ │ ├── parser.go +│ │ └── location.go # NEW: Filter algorithm, AssignBeaconToList (from cmd/location) +│ │ +│ ├── location/ # NEW: location service internals +│ │ ├── filter.go # getLikelyLocations logic (score, confidence, write) +│ │ ├── assign.go # assignBeaconToList +│ │ └── inference.go # Interface for “get AI position”; adapter over client +│ │ +│ ├── bridge/ # NEW: bridge-specific processing +│ │ ├── mqtt.go # MQTT client options, connect, subscribe (thin wrapper) +│ │ └── handler.go # MQTT message → Kafka (mqtthandler logic) +│ │ +│ └── decoder/ # NEW: decoder-specific processing (or under service/) +│ │ ├── process.go # ProcessIncoming, DecodeBeacon (from cmd/decoder) +│ │ └── registry.go # Optional: wrap ParserRegistry with add/delete/update +│ │ +├── app/ # NEW (optional): per-service composition / “application” layer +│ ├── server/ +│ │ ├── app.go # ServerApp: config, db, kafka, router, event loop, shutdown +│ │ ├── routes.go # Register all routes with deps +│ │ └── events.go # RunEventLoop(ctx): location, alertbeacons, ticker +│ ├── location/ +│ │ ├── app.go # LocationApp: config, kafka, store, filter/inference, run loop +│ │ └── loop.go # Run(ctx): ticker + channels +│ ├── bridge/ +│ │ ├── app.go # BridgeApp: config, kafka, mqtt, store, run loop +│ │ └── loop.go +│ └── decoder/ +│ ├── app.go # DecoderApp: config, kafka, registry, run loop +│ └── loop.go +``` + +You can adopt this incrementally: e.g. first add `internal/app/server` and move event loop + route registration there, then do the same for location/bridge/decoder. + +### 3.2 What Each `cmd//main.go` Becomes + +- **cmd/server/main.go:** + Load config (or exit on error), create logger, call `serverapp.New(cfg, logger)` (or bootstrap), then `app.Run(ctx)` and `app.Shutdown()`. No DB seed or parser sync in main—move those into `ServerApp` constructor or a `ServerApp.Init(ctx)`. + +- **cmd/location/main.go:** + Load config, create logger, create AppState, call `locationapp.New(cfg, logger, appState)` (and optionally Kafka manager from bootstrap), then `app.Run(ctx)` and `app.Shutdown()`. + +- **cmd/bridge/main.go:** + Same idea: bootstrap, then `bridgeapp.New(...)`, `Run(ctx)`, `Shutdown()`. + +- **cmd/decoder/main.go:** + Bootstrap, then `decoderapp.New(...)` with parser registry, `Run(ctx)`, `Shutdown()`. + +So each `main.go` is on the order of 20–40 lines: config + logger + optional bootstrap, build app, run, shutdown. + +--- + +## 4. Refactoring Steps (Concrete) + +### Phase 1: Extract bootstrap and shrink main (high impact, low risk) + +1. **Add `internal/pkg/bootstrap` (or `runtime`):** + - `Bootstrap(ctx) (cfg *config.Config, log *slog.Logger, kafka *kafkaclient.KafkaManager, cleanup func())` for a given service type (or one function per service that returns what that service needs). + - Use it from all four `main.go` so that “create logger + kafka + context” is one place. +2. **Move shutdown sequence into a single place:** e.g. `Shutdown(ctx, kafkaManager, cleanup)` so each main just calls it after breaking the loop. + +After this, each `main` is: load config → bootstrap → build Kafka/channels (or get from app) → create “App” (see Phase 2) → run loop → shutdown. + +### Phase 2: Move event loops and “server wiring” into `internal/app` + +3. **Server** + - Add `internal/app/server`: `ServerApp` struct holding cfg, db, kafkaManager, channels, router, server, wg. + - Move config load + DB connect + parser sync + Kafka reader setup into `NewServerApp(cfg, logger)` or `NewServerApp(...).Init(ctx)`. + - Move route registration into `RegisterRoutes(app *ServerApp)` (or `app.Routes()` that returns `http.Handler`). + - Move the event loop (select over chLoc, chEvents, ticker) into `ServerApp.RunEventLoop(ctx)`. + - `main` becomes: config → bootstrap → NewServerApp → Init → go ListenAndServe → RunEventLoop(ctx) → Shutdown. +4. **Location** + - Add `internal/app/location`: `LocationApp` with kafkaManager, appState, channels, filter algo, inference client. + - Move `getLikelyLocations` and `assignBeaconToList` into `internal/pkg/service/location.go` or `internal/pkg/location/filter.go` and `assign.go`. + - Move `getAI` behind an interface `LocationInferencer` in `internal/pkg/location`; implement with `client` (auth + infer). + - Event loop in `LocationApp.Run(ctx)`. +5. **Bridge** + - Add `internal/app/bridge`: `BridgeApp` with kafkaManager, mqtt client, appState, channels. + - Move `mqtthandler` and MQTT subscribe into `internal/pkg/bridge/handler.go` and `mqtt.go`; call from app. + - Event loop in `BridgeApp.Run(ctx)`. +6. **Decoder** + - Add `internal/app/decoder`: `DecoderApp` with kafkaManager, parser registry, channels. + - Move `processIncoming` and `decodeBeacon` into `internal/pkg/decoder/process.go`. + - Event loop in `DecoderApp.Run(ctx)`. + +This removes “too much inside main” and gives a single place per service to add features (the `app` and the packages it uses). + +### Phase 3: Interfaces and dependency injection + +7. **Messaging** + - In `internal/pkg/messaging` (or keep `kafkaclient` and add interfaces there), define e.g. `MessageWriter` and `MessageReader` interfaces. + - Have `KafkaManager` (or a thin wrapper) implement them so handlers and services accept interfaces; tests can inject fakes. +8. **Store** + - Rename or keep `appcontext`; if you introduce `store`, have `AppState` implement e.g. `BeaconStore` / `SettingsStore` so location and bridge depend on interfaces. +9. **Server handlers** + - Instead of passing `db`, `writer`, `ctx` to each handler, introduce a `Server` or `HandlerEnv` struct that holds DB, writers, and optionally logger; handlers become methods or receive this struct. Then you can add health checks and middleware in one place. + +### Phase 4: Domain and API clarity + +10. **Domain** + - Create `internal/pkg/domain` and move shared types from `model` into domain subpackages (beacon, parser, location, trackers, etc.). Keep `model` as an alias or migrate imports gradually so that “core types” live under domain and “GORM models” stay under `db` if you split them. +11. **API** + - Move HTTP handlers from `controller` to `internal/pkg/api/handler`; add `api/response` for JSON and errors; add `api/middleware` for CORS, logging, recovery. Register routes in `app/server` using these handlers. + +--- + +## 5. Summary Table + +| Service | Current main responsibilities | After refactor: main does | New home for logic | +|----------|-----------------------------------|----------------------------------------|---------------------------------------------| +| server | Bootstrap, DB seed, routes, loop, shutdown | Config, bootstrap, NewServerApp, Run, Shutdown | `app/server` (event loop, routes), `api/handler`, `service` | +| location | Bootstrap, Kafka, loop, shutdown | Config, bootstrap, NewLocationApp, Run, Shutdown | `app/location`, `service/location` or `pkg/location` (filter, assign, inference) | +| bridge | Bootstrap, Kafka, MQTT, loop, shutdown | Config, bootstrap, NewBridgeApp, Run, Shutdown | `app/bridge`, `pkg/bridge` (mqtt, handler) | +| decoder | Bootstrap, Kafka, loop, shutdown | Config, bootstrap, NewDecoderApp, Run, Shutdown | `app/decoder`, `pkg/decoder` (process, decode) | + +--- + +## 6. Benefits After Refactoring + +- **Reusability:** Location algorithm, bridge MQTT handling, and decoder logic live in `internal/pkg` and can be tested and reused without running a full `main`. +- **Separation:** `cmd` only composes and runs; `internal/app` owns per-service lifecycle and event loops; `internal/pkg` holds domain, store, messaging, API, and services. +- **Maintainability:** Adding a new route or a new Kafka consumer is “add to ServerApp and register”; adding a new algorithm is “add to location package and call from LocationApp.” +- **Testability:** Event loops and handlers can be unit-tested with fake writers/stores; integration tests can build `*ServerApp` or `*DecoderApp` with test doubles. +- **Consistency:** One bootstrap and one shutdown pattern across all four services; same style of “App” struct and `Run(ctx)`. + +You can implement Phase 1 and Phase 2 first (bootstrap + app with event loops and moved logic), then Phase 3 (interfaces) and Phase 4 (domain + API) as follow-ups. diff --git a/go.mod b/go.mod index 6e7b47f..ce4013e 100644 --- a/go.mod +++ b/go.mod @@ -25,10 +25,12 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/klauspost/compress v1.15.9 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/pierrec/lz4/v4 v4.1.15 // indirect github.com/stretchr/testify v1.11.1 // indirect golang.org/x/crypto v0.42.0 // indirect golang.org/x/net v0.44.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/text v0.29.0 // indirect + gorm.io/driver/sqlite v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 6d8f87e..030ac1d 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY= github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0= @@ -60,5 +62,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/README.md b/internal/README.md deleted file mode 100644 index 5a9f68a..0000000 --- a/internal/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# `/internal` - -Private application and library code. This is the code you don't want others importing in their applications or libraries. Note that this layout pattern is enforced by the Go compiler itself. See the Go 1.4 [`release notes`](https://golang.org/doc/go1.4#internalpackages) for more details. Note that you are not limited to the top level `internal` directory. You can have more than one `internal` directory at any level of your project tree. - -You can optionally add a bit of extra structure to your internal packages to separate your shared and non-shared internal code. It's not required (especially for smaller projects), but it's nice to have visual clues showing the intended package use. Your actual application code can go in the `/internal/app` directory (e.g., `/internal/app/myapp`) and the code shared by those apps in the `/internal/pkg` directory (e.g., `/internal/pkg/myprivlib`). - -Examples: - -* https://github.com/hashicorp/terraform/tree/main/internal -* https://github.com/influxdata/influxdb/tree/master/internal -* https://github.com/perkeep/perkeep/tree/master/internal -* https://github.com/jaegertracing/jaeger/tree/main/internal -* https://github.com/moby/moby/tree/master/internal -* https://github.com/satellity/satellity/tree/main/internal -* https://github.com/minio/minio/tree/master/internal - -## `/internal/pkg` - -Examples: - -* https://github.com/hashicorp/waypoint/tree/main/internal/pkg diff --git a/internal/app/_your_app_/.keep b/internal/app/_your_app_/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/app/bridge/app.go b/internal/app/bridge/app.go new file mode 100644 index 0000000..13f3e33 --- /dev/null +++ b/internal/app/bridge/app.go @@ -0,0 +1,122 @@ +package bridge + +import ( + "context" + "encoding/json" + "log/slog" + "sync" + + "github.com/AFASystems/presence/internal/pkg/bridge" + "github.com/AFASystems/presence/internal/pkg/common/appcontext" + "github.com/AFASystems/presence/internal/pkg/config" + "github.com/AFASystems/presence/internal/pkg/kafkaclient" + "github.com/AFASystems/presence/internal/pkg/logger" + "github.com/AFASystems/presence/internal/pkg/model" + mqtt "github.com/eclipse/paho.mqtt.golang" +) + +// BridgeApp holds dependencies for the bridge service (MQTT <-> Kafka). +type BridgeApp struct { + Cfg *config.Config + KafkaManager *kafkaclient.KafkaManager + AppState *appcontext.AppState + MQTT *bridge.MQTTClient + ChApi chan model.ApiUpdate + ChAlert chan model.Alert + ChMqtt chan []model.Tracker + Cleanup func() + wg sync.WaitGroup +} + +// New creates a BridgeApp with Kafka readers (apibeacons, alert, mqtt), writer (rawbeacons), and MQTT client. +func New(cfg *config.Config) (*BridgeApp, error) { + appState := appcontext.NewAppState() + kafkaManager := kafkaclient.InitKafkaManager() + + srvLogger, cleanup := logger.CreateLogger("bridge.log") + slog.SetDefault(srvLogger) + + readerTopics := []string{"apibeacons", "alert", "mqtt"} + writerTopics := []string{"rawbeacons"} + kafkaManager.PopulateKafkaManager(cfg.KafkaURL, "bridge", readerTopics) + kafkaManager.PopulateKafkaManager(cfg.KafkaURL, "", writerTopics) + slog.Info("bridge service initialized", "readers", readerTopics, "writers", writerTopics) + + writer := kafkaManager.GetWriter("rawbeacons") + mqttClient, err := bridge.NewMQTTClient(cfg, func(m mqtt.Message) { + bridge.HandleMQTTMessage(m.Topic(), m.Payload(), appState, writer) + }) + if err != nil { + cleanup() + return nil, err + } + mqttClient.Subscribe() + + return &BridgeApp{ + Cfg: cfg, + KafkaManager: kafkaManager, + AppState: appState, + MQTT: mqttClient, + ChApi: make(chan model.ApiUpdate, config.SMALL_CHANNEL_SIZE), + ChAlert: make(chan model.Alert, config.SMALL_CHANNEL_SIZE), + ChMqtt: make(chan []model.Tracker, config.SMALL_CHANNEL_SIZE), + Cleanup: cleanup, + }, nil +} + +// Run starts Kafka consumers and the event loop until ctx is cancelled. +func (a *BridgeApp) Run(ctx context.Context) { + a.wg.Add(3) + go kafkaclient.Consume(a.KafkaManager.GetReader("apibeacons"), a.ChApi, ctx, &a.wg) + go kafkaclient.Consume(a.KafkaManager.GetReader("alert"), a.ChAlert, ctx, &a.wg) + go kafkaclient.Consume(a.KafkaManager.GetReader("mqtt"), a.ChMqtt, ctx, &a.wg) + + for { + select { + case <-ctx.Done(): + return + case msg := <-a.ChApi: + switch msg.Method { + case "POST": + a.AppState.AddBeaconToLookup(msg.MAC, msg.ID) + slog.Info("beacon added to lookup", "id", msg.ID) + case "DELETE": + if msg.MAC == "all" { + a.AppState.CleanLookup() + slog.Info("lookup cleared") + continue + } + a.AppState.RemoveBeaconFromLookup(msg.MAC) + slog.Info("beacon removed from lookup", "mac", msg.MAC) + } + case msg := <-a.ChAlert: + p, err := json.Marshal(msg) + if err != nil { + slog.Error("marshaling alert", "err", err) + continue + } + a.MQTT.Client.Publish("/alerts", 0, true, p) + case msg := <-a.ChMqtt: + p, err := json.Marshal(msg) + if err != nil { + slog.Error("marshaling trackers", "err", err) + continue + } + a.MQTT.Client.Publish("/trackers", 0, true, p) + } + } +} + +// Shutdown disconnects MQTT, waits for consumers, and cleans up. +func (a *BridgeApp) Shutdown() { + a.wg.Wait() + if a.MQTT != nil { + a.MQTT.Disconnect() + } + a.KafkaManager.CleanKafkaReaders() + a.KafkaManager.CleanKafkaWriters() + if a.Cleanup != nil { + a.Cleanup() + } + slog.Info("bridge service shutdown complete") +} diff --git a/internal/app/decoder/app.go b/internal/app/decoder/app.go new file mode 100644 index 0000000..080899e --- /dev/null +++ b/internal/app/decoder/app.go @@ -0,0 +1,91 @@ +package decoder + +import ( + "context" + "log/slog" + "sync" + + "github.com/AFASystems/presence/internal/pkg/common/appcontext" + "github.com/AFASystems/presence/internal/pkg/config" + "github.com/AFASystems/presence/internal/pkg/decoder" + "github.com/AFASystems/presence/internal/pkg/kafkaclient" + "github.com/AFASystems/presence/internal/pkg/logger" + "github.com/AFASystems/presence/internal/pkg/model" +) + +// DecoderApp holds dependencies for the decoder service. +type DecoderApp struct { + Cfg *config.Config + KafkaManager *kafkaclient.KafkaManager + AppState *appcontext.AppState + ParserRegistry *model.ParserRegistry + ChRaw chan model.BeaconAdvertisement + ChParser chan model.KafkaParser + Cleanup func() + wg sync.WaitGroup +} + +// New creates a DecoderApp with Kafka readers (rawbeacons, parser) and writer (alertbeacons). +func New(cfg *config.Config) (*DecoderApp, error) { + appState := appcontext.NewAppState() + kafkaManager := kafkaclient.InitKafkaManager() + + srvLogger, cleanup := logger.CreateLogger("decoder.log") + slog.SetDefault(srvLogger) + + readerTopics := []string{"rawbeacons", "parser"} + writerTopics := []string{"alertbeacons"} + kafkaManager.PopulateKafkaManager(cfg.KafkaURL, "decoder", readerTopics) + kafkaManager.PopulateKafkaManager(cfg.KafkaURL, "", writerTopics) + slog.Info("decoder service initialized", "readers", readerTopics, "writers", writerTopics) + + registry := &model.ParserRegistry{ + ParserList: make(map[string]model.BeaconParser), + } + + return &DecoderApp{ + Cfg: cfg, + KafkaManager: kafkaManager, + AppState: appState, + ParserRegistry: registry, + ChRaw: make(chan model.BeaconAdvertisement, config.LARGE_CHANNEL_SIZE), + ChParser: make(chan model.KafkaParser, config.SMALL_CHANNEL_SIZE), + Cleanup: cleanup, + }, nil +} + +// Run starts Kafka consumers and the event loop until ctx is cancelled. +func (a *DecoderApp) Run(ctx context.Context) { + a.wg.Add(2) + go kafkaclient.Consume(a.KafkaManager.GetReader("rawbeacons"), a.ChRaw, ctx, &a.wg) + go kafkaclient.Consume(a.KafkaManager.GetReader("parser"), a.ChParser, ctx, &a.wg) + + for { + select { + case <-ctx.Done(): + return + case msg := <-a.ChRaw: + decoder.ProcessIncoming(msg, a.AppState, a.KafkaManager.GetWriter("alertbeacons"), a.ParserRegistry) + case msg := <-a.ChParser: + switch msg.ID { + case "add": + a.ParserRegistry.Register(msg.Config.Name, msg.Config) + case "delete": + a.ParserRegistry.Unregister(msg.Name) + case "update": + a.ParserRegistry.Register(msg.Config.Name, msg.Config) + } + } + } +} + +// Shutdown waits for consumers and cleans up. +func (a *DecoderApp) Shutdown() { + a.wg.Wait() + a.KafkaManager.CleanKafkaReaders() + a.KafkaManager.CleanKafkaWriters() + if a.Cleanup != nil { + a.Cleanup() + } + slog.Info("decoder service shutdown complete") +} diff --git a/internal/app/location/app.go b/internal/app/location/app.go new file mode 100644 index 0000000..88ac84c --- /dev/null +++ b/internal/app/location/app.go @@ -0,0 +1,100 @@ +package location + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/AFASystems/presence/internal/pkg/common/appcontext" + "github.com/AFASystems/presence/internal/pkg/config" + "github.com/AFASystems/presence/internal/pkg/kafkaclient" + "github.com/AFASystems/presence/internal/pkg/logger" + pkglocation "github.com/AFASystems/presence/internal/pkg/location" + "github.com/AFASystems/presence/internal/pkg/model" +) + +// LocationApp holds dependencies for the location service. +type LocationApp struct { + Cfg *config.Config + KafkaManager *kafkaclient.KafkaManager + AppState *appcontext.AppState + Inferencer pkglocation.Inferencer + ChRaw chan model.BeaconAdvertisement + ChSettings chan map[string]any + Cleanup func() + wg sync.WaitGroup +} + +// New creates a LocationApp with Kafka readers (rawbeacons, settings) and writer (locevents). +func New(cfg *config.Config) (*LocationApp, error) { + appState := appcontext.NewAppState() + kafkaManager := kafkaclient.InitKafkaManager() + + srvLogger, cleanup := logger.CreateLogger("location.log") + slog.SetDefault(srvLogger) + + readerTopics := []string{"rawbeacons", "settings"} + writerTopics := []string{"locevents"} + kafkaManager.PopulateKafkaManager(cfg.KafkaURL, "location", readerTopics) + kafkaManager.PopulateKafkaManager(cfg.KafkaURL, "", writerTopics) + slog.Info("location service initialized", "readers", readerTopics, "writers", writerTopics) + + return &LocationApp{ + Cfg: cfg, + KafkaManager: kafkaManager, + AppState: appState, + Inferencer: pkglocation.NewDefaultInferencer(cfg.TLSInsecureSkipVerify), + ChRaw: make(chan model.BeaconAdvertisement, config.LARGE_CHANNEL_SIZE), + ChSettings: make(chan map[string]any, config.SMALL_CHANNEL_SIZE), + Cleanup: cleanup, + }, nil +} + +// Run starts consumers and the event loop until ctx is cancelled. +func (a *LocationApp) Run(ctx context.Context) { + a.wg.Add(2) + go kafkaclient.Consume(a.KafkaManager.GetReader("rawbeacons"), a.ChRaw, ctx, &a.wg) + go kafkaclient.Consume(a.KafkaManager.GetReader("settings"), a.ChSettings, ctx, &a.wg) + + locTicker := time.NewTicker(config.SMALL_TICKER_INTERVAL) + defer locTicker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-locTicker.C: + settings := a.AppState.GetSettings() + slog.Info("location tick", "settings", fmt.Sprintf("%+v", settings)) + switch settings.CurrentAlgorithm { + case "filter": + pkglocation.GetLikelyLocations(a.AppState, a.KafkaManager.GetWriter("locevents")) + case "ai": + inferred, err := a.Inferencer.Infer(ctx, a.Cfg) + if err != nil { + slog.Error("AI inference", "err", err) + continue + } + slog.Info("AI algorithm", "count", inferred.Count, "items", len(inferred.Items)) + } + case msg := <-a.ChRaw: + pkglocation.AssignBeaconToList(msg, a.AppState) + case msg := <-a.ChSettings: + slog.Info("settings update", "msg", msg) + a.AppState.UpdateSettings(msg) + } + } +} + +// Shutdown waits for consumers and cleans up Kafka and logger. +func (a *LocationApp) Shutdown() { + a.wg.Wait() + a.KafkaManager.CleanKafkaReaders() + a.KafkaManager.CleanKafkaWriters() + if a.Cleanup != nil { + a.Cleanup() + } + slog.Info("location service shutdown complete") +} diff --git a/internal/app/server/app.go b/internal/app/server/app.go new file mode 100644 index 0000000..433fe5b --- /dev/null +++ b/internal/app/server/app.go @@ -0,0 +1,145 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "sync" + + "github.com/AFASystems/presence/internal/pkg/apiclient" + "github.com/AFASystems/presence/internal/pkg/common/appcontext" + "github.com/AFASystems/presence/internal/pkg/config" + "github.com/AFASystems/presence/internal/pkg/database" + "github.com/AFASystems/presence/internal/pkg/kafkaclient" + "github.com/AFASystems/presence/internal/pkg/logger" + "github.com/AFASystems/presence/internal/pkg/model" + "github.com/AFASystems/presence/internal/pkg/service" + "gorm.io/gorm" +) + +// ServerApp holds dependencies and state for the server service. +type ServerApp struct { + Cfg *config.Config + DB *gorm.DB + KafkaManager *kafkaclient.KafkaManager + AppState *appcontext.AppState + ChLoc chan model.HTTPLocation + ChEvents chan model.BeaconEvent + ctx context.Context + Server *http.Server + Cleanup func() + wg sync.WaitGroup +} + +// New creates a ServerApp: loads config, creates logger, connects DB, creates Kafka manager and writers. +// Caller must call Init(ctx) then Run(ctx) then Shutdown(). +func New(cfg *config.Config) (*ServerApp, error) { + srvLogger, cleanup := logger.CreateLogger("server.log") + slog.SetDefault(srvLogger) + + db, err := database.Connect(cfg) + if err != nil { + cleanup() + return nil, fmt.Errorf("database: %w", err) + } + + appState := appcontext.NewAppState() + kafkaManager := kafkaclient.InitKafkaManager() + + writerTopics := []string{"apibeacons", "alert", "mqtt", "settings", "parser"} + kafkaManager.PopulateKafkaManager(cfg.KafkaURL, "", writerTopics) + slog.Info("Kafka writers initialized", "topics", writerTopics) + + return &ServerApp{ + Cfg: cfg, + DB: db, + KafkaManager: kafkaManager, + AppState: appState, + Cleanup: cleanup, + }, nil +} + +// Init loads config from file, seeds DB, runs UpdateDB, adds Kafka readers and starts consumers. +func (a *ServerApp) Init(ctx context.Context) error { + a.ctx = ctx + + configFile, err := os.Open(a.Cfg.ConfigPath) + if err != nil { + return fmt.Errorf("config file: %w", err) + } + defer configFile.Close() + + b, err := io.ReadAll(configFile) + if err != nil { + return fmt.Errorf("read config: %w", err) + } + + var configs []model.Config + if err := json.Unmarshal(b, &configs); err != nil { + return fmt.Errorf("unmarshal config: %w", err) + } + + for _, c := range configs { + a.DB.Create(&c) + } + a.DB.Find(&configs) + for _, c := range configs { + kp := model.KafkaParser{ID: "add", Config: c} + if err := service.SendParserConfig(kp, a.KafkaManager.GetWriter("parser"), ctx); err != nil { + slog.Error("sending parser config to kafka", "err", err, "name", c.Name) + } + } + + if err := apiclient.UpdateDB(a.DB, ctx, a.Cfg, a.KafkaManager.GetWriter("apibeacons"), a.AppState); err != nil { + slog.Error("UpdateDB", "err", err) + } + + readerTopics := []string{"locevents", "alertbeacons"} + a.KafkaManager.PopulateKafkaManager(a.Cfg.KafkaURL, "server", readerTopics) + slog.Info("Kafka readers initialized", "topics", readerTopics) + + a.ChLoc = make(chan model.HTTPLocation, config.SMALL_CHANNEL_SIZE) + a.ChEvents = make(chan model.BeaconEvent, config.MEDIUM_CHANNEL_SIZE) + + a.wg.Add(2) + go kafkaclient.Consume(a.KafkaManager.GetReader("locevents"), a.ChLoc, ctx, &a.wg) + go kafkaclient.Consume(a.KafkaManager.GetReader("alertbeacons"), a.ChEvents, ctx, &a.wg) + + a.Server = &http.Server{ + Addr: a.Cfg.HTTPAddr, + Handler: a.RegisterRoutes(), + } + return nil +} + +// Run starts the HTTP server and runs the event loop until ctx is cancelled. +func (a *ServerApp) Run(ctx context.Context) { + go func() { + if err := a.Server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("HTTP server", "err", err) + } + }() + RunEventLoop(ctx, a) +} + +// Shutdown stops the HTTP server, waits for consumers, and cleans up Kafka and logger. +func (a *ServerApp) Shutdown() { + if a.Server != nil { + if err := a.Server.Shutdown(context.Background()); err != nil { + slog.Error("server shutdown", "err", err) + } + slog.Info("HTTP server stopped") + } + a.wg.Wait() + slog.Info("Kafka consumers stopped") + a.KafkaManager.CleanKafkaReaders() + a.KafkaManager.CleanKafkaWriters() + if a.Cleanup != nil { + a.Cleanup() + } + slog.Info("server shutdown complete") +} diff --git a/internal/app/server/events.go b/internal/app/server/events.go new file mode 100644 index 0000000..f5cdeaf --- /dev/null +++ b/internal/app/server/events.go @@ -0,0 +1,51 @@ +package server + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/AFASystems/presence/internal/pkg/config" + "github.com/AFASystems/presence/internal/pkg/model" + "github.com/AFASystems/presence/internal/pkg/service" + "github.com/segmentio/kafka-go" +) + +// RunEventLoop runs the server event loop until ctx is cancelled. +// Handles: location events -> LocationToBeaconService, alert events -> update tracker in DB, ticker -> publish trackers to mqtt. +func RunEventLoop(ctx context.Context, a *ServerApp) { + beaconTicker := time.NewTicker(config.MEDIUM_TICKER_INTERVAL) + defer beaconTicker.Stop() + + for { + select { + case <-ctx.Done(): + return + case msg := <-a.ChLoc: + service.LocationToBeaconService(msg, a.DB, a.KafkaManager.GetWriter("alert"), ctx) + case msg := <-a.ChEvents: + slog.Info("decoder event", "event", msg) + id := msg.ID + if err := a.DB.First(&model.Tracker{}, "id = ?", id).Error; err != nil { + slog.Error("decoder event for untracked beacon", "id", id) + continue + } + if err := a.DB.Updates(&model.Tracker{ID: id, Battery: msg.Battery, Temperature: msg.Temperature}).Error; err != nil { + slog.Error("saving decoder event for beacon", "id", id, "err", err) + continue + } + case <-beaconTicker.C: + var list []model.Tracker + a.DB.Find(&list) + eMsg, err := json.Marshal(list) + if err != nil { + slog.Error("marshaling trackers list", "err", err) + continue + } + if err := a.KafkaManager.GetWriter("mqtt").WriteMessages(ctx, kafka.Message{Value: eMsg}); err != nil { + slog.Error("writing trackers to mqtt topic", "err", err) + } + } + } +} diff --git a/internal/app/server/routes.go b/internal/app/server/routes.go new file mode 100644 index 0000000..eeef9bb --- /dev/null +++ b/internal/app/server/routes.go @@ -0,0 +1,59 @@ +package server + +import ( + "net/http" + + "github.com/AFASystems/presence/internal/pkg/api/handler" + "github.com/AFASystems/presence/internal/pkg/api/middleware" + "github.com/AFASystems/presence/internal/pkg/controller" + "github.com/gorilla/mux" +) + +// RegisterRoutes builds the router and applies middleware. Uses app's DB, Kafka writers, and ctx. +func (a *ServerApp) RegisterRoutes() http.Handler { + r := mux.NewRouter() + + // Health + r.HandleFunc("/health", handler.Health).Methods("GET") + r.HandleFunc("/ready", handler.Ready(a.DB)).Methods("GET") + + // Gateways + r.HandleFunc("/reslevis/getGateways", controller.GatewayListController(a.DB)).Methods("GET") + r.HandleFunc("/reslevis/postGateway", controller.GatewayAddController(a.DB)).Methods("POST") + r.HandleFunc("/reslevis/removeGateway/{id}", controller.GatewayDeleteController(a.DB)).Methods("DELETE") + r.HandleFunc("/reslevis/updateGateway/{id}", controller.GatewayUpdateController(a.DB)).Methods("PUT") + + // Zones + r.HandleFunc("/reslevis/getZones", controller.ZoneListController(a.DB)).Methods("GET") + r.HandleFunc("/reslevis/postZone", controller.ZoneAddController(a.DB)).Methods("POST") + r.HandleFunc("/reslevis/removeZone/{id}", controller.ZoneDeleteController(a.DB)).Methods("DELETE") + r.HandleFunc("/reslevis/updateZone", controller.ZoneUpdateController(a.DB)).Methods("PUT") + + // Tracker zones + r.HandleFunc("/reslevis/getTrackerZones", controller.TrackerZoneListController(a.DB)).Methods("GET") + r.HandleFunc("/reslevis/postTrackerZone", controller.TrackerZoneAddController(a.DB)).Methods("POST") + r.HandleFunc("/reslevis/removeTrackerZone/{id}", controller.TrackerZoneDeleteController(a.DB)).Methods("DELETE") + r.HandleFunc("/reslevis/updateTrackerZone", controller.TrackerZoneUpdateController(a.DB)).Methods("PUT") + + // Trackers + r.HandleFunc("/reslevis/getTrackers", controller.TrackerList(a.DB)).Methods("GET") + r.HandleFunc("/reslevis/postTracker", controller.TrackerAdd(a.DB, a.KafkaManager.GetWriter("apibeacons"), a.ctx)).Methods("POST") + r.HandleFunc("/reslevis/removeTracker/{id}", controller.TrackerDelete(a.DB, a.KafkaManager.GetWriter("apibeacons"), a.ctx)).Methods("DELETE") + r.HandleFunc("/reslevis/updateTracker", controller.TrackerUpdate(a.DB)).Methods("PUT") + + // Parser configs + r.HandleFunc("/configs/beacons", controller.ParserListController(a.DB)).Methods("GET") + r.HandleFunc("/configs/beacons", controller.ParserAddController(a.DB, a.KafkaManager.GetWriter("parser"), a.ctx)).Methods("POST") + r.HandleFunc("/configs/beacons/{id}", controller.ParserUpdateController(a.DB, a.KafkaManager.GetWriter("parser"), a.ctx)).Methods("PUT") + r.HandleFunc("/configs/beacons/{id}", controller.ParserDeleteController(a.DB, a.KafkaManager.GetWriter("parser"), a.ctx)).Methods("DELETE") + + // Settings + r.HandleFunc("/reslevis/settings", controller.SettingsUpdateController(a.DB, a.KafkaManager.GetWriter("settings"), a.ctx)).Methods("PATCH") + r.HandleFunc("/reslevis/settings", controller.SettingsListController(a.DB)).Methods("GET") + + // Tracks + r.HandleFunc("/reslevis/getTracks/{id}", controller.TracksListController(a.DB)).Methods("GET") + + chain := middleware.Recovery(middleware.Logging(middleware.RequestID(middleware.CORS(nil, nil, nil)(r)))) + return chain +} diff --git a/internal/pkg/api/handler/health.go b/internal/pkg/api/handler/health.go new file mode 100644 index 0000000..35991f3 --- /dev/null +++ b/internal/pkg/api/handler/health.go @@ -0,0 +1,29 @@ +package handler + +import ( + "net/http" + + "github.com/AFASystems/presence/internal/pkg/api/response" + "gorm.io/gorm" +) + +// Health returns OK and status "ok". Useful for liveness. +func Health(w http.ResponseWriter, r *http.Request) { + response.JSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// Ready checks DB connectivity and returns 200 if ready, 503 otherwise. +func Ready(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sqlDB, err := db.DB() + if err != nil { + response.Error(w, http.StatusServiceUnavailable, "not_ready", "database not available") + return + } + if err := sqlDB.Ping(); err != nil { + response.Error(w, http.StatusServiceUnavailable, "not_ready", "database ping failed") + return + } + response.JSON(w, http.StatusOK, map[string]string{"status": "ready"}) + } +} diff --git a/internal/pkg/api/middleware/cors.go b/internal/pkg/api/middleware/cors.go new file mode 100644 index 0000000..c80e8b8 --- /dev/null +++ b/internal/pkg/api/middleware/cors.go @@ -0,0 +1,26 @@ +package middleware + +import ( + "net/http" + + "github.com/gorilla/handlers" +) + +// CORS returns a handler that applies CORS with the given origins, headers, and methods. +// If origins is nil or empty, AllowAll() is not applied (caller can use handlers.CORS manually). +func CORS(origins, headers, methods []string) func(http.Handler) http.Handler { + if len(origins) == 0 { + origins = []string{"*"} + } + if len(headers) == 0 { + headers = []string{"X-Requested-With", "Content-Type", "Authorization", RequestIDHeader} + } + if len(methods) == 0 { + methods = []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"} + } + return handlers.CORS( + handlers.AllowedOrigins(origins), + handlers.AllowedHeaders(headers), + handlers.AllowedMethods(methods), + ) +} diff --git a/internal/pkg/api/middleware/logging.go b/internal/pkg/api/middleware/logging.go new file mode 100644 index 0000000..a050614 --- /dev/null +++ b/internal/pkg/api/middleware/logging.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "log/slog" + "net/http" + "time" +) + +// responseWriter wraps http.ResponseWriter to capture status and bytes written. +type responseWriter struct { + http.ResponseWriter + status int + bytes int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.status = code + rw.ResponseWriter.WriteHeader(code) +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + n, err := rw.ResponseWriter.Write(b) + rw.bytes += n + return n, err +} + +// Logging logs each request with method, path, status, duration, and bytes written. +func Logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + wrap := &responseWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(wrap, r) + slog.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", wrap.status, + "duration_ms", time.Since(start).Milliseconds(), + "bytes", wrap.bytes, + ) + }) +} diff --git a/internal/pkg/api/middleware/recovery.go b/internal/pkg/api/middleware/recovery.go new file mode 100644 index 0000000..912e48d --- /dev/null +++ b/internal/pkg/api/middleware/recovery.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "log/slog" + "net/http" + "runtime/debug" +) + +// Recovery recovers from panics, logs the stack, and returns 500. +func Recovery(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + slog.Error("panic recovered", "err", err, "stack", string(debug.Stack())) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"internal_error","message":"internal server error"}`)) + } + }() + next.ServeHTTP(w, r) + }) +} diff --git a/internal/pkg/api/middleware/requestid.go b/internal/pkg/api/middleware/requestid.go new file mode 100644 index 0000000..aea3e47 --- /dev/null +++ b/internal/pkg/api/middleware/requestid.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "net/http" + + "github.com/google/uuid" +) + +const RequestIDHeader = "X-Request-ID" + +// RequestID adds a unique X-Request-ID to each request and to the request context. +// The same ID is set on the response header for tracing. +func RequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get(RequestIDHeader) + if id == "" { + id = uuid.New().String() + } + w.Header().Set(RequestIDHeader, id) + next.ServeHTTP(w, r) + }) +} diff --git a/internal/pkg/api/response/response.go b/internal/pkg/api/response/response.go new file mode 100644 index 0000000..a2c574c --- /dev/null +++ b/internal/pkg/api/response/response.go @@ -0,0 +1,55 @@ +package response + +import ( + "encoding/json" + "log/slog" + "net/http" +) + +// ErrorBody is the standard JSON error response shape. +type ErrorBody struct { + Error string `json:"error"` + Message string `json:"message,omitempty"` +} + +// JSON writes a JSON body with status code and Content-Type. +func JSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if v != nil { + _ = json.NewEncoder(w).Encode(v) + } +} + +// OK writes 200 with optional JSON body. +func OK(w http.ResponseWriter, v any) { + if v == nil { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + return + } + JSON(w, http.StatusOK, v) +} + +// Error writes a JSON error response. +func Error(w http.ResponseWriter, status int, err string, message string) { + JSON(w, status, ErrorBody{Error: err, Message: message}) +} + +// BadRequest writes 400 with error message. +func BadRequest(w http.ResponseWriter, message string) { + Error(w, http.StatusBadRequest, "bad_request", message) +} + +// InternalError writes 500 and logs the err. +func InternalError(w http.ResponseWriter, message string, logErr error) { + if logErr != nil { + slog.Error(message, "err", logErr) + } + Error(w, http.StatusInternalServerError, "internal_error", message) +} + +// NotFound writes 404. +func NotFound(w http.ResponseWriter, message string) { + Error(w, http.StatusNotFound, "not_found", message) +} diff --git a/internal/pkg/apiclient/auth.go b/internal/pkg/apiclient/auth.go index abf78d4..cb5ce9f 100644 --- a/internal/pkg/apiclient/auth.go +++ b/internal/pkg/apiclient/auth.go @@ -3,6 +3,7 @@ package apiclient import ( "context" "encoding/json" + "fmt" "net/http" "net/url" "strings" @@ -17,14 +18,15 @@ type response struct { func GetToken(ctx context.Context, cfg *config.Config, client *http.Client) (string, error) { formData := url.Values{} formData.Set("grant_type", "password") - formData.Set("client_id", "Fastapi") - formData.Set("client_secret", "wojuoB7Z5xhlPFrF2lIxJSSdVHCApEgC") - formData.Set("username", "core") - formData.Set("password", "C0r3_us3r_Cr3d3nt14ls") - formData.Set("audience", "Fastapi") + formData.Set("client_id", cfg.HTTPClientID) + formData.Set("client_secret", cfg.ClientSecret) + formData.Set("username", cfg.HTTPUsername) + formData.Set("password", cfg.HTTPPassword) + formData.Set("audience", cfg.HTTPAudience) - req, err := http.NewRequest("POST", "https://10.251.0.30:10002/realms/API.Server.local/protocol/openid-connect/token", strings.NewReader(formData.Encode())) + req, err := http.NewRequest("POST", fmt.Sprintf("%s/realms/API.Server.local/protocol/openid-connect/token", cfg.APIAuthURL), strings.NewReader(formData.Encode())) if err != nil { + fmt.Println("error", err) return "", err } req.Header.Add("Content-Type", "application/x-www-form-urlencoded") @@ -32,12 +34,14 @@ func GetToken(ctx context.Context, cfg *config.Config, client *http.Client) (str req = req.WithContext(ctx) res, err := client.Do(req) if err != nil { + fmt.Println("error", err) return "", err } var j response if err := json.NewDecoder(res.Body).Decode(&j); err != nil { + fmt.Println("error", err) return "", err } diff --git a/internal/pkg/apiclient/data.go b/internal/pkg/apiclient/data.go index e5e4104..24a8a5e 100644 --- a/internal/pkg/apiclient/data.go +++ b/internal/pkg/apiclient/data.go @@ -5,27 +5,31 @@ import ( "fmt" "net/http" + "github.com/AFASystems/presence/internal/pkg/config" "github.com/AFASystems/presence/internal/pkg/model" ) -func GetTrackers(token string, client *http.Client) ([]model.Tracker, error) { - res, err := getRequest(token, "getTrackers", client) +func GetTrackers(token string, client *http.Client, cfg *config.Config) ([]model.Tracker, error) { + res, err := getRequest(token, "getTrackers", client, cfg) if err != nil { + fmt.Printf("error get trackers: %+v\n", err) return []model.Tracker{}, err } var i []model.Tracker err = json.NewDecoder(res.Body).Decode(&i) if err != nil { + fmt.Printf("error decode trackers: %+v\n", err) return []model.Tracker{}, err } return i, nil } -func GetGateways(token string, client *http.Client) ([]model.Gateway, error) { - res, err := getRequest(token, "getGateways", client) +func GetGateways(token string, client *http.Client, cfg *config.Config) ([]model.Gateway, error) { + res, err := getRequest(token, "getGateways", client, cfg) if err != nil { + fmt.Printf("error get gateways: %+v\n", err) return []model.Gateway{}, err } @@ -38,8 +42,8 @@ func GetGateways(token string, client *http.Client) ([]model.Gateway, error) { return i, nil } -func GetTrackerZones(token string, client *http.Client) ([]model.TrackerZones, error) { - res, err := getRequest(token, "getTrackerZones", client) +func GetTrackerZones(token string, client *http.Client, cfg *config.Config) ([]model.TrackerZones, error) { + res, err := getRequest(token, "getTrackerZones", client, cfg) if err != nil { return []model.TrackerZones{}, err } @@ -53,8 +57,8 @@ func GetTrackerZones(token string, client *http.Client) ([]model.TrackerZones, e return i, nil } -func GetZones(token string, client *http.Client) ([]model.Zone, error) { - res, err := getRequest(token, "getZones", client) +func GetZones(token string, client *http.Client, cfg *config.Config) ([]model.Zone, error) { + res, err := getRequest(token, "getZones", client, cfg) if err != nil { return []model.Zone{}, err } @@ -68,35 +72,28 @@ func GetZones(token string, client *http.Client) ([]model.Zone, error) { return i, nil } -func GetTracks(token string, client *http.Client) ([]model.Tracks, error) { - res, err := getRequest(token, "getTracks", client) - if err != nil { - return []model.Tracks{}, err - } - - var i []model.Tracks - err = json.NewDecoder(res.Body).Decode(&i) +func InferPosition(token string, client *http.Client, cfg *config.Config) (model.PositionResponse, error) { + url := fmt.Sprintf("%s/ble-ai/infer", cfg.APIBaseURL) + req, err := http.NewRequest("GET", url, nil) if err != nil { - return []model.Tracks{}, err + fmt.Printf("error new request: %+v\n", err) + return model.PositionResponse{}, err } - return i, nil -} + setHeader(req, token) -func getRequest(token, route string, client *http.Client) (*http.Response, error) { - url := fmt.Sprintf("https://10.251.0.30:5050/reslevis/%s", route) - req, err := http.NewRequest("GET", url, nil) + res, err := client.Do(req) if err != nil { - return nil, err + fmt.Printf("error do request: %+v\n", err) + return model.PositionResponse{}, err } - header := fmt.Sprintf("Bearer %s", token) - - req.Header.Add("Authorization", header) - res, err := client.Do(req) + var i model.PositionResponse + err = json.NewDecoder(res.Body).Decode(&i) if err != nil { - return nil, err + fmt.Printf("error decode response: %+v\n", err) + return model.PositionResponse{}, err } - return res, nil + return i, nil } diff --git a/internal/pkg/apiclient/updatedb.go b/internal/pkg/apiclient/updatedb.go index c670644..8239164 100644 --- a/internal/pkg/apiclient/updatedb.go +++ b/internal/pkg/apiclient/updatedb.go @@ -4,6 +4,7 @@ import ( "context" "crypto/tls" "fmt" + "log/slog" "net/http" "reflect" @@ -27,10 +28,11 @@ func UpdateDB(db *gorm.DB, ctx context.Context, cfg *config.Config, writer *kafk return err } - if trackers, err := GetTrackers(token, client); err == nil { + if trackers, err := GetTrackers(token, client, cfg); err == nil { syncTable(db, trackers) if err := controller.SendKafkaMessage(writer, &model.ApiUpdate{Method: "DELETE", MAC: "all"}, ctx); err != nil { - fmt.Printf("Error in sending delete all from lookup message: %v", err) + msg := fmt.Sprintf("Error in sending delete all from lookup message: %v", err) + slog.Error(msg) } for _, v := range trackers { @@ -41,32 +43,37 @@ func UpdateDB(db *gorm.DB, ctx context.Context, cfg *config.Config, writer *kafk } if err := controller.SendKafkaMessage(writer, &apiUpdate, ctx); err != nil { - fmt.Printf("Error in sending POST kafka message: %v", err) + msg := fmt.Sprintf("Error in sending POST kafka message: %v", err) + slog.Error(msg) } } } - if gateways, err := GetGateways(token, client); err == nil { + if gateways, err := GetGateways(token, client, cfg); err == nil { syncTable(db, gateways) } - // if tracks, err := GetTracks(token, client); err == nil { - // fmt.Printf("Tracks: %+v\n", tracks) - // syncTable(db, tracks) - // } - - if zones, err := GetZones(token, client); err == nil { + if zones, err := GetZones(token, client, cfg); err == nil { syncTable(db, zones) } - if trackerZones, err := GetTrackerZones(token, client); err == nil { + if trackerZones, err := GetTrackerZones(token, client, cfg); err == nil { syncTable(db, trackerZones) } + if inferredPosition, err := InferPosition(token, client, cfg); err == nil { + for _, v := range inferredPosition.Items { + mac := convertMac(v.Mac) + fmt.Println(mac) + db.Model(&model.Tracker{}).Where("mac = ?", mac).Update("x", v.X).Update("y", v.Y) + } + } + var settings model.Settings db.First(&settings) if settings.ID == 0 { - fmt.Println("settings are empty") + msg := "settings are empty" + slog.Info(msg) db.Create(appState.GetSettings()) } diff --git a/internal/pkg/apiclient/utils.go b/internal/pkg/apiclient/utils.go new file mode 100644 index 0000000..52d6bf0 --- /dev/null +++ b/internal/pkg/apiclient/utils.go @@ -0,0 +1,28 @@ +package apiclient + +import ( + "fmt" + "net/http" + "strings" + + "github.com/AFASystems/presence/internal/pkg/config" +) + +func setHeader(req *http.Request, token string) { + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) +} + +func getRequest(token, route string, client *http.Client, cfg *config.Config) (*http.Response, error) { + url := fmt.Sprintf("%s/reslevis/%s", cfg.APIBaseURL, route) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + setHeader(req, token) + return client.Do(req) +} + +func convertMac(mac string) string { + return strings.ToUpper(strings.ReplaceAll(mac, ":", "")) +} diff --git a/internal/pkg/bridge/handler.go b/internal/pkg/bridge/handler.go new file mode 100644 index 0000000..4c61055 --- /dev/null +++ b/internal/pkg/bridge/handler.go @@ -0,0 +1,76 @@ +package bridge + +import ( + "context" + "encoding/json" + "log/slog" + "strings" + "time" + + "github.com/AFASystems/presence/internal/pkg/model" + "github.com/segmentio/kafka-go" +) + +// RawBeaconWriter writes beacon advertisements to the rawbeacons topic. +type RawBeaconWriter interface { + WriteMessages(ctx context.Context, msgs ...kafka.Message) error +} + +// BeaconLookup provides MAC->ID lookup (e.g. AppState). +type BeaconLookup interface { + BeaconExists(mac string) (id string, ok bool) +} + +// HandleMQTTMessage processes an MQTT message: parses JSON array of RawReading or CSV. +// For JSON, converts each reading to BeaconAdvertisement and writes to the writer if MAC is in lookup. +// Hostname is derived from topic (e.g. "publish_out/gateway1" -> "gateway1"). Safe if topic has no "/". +func HandleMQTTMessage(topic string, payload []byte, lookup BeaconLookup, writer RawBeaconWriter) { + parts := strings.SplitN(topic, "/", 2) + hostname := "" + if len(parts) >= 2 { + hostname = parts[1] + } + + msgStr := string(payload) + if strings.HasPrefix(msgStr, "[") { + var readings []model.RawReading + if err := json.Unmarshal(payload, &readings); err != nil { + slog.Error("parsing MQTT JSON", "err", err, "topic", topic) + return + } + for _, reading := range readings { + if reading.Type == "Gateway" { + continue + } + id, ok := lookup.BeaconExists(reading.MAC) + if !ok { + continue + } + adv := model.BeaconAdvertisement{ + ID: id, + Hostname: hostname, + MAC: reading.MAC, + RSSI: int64(reading.RSSI), + Data: reading.RawData, + } + encoded, err := json.Marshal(adv) + if err != nil { + slog.Error("marshaling beacon advertisement", "err", err) + break + } + if err := writer.WriteMessages(context.Background(), kafka.Message{Value: encoded}); err != nil { + slog.Error("writing to Kafka", "err", err) + time.Sleep(1 * time.Second) + break + } + } + return + } + // CSV format: validate minimum fields (e.g. 6 columns); full parsing can be added later + s := strings.Split(msgStr, ",") + if len(s) < 6 { + slog.Error("invalid CSV MQTT message", "topic", topic, "message", msgStr) + return + } + slog.Debug("CSV MQTT message received", "topic", topic, "fields", len(s)) +} diff --git a/internal/pkg/bridge/mqtt.go b/internal/pkg/bridge/mqtt.go new file mode 100644 index 0000000..81278fa --- /dev/null +++ b/internal/pkg/bridge/mqtt.go @@ -0,0 +1,61 @@ +package bridge + +import ( + "fmt" + "log/slog" + + "github.com/AFASystems/presence/internal/pkg/config" + mqtt "github.com/eclipse/paho.mqtt.golang" + "github.com/google/uuid" +) + +const defaultMQTTPort = 1883 +const subscribeTopic = "publish_out/#" +const disconnectQuiesceMs = 250 + +// MQTTClient wraps paho MQTT client and options. +type MQTTClient struct { + Client mqtt.Client +} + +// NewMQTTClient creates and connects an MQTT client. Returns error instead of panic on connect failure. +func NewMQTTClient(cfg *config.Config, publishHandler func(mqtt.Message)) (*MQTTClient, error) { + opts := mqtt.NewClientOptions() + opts.AddBroker(fmt.Sprintf("tcp://%s:%d", cfg.MQTTHost, defaultMQTTPort)) + opts.SetClientID(fmt.Sprintf("bridge-%s", uuid.New().String())) + opts.SetAutoReconnect(true) + opts.SetConnectRetry(true) + opts.SetConnectRetryInterval(config.SMALL_TICKER_INTERVAL) + opts.SetMaxReconnectInterval(config.LARGE_TICKER_INTERVAL) + opts.SetCleanSession(false) + opts.SetDefaultPublishHandler(func(c mqtt.Client, m mqtt.Message) { + publishHandler(m) + }) + opts.OnConnect = func(client mqtt.Client) { + slog.Info("MQTT connected") + } + opts.OnConnectionLost = func(client mqtt.Client, err error) { + slog.Error("MQTT connection lost", "err", err) + } + + client := mqtt.NewClient(opts) + token := client.Connect() + token.Wait() + if err := token.Error(); err != nil { + return nil, fmt.Errorf("mqtt connect: %w", err) + } + return &MQTTClient{Client: client}, nil +} + +// Subscribe subscribes to the default bridge topic. +func (m *MQTTClient) Subscribe() { + token := m.Client.Subscribe(subscribeTopic, 1, nil) + token.Wait() + slog.Info("MQTT subscribed", "topic", subscribeTopic) +} + +// Disconnect disconnects the client with quiesce. +func (m *MQTTClient) Disconnect() { + m.Client.Disconnect(disconnectQuiesceMs) + slog.Info("MQTT disconnected") +} diff --git a/internal/pkg/common/appcontext/context.go b/internal/pkg/common/appcontext/context.go index 2174e54..83c3401 100644 --- a/internal/pkg/common/appcontext/context.go +++ b/internal/pkg/common/appcontext/context.go @@ -2,6 +2,7 @@ package appcontext import ( "fmt" + "log/slog" "github.com/AFASystems/presence/internal/pkg/model" "github.com/mitchellh/mapstructure" @@ -10,10 +11,9 @@ import ( // AppState provides centralized access to application state type AppState struct { beacons model.BeaconsList - httpResults model.HTTPResultList settings model.Settings beaconEvents model.BeaconEventList - beaconsLookup map[string]string + beaconsLookup model.BeaconsLookup } // NewAppState creates a new application context AppState with default values @@ -22,9 +22,6 @@ func NewAppState() *AppState { beacons: model.BeaconsList{ Beacons: make(map[string]model.Beacon), }, - httpResults: model.HTTPResultList{ - Results: make(map[string]model.HTTPResult), - }, settings: model.Settings{ ID: 1, CurrentAlgorithm: "filter", // possible values filter or AI @@ -39,12 +36,16 @@ func NewAppState() *AppState { beaconEvents: model.BeaconEventList{ Beacons: make(map[string]model.BeaconEvent), }, - beaconsLookup: make(map[string]string), + beaconsLookup: model.BeaconsLookup{ + Lookup: make(map[string]string), + }, } } // GetBeacons returns thread-safe access to beacons list func (m *AppState) GetBeacons() *model.BeaconsList { + m.beacons.Lock.RLock() + defer m.beacons.Lock.RUnlock() return &m.beacons } @@ -55,43 +56,36 @@ func (m *AppState) GetSettings() *model.Settings { // GetBeaconEvents returns thread-safe access to beacon events func (m *AppState) GetBeaconEvents() *model.BeaconEventList { + m.beaconEvents.Lock.RLock() + defer m.beaconEvents.Lock.RUnlock() return &m.beaconEvents } -// GetBeaconsLookup returns thread-safe access to beacon lookup map -func (m *AppState) GetBeaconsLookup() map[string]string { - return m.beaconsLookup -} - // AddBeaconToLookup adds a beacon ID to the lookup map func (m *AppState) AddBeaconToLookup(id, value string) { - m.beaconsLookup[id] = value + m.beaconsLookup.Lock.Lock() + m.beaconsLookup.Lookup[id] = value + m.beaconsLookup.Lock.Unlock() } // RemoveBeaconFromLookup removes a beacon ID from the lookup map func (m *AppState) RemoveBeaconFromLookup(id string) { - delete(m.beaconsLookup, id) + m.beaconsLookup.Lock.Lock() + delete(m.beaconsLookup.Lookup, id) + m.beaconsLookup.Lock.Unlock() } func (m *AppState) CleanLookup() { - clear(m.beaconsLookup) -} - -func (m *AppState) RemoveBeacon(id string) { - m.beacons.Lock.Lock() - delete(m.beacons.Beacons, id) - m.beacons.Lock.Unlock() -} - -func (m *AppState) RemoveHTTPResult(id string) { - m.httpResults.Lock.Lock() - delete(m.httpResults.Results, id) - m.httpResults.Lock.Unlock() + m.beaconsLookup.Lock.Lock() + clear(m.beaconsLookup.Lookup) + m.beaconsLookup.Lock.Unlock() } // BeaconExists checks if a beacon exists in the lookup func (m *AppState) BeaconExists(id string) (string, bool) { - val, exists := m.beaconsLookup[id] + m.beaconsLookup.Lock.RLock() + defer m.beaconsLookup.Lock.RUnlock() + val, exists := m.beaconsLookup.Lookup[id] return val, exists } @@ -157,6 +151,7 @@ func (m *AppState) GetSettingsValue() model.Settings { // UpdateSettings updates the system settings (thread-safe) func (m *AppState) UpdateSettings(settings map[string]any) { if err := mapstructure.Decode(settings, &m.settings); err != nil { - fmt.Printf("Error in persisting settings: %v\n", err) + msg := fmt.Sprintf("Error in persisting settings: %v", err) + slog.Error(msg) } } diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 65403b4..ae73c4b 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -1,6 +1,10 @@ package config -import "os" +import ( + "fmt" + "os" + "time" +) type Config struct { HTTPAddr string @@ -19,9 +23,13 @@ type Config struct { HTTPUsername string HTTPPassword string HTTPAudience string + ConfigPath string + APIBaseURL string + APIAuthURL string + // TLSInsecureSkipVerify enables skipping TLS cert verification (e.g. for dev); default false. + TLSInsecureSkipVerify bool } -// getEnv returns env var value or a default if not set. func getEnv(key, def string) string { if v := os.Getenv(key); v != "" { return v @@ -29,23 +37,95 @@ func getEnv(key, def string) string { return def } +func getEnvBool(key string, defaultVal bool) bool { + switch os.Getenv(key) { + case "1", "true", "TRUE", "yes": + return true + case "0", "false", "FALSE", "no": + return false + } + return defaultVal +} + +func getEnvPanic(key string) string { + if v := os.Getenv(key); v != "" { + return v + } + panic(fmt.Sprintf("environment variable %s is not set", key)) +} + func Load() *Config { return &Config{ HTTPAddr: getEnv("HTTP_HOST_PATH", "0.0.0.0:1902"), WSAddr: getEnv("HTTPWS_HOST_PATH", "0.0.0.0:8088"), MQTTHost: getEnv("MQTT_HOST", "192.168.1.101"), - MQTTUser: getEnv("MQTT_USERNAME", "user"), - MQTTPass: getEnv("MQTT_PASSWORD", "pass"), - MQTTClientID: getEnv("MQTT_CLIENT_ID", "presence-detector"), + MQTTUser: getEnvPanic("MQTT_USERNAME"), + MQTTPass: getEnvPanic("MQTT_PASSWORD"), + MQTTClientID: getEnvPanic("MQTT_CLIENT_ID"), KafkaURL: getEnv("KAFKA_URL", "127.0.0.1:9092"), DBHost: getEnv("DBHost", "127.0.0.1"), - DBUser: getEnv("DBUser", "postgres"), - DBPass: getEnv("DBPass", "postgres"), + DBUser: getEnvPanic("DBUser"), + DBPass: getEnvPanic("DBPass"), DBName: getEnv("DBName", "go_crud_db"), - HTTPClientID: getEnv("HTTPClientID", "Fastapi"), - ClientSecret: getEnv("ClientSecret", "wojuoB7Z5xhlPFrF2lIxJSSdVHCApEgC"), - HTTPUsername: getEnv("HTTPUsername", "core"), - HTTPPassword: getEnv("HTTPPassword", "C0r3_us3r_Cr3d3nt14ls"), - HTTPAudience: getEnv("HTTPAudience", "Fastapi"), + HTTPClientID: getEnvPanic("HTTPClientID"), + ClientSecret: getEnvPanic("ClientSecret"), + HTTPUsername: getEnvPanic("HTTPUsername"), + HTTPPassword: getEnvPanic("HTTPPassword"), + HTTPAudience: getEnvPanic("HTTPAudience"), + ConfigPath: getEnv("CONFIG_PATH", "/app/cmd/server/config.json"), + APIBaseURL: getEnv("API_BASE_URL", "https://10.251.0.30:5050"), + TLSInsecureSkipVerify: getEnvBool("TLS_INSECURE_SKIP_VERIFY", false), } } + +func LoadDecoder() *Config { + return &Config{ + KafkaURL: getEnv("KAFKA_URL", "127.0.0.1:9092"), + } +} + +func LoadServer() *Config { + return &Config{ + KafkaURL: getEnv("KAFKA_URL", "127.0.0.1:9092"), + HTTPAddr: getEnv("HTTP_HOST_PATH", "0.0.0.0:1902"), + DBHost: getEnv("DBHost", "127.0.0.1"), + DBUser: getEnvPanic("DBUser"), + DBPass: getEnvPanic("DBPass"), + DBName: getEnv("DBName", "go_crud_db"), + HTTPClientID: getEnvPanic("HTTPClientID"), + ClientSecret: getEnvPanic("ClientSecret"), + HTTPUsername: getEnvPanic("HTTPUsername"), + HTTPPassword: getEnvPanic("HTTPPassword"), + HTTPAudience: getEnvPanic("HTTPAudience"), + ConfigPath: getEnv("CONFIG_PATH", "/app/cmd/server/config.json"), + APIBaseURL: getEnv("API_BASE_URL", "https://10.251.0.30:5050"), + APIAuthURL: getEnv("API_AUTH_URL", "https://10.251.0.30:10002"), + TLSInsecureSkipVerify: getEnvBool("TLS_INSECURE_SKIP_VERIFY", false), + } +} + +func LoadBridge() *Config { + return &Config{ + KafkaURL: getEnv("KAFKA_URL", "127.0.0.1:9092"), + MQTTHost: getEnv("MQTT_HOST", "192.168.1.101"), + MQTTUser: getEnvPanic("MQTT_USERNAME"), + MQTTPass: getEnvPanic("MQTT_PASSWORD"), + MQTTClientID: getEnvPanic("MQTT_CLIENT_ID"), + } +} + +func LoadLocation() *Config { + return &Config{ + KafkaURL: getEnv("KAFKA_URL", "127.0.0.1:9092"), + TLSInsecureSkipVerify: getEnvBool("TLS_INSECURE_SKIP_VERIFY", false), + } +} + +const ( + SMALL_CHANNEL_SIZE = 200 + MEDIUM_CHANNEL_SIZE = 500 + LARGE_CHANNEL_SIZE = 2000 + SMALL_TICKER_INTERVAL = 1 * time.Second + MEDIUM_TICKER_INTERVAL = 2 * time.Second + LARGE_TICKER_INTERVAL = 5 * time.Second +) diff --git a/internal/pkg/controller/parser_controller.go b/internal/pkg/controller/parser_controller.go index 51b0462..1bcd2b3 100644 --- a/internal/pkg/controller/parser_controller.go +++ b/internal/pkg/controller/parser_controller.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "net/http" "github.com/AFASystems/presence/internal/pkg/model" @@ -32,7 +33,8 @@ func ParserAddController(db *gorm.DB, writer *kafka.Writer, ctx context.Context) if err := service.SendParserConfig(kp, writer, ctx); err != nil { http.Error(w, "Unable to send parser config to kafka broker", 400) - fmt.Printf("Unable to send parser config to kafka broker %v\n", err) + msg := fmt.Sprintf("Unable to send parser config to kafka broker %v", err) + slog.Error(msg) return } @@ -69,7 +71,8 @@ func ParserDeleteController(db *gorm.DB, writer *kafka.Writer, ctx context.Conte if err := service.SendParserConfig(kp, writer, ctx); err != nil { http.Error(w, "Unable to send parser config to kafka broker", 400) - fmt.Printf("Unable to send parser config to kafka broker %v\n", err) + msg := fmt.Sprintf("Unable to send parser config to kafka broker %v", err) + slog.Error(msg) return } @@ -103,7 +106,8 @@ func ParserUpdateController(db *gorm.DB, writer *kafka.Writer, ctx context.Conte db.Save(&config) if err := service.SendParserConfig(kp, writer, ctx); err != nil { http.Error(w, "Unable to send parser config to kafka broker", 400) - fmt.Printf("Unable to send parser config to kafka broker %v\n", err) + msg := fmt.Sprintf("Unable to send parser config to kafka broker %v", err) + slog.Error(msg) return } diff --git a/internal/pkg/controller/settings_controller.go b/internal/pkg/controller/settings_controller.go index 9e70988..2cefa50 100644 --- a/internal/pkg/controller/settings_controller.go +++ b/internal/pkg/controller/settings_controller.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "net/http" "github.com/AFASystems/presence/internal/pkg/model" @@ -33,9 +34,12 @@ func SettingsUpdateController(db *gorm.DB, writer *kafka.Writer, ctx context.Con return } - fmt.Printf("updates: %+v\n", updates) + inMsg := fmt.Sprintf("updates: %+v", updates) + slog.Info(inMsg) if err := db.Model(&model.Settings{}).Where("id = ?", 1).Updates(updates).Error; err != nil { + msg := fmt.Sprintf("Error in updating settings: %v", err) + slog.Error(msg) http.Error(w, err.Error(), 500) return } @@ -43,16 +47,22 @@ func SettingsUpdateController(db *gorm.DB, writer *kafka.Writer, ctx context.Con eMsg, err := json.Marshal(updates) if err != nil { http.Error(w, "Error in marshaling settings updates", 400) + msg := fmt.Sprintf("Error in marshaling settings updates: %v", err) + slog.Error(msg) return } + msg := kafka.Message{ Value: eMsg, } - fmt.Printf("Kafka message: %+v\n", eMsg) - - writer.WriteMessages(ctx, msg) + if err := writer.WriteMessages(ctx, msg); err != nil { + slog.Error("writing settings to Kafka", "err", err) + http.Error(w, "Failed to publish settings update", 500) + return + } - w.Write([]byte("Settings updated")) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"Settings updated"}`)) } } diff --git a/internal/pkg/controller/trackers_controller.go b/internal/pkg/controller/trackers_controller.go index 8f845c7..76fd717 100644 --- a/internal/pkg/controller/trackers_controller.go +++ b/internal/pkg/controller/trackers_controller.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "net/http" "github.com/AFASystems/presence/internal/pkg/model" @@ -15,7 +16,8 @@ import ( func SendKafkaMessage(writer *kafka.Writer, value *model.ApiUpdate, ctx context.Context) error { valueStr, err := json.Marshal(&value) if err != nil { - fmt.Println("error in encoding: ", err) + msg := fmt.Sprintf("error in encoding: %v", err) + slog.Error(msg) return err } msg := kafka.Message{ @@ -23,7 +25,8 @@ func SendKafkaMessage(writer *kafka.Writer, value *model.ApiUpdate, ctx context. } if err := writer.WriteMessages(ctx, msg); err != nil { - fmt.Println("Error in sending kafka message: ", err) + msg := fmt.Sprintf("Error in sending kafka message: %v", err) + slog.Error(msg) return err } @@ -46,7 +49,8 @@ func TrackerAdd(db *gorm.DB, writer *kafka.Writer, ctx context.Context) http.Han } if err := SendKafkaMessage(writer, &apiUpdate, ctx); err != nil { - fmt.Println("error in sending Kafka POST message") + msg := "error in sending Kafka POST message" + slog.Error(msg) http.Error(w, "Error in sending kafka message", 500) return } @@ -101,13 +105,15 @@ func TrackerDelete(db *gorm.DB, writer *kafka.Writer, ctx context.Context) http. apiUpdate := model.ApiUpdate{ Method: "DELETE", - ID: tracker.ID, + MAC: tracker.MAC, } - fmt.Printf("Sending DELETE tracker id: %s message\n", id) + msg := fmt.Sprintf("Sending DELETE tracker id: %s message", id) + slog.Info(msg) if err := SendKafkaMessage(writer, &apiUpdate, ctx); err != nil { - fmt.Println("error in sending Kafka DELETE message") + msg := "error in sending Kafka DELETE message" + slog.Error(msg) http.Error(w, "Error in sending kafka message", 500) return } diff --git a/internal/pkg/database/database.go b/internal/pkg/database/database.go index 8539df6..264b59b 100644 --- a/internal/pkg/database/database.go +++ b/internal/pkg/database/database.go @@ -2,6 +2,7 @@ package database import ( "fmt" + "log/slog" "github.com/AFASystems/presence/internal/pkg/config" "github.com/AFASystems/presence/internal/pkg/model" @@ -9,8 +10,6 @@ import ( "gorm.io/gorm" ) -var DB *gorm.DB - func Connect(cfg *config.Config) (*gorm.DB, error) { // Connect to PostgreSQL database dsn := fmt.Sprintf( @@ -30,6 +29,7 @@ func Connect(cfg *config.Config) (*gorm.DB, error) { return nil, err } - fmt.Println("Database connection established") + msg := "Database connection established" + slog.Info(msg) return db, nil } diff --git a/internal/pkg/decoder/process.go b/internal/pkg/decoder/process.go new file mode 100644 index 0000000..cdf1c97 --- /dev/null +++ b/internal/pkg/decoder/process.go @@ -0,0 +1,71 @@ +package decoder + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "log/slog" + "strings" + + "github.com/AFASystems/presence/internal/pkg/common/appcontext" + "github.com/AFASystems/presence/internal/pkg/common/utils" + "github.com/AFASystems/presence/internal/pkg/model" + "github.com/segmentio/kafka-go" +) + +// AlertWriter writes decoded beacon events (e.g. to alertbeacons topic). +type AlertWriter interface { + WriteMessages(ctx context.Context, msgs ...kafka.Message) error +} + +// ProcessIncoming decodes a beacon advertisement and writes the event to the writer if it changed. +func ProcessIncoming(adv model.BeaconAdvertisement, appState *appcontext.AppState, writer AlertWriter, registry *model.ParserRegistry) { + if err := DecodeBeacon(adv, appState, writer, registry); err != nil { + slog.Error("decoding beacon", "err", err, "id", adv.ID) + } +} + +// DecodeBeacon hex-decodes the payload, runs the parser registry, dedupes by event hash, and writes to writer. +func DecodeBeacon(adv model.BeaconAdvertisement, appState *appcontext.AppState, writer AlertWriter, registry *model.ParserRegistry) error { + beacon := strings.TrimSpace(adv.Data) + id := adv.ID + if beacon == "" { + return nil + } + + b, err := hex.DecodeString(beacon) + if err != nil { + return err + } + + b = utils.RemoveFlagBytes(b) + indices := utils.ParseADFast(b) + event := utils.LoopADStructures(b, indices, id, registry) + + if event.ID == "" { + return nil + } + + prevEvent, ok := appState.GetBeaconEvent(id) + appState.UpdateBeaconEvent(id, event) + + if event.Type == "iBeacon" { + event.BtnPressed = true + } + + if ok && bytes.Equal(prevEvent.Hash(), event.Hash()) { + return nil + } + + eMsg, err := event.ToJSON() + if err != nil { + return err + } + + if err := writer.WriteMessages(context.Background(), kafka.Message{Value: eMsg}); err != nil { + return fmt.Errorf("write alert: %w", err) + } + + return nil +} diff --git a/internal/pkg/kafkaclient/consumer.go b/internal/pkg/kafkaclient/consumer.go index a5dda6b..bd37721 100644 --- a/internal/pkg/kafkaclient/consumer.go +++ b/internal/pkg/kafkaclient/consumer.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "sync" "github.com/segmentio/kafka-go" @@ -14,18 +15,21 @@ func Consume[T any](r *kafka.Reader, ch chan<- T, ctx context.Context, wg *sync. for { select { case <-ctx.Done(): - fmt.Println("consumer closed") + msg := "consumer closed" + slog.Info(msg) return default: msg, err := r.ReadMessage(ctx) if err != nil { - fmt.Println("error reading message:", err) + msg := fmt.Sprintf("error reading message: %v", err) + slog.Error(msg) continue } var data T if err := json.Unmarshal(msg.Value, &data); err != nil { - fmt.Println("error decoding:", err) + msg := fmt.Sprintf("error decoding: %v", err) + slog.Error(msg) continue } diff --git a/internal/pkg/kafkaclient/manager.go b/internal/pkg/kafkaclient/manager.go index 2341ad0..a904e0b 100644 --- a/internal/pkg/kafkaclient/manager.go +++ b/internal/pkg/kafkaclient/manager.go @@ -2,6 +2,7 @@ package kafkaclient import ( "fmt" + "log/slog" "strings" "sync" "time" @@ -52,15 +53,18 @@ func (m *KafkaManager) AddKafkaWriter(kafkaUrl, topic string) { } func (m *KafkaManager) CleanKafkaWriters() { - fmt.Println("shutdown of kafka readers starts") + msg := "shutdown of kafka writers starts" + slog.Info(msg) m.kafkaWritersMap.KafkaWritersLock.Lock() for _, r := range m.kafkaWritersMap.KafkaWriters { if err := r.Close(); err != nil { - fmt.Printf("Error in closing kafka writer %v", err) + msg := fmt.Sprintf("Error in closing kafka writer %v", err) + slog.Error(msg) } } m.kafkaWritersMap.KafkaWritersLock.Unlock() - fmt.Println("Kafka writers graceful shutdown complete") + msg = "Kafka writers graceful shutdown complete" + slog.Info(msg) } func (m *KafkaManager) AddKafkaReader(kafkaUrl, topic, groupID string) { @@ -82,11 +86,13 @@ func (m *KafkaManager) CleanKafkaReaders() { m.kafkaReadersMap.KafkaReadersLock.Lock() for _, r := range m.kafkaReadersMap.KafkaReaders { if err := r.Close(); err != nil { - fmt.Printf("Error in closing kafka reader %v", err) + msg := fmt.Sprintf("Error in closing kafka reader %v", err) + slog.Error(msg) } } m.kafkaReadersMap.KafkaReadersLock.Unlock() - fmt.Println("Kafka readers graceful shutdown complete") + msg := "Kafka readers graceful shutdown complete" + slog.Info(msg) } func (m *KafkaManager) PopulateKafkaManager(url, name string, topics []string) { @@ -101,13 +107,13 @@ func (m *KafkaManager) PopulateKafkaManager(url, name string, topics []string) { } func (m *KafkaManager) GetReader(topic string) *kafka.Reader { - m.kafkaReadersMap.KafkaReadersLock.Lock() - defer m.kafkaReadersMap.KafkaReadersLock.Unlock() + m.kafkaReadersMap.KafkaReadersLock.RLock() + defer m.kafkaReadersMap.KafkaReadersLock.RUnlock() return m.kafkaReadersMap.KafkaReaders[topic] } func (m *KafkaManager) GetWriter(topic string) *kafka.Writer { - m.kafkaWritersMap.KafkaWritersLock.Lock() - defer m.kafkaWritersMap.KafkaWritersLock.Unlock() + m.kafkaWritersMap.KafkaWritersLock.RLock() + defer m.kafkaWritersMap.KafkaWritersLock.RUnlock() return m.kafkaWritersMap.KafkaWriters[topic] } diff --git a/internal/pkg/location/assign.go b/internal/pkg/location/assign.go new file mode 100644 index 0000000..c9bf9b1 --- /dev/null +++ b/internal/pkg/location/assign.go @@ -0,0 +1,51 @@ +package location + +import ( + "log/slog" + "time" + + "github.com/AFASystems/presence/internal/pkg/common/appcontext" + "github.com/AFASystems/presence/internal/pkg/common/utils" + "github.com/AFASystems/presence/internal/pkg/model" +) + +// AssignBeaconToList updates app state with a new beacon advertisement: appends a metric +// to the beacon's sliding window and updates last seen. +func AssignBeaconToList(adv model.BeaconAdvertisement, appState *appcontext.AppState) { + id := adv.ID + now := time.Now().Unix() + settings := appState.GetSettingsValue() + + if settings.RSSIEnforceThreshold && int64(adv.RSSI) < settings.RSSIMinThreshold { + slog.Debug("settings RSSI threshold filter", "id", id) + return + } + + beacon, ok := appState.GetBeacon(id) + if !ok { + beacon = model.Beacon{ID: id} + } + + beacon.IncomingJSON = adv + beacon.LastSeen = now + + if beacon.BeaconMetrics == nil { + beacon.BeaconMetrics = make([]model.BeaconMetric, 0, settings.BeaconMetricSize) + } + + metric := model.BeaconMetric{ + Distance: utils.CalculateDistance(adv), + Timestamp: now, + RSSI: int64(adv.RSSI), + Location: adv.Hostname, + } + + if len(beacon.BeaconMetrics) >= settings.BeaconMetricSize { + copy(beacon.BeaconMetrics, beacon.BeaconMetrics[1:]) + beacon.BeaconMetrics[settings.BeaconMetricSize-1] = metric + } else { + beacon.BeaconMetrics = append(beacon.BeaconMetrics, metric) + } + + appState.UpdateBeacon(id, beacon) +} diff --git a/internal/pkg/location/filter.go b/internal/pkg/location/filter.go new file mode 100644 index 0000000..a963611 --- /dev/null +++ b/internal/pkg/location/filter.go @@ -0,0 +1,96 @@ +package location + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/AFASystems/presence/internal/pkg/common/appcontext" + "github.com/AFASystems/presence/internal/pkg/model" + "github.com/segmentio/kafka-go" +) + +// Score weights for location algorithm (configurable via constants). +const ( + SeenWeight = 1.5 + RSSIWeight = 0.75 + DefaultDistance = 999 + DefaultLastSeen = 999 +) + +// LocationWriter writes location events (e.g. to Kafka). +type LocationWriter interface { + WriteMessages(ctx context.Context, msgs ...kafka.Message) error +} + +// GetLikelyLocations runs the filter algorithm: scores beacons by RSSI and seen count, +// updates app state with best location and confidence, and writes HTTPLocation to the writer. +func GetLikelyLocations(appState *appcontext.AppState, writer LocationWriter) { + ctx := context.Background() + beacons := appState.GetAllBeacons() + settings := appState.GetSettingsValue() + + for _, beacon := range beacons { + r := model.HTTPLocation{ + Method: "Standard", + Distance: DefaultDistance, + ID: beacon.ID, + Location: "", + LastSeen: DefaultLastSeen, + } + + mSize := len(beacon.BeaconMetrics) + if mSize == 0 { + continue + } + + if (int64(time.Now().Unix()) - beacon.BeaconMetrics[mSize-1].Timestamp) > settings.LastSeenThreshold { + slog.Warn("beacon is too old", "id", beacon.ID) + continue + } + + locList := make(map[string]float64) + for _, metric := range beacon.BeaconMetrics { + res := SeenWeight + (RSSIWeight * (1.0 - (float64(metric.RSSI) / -100.0))) + locList[metric.Location] += res + } + + bestLocName := "" + maxScore := 0.0 + for locName, score := range locList { + if score > maxScore { + maxScore = score + bestLocName = locName + } + } + + if bestLocName == beacon.PreviousLocation { + beacon.LocationConfidence++ + } else { + beacon.LocationConfidence = 0 + } + + r.Distance = beacon.BeaconMetrics[mSize-1].Distance + r.Location = bestLocName + r.LastSeen = beacon.BeaconMetrics[mSize-1].Timestamp + r.RSSI = beacon.BeaconMetrics[mSize-1].RSSI + + if beacon.LocationConfidence == settings.LocationConfidence && beacon.PreviousConfidentLocation != bestLocName { + beacon.LocationConfidence = 0 + } + + beacon.PreviousLocation = bestLocName + appState.UpdateBeacon(beacon.ID, beacon) + + js, err := json.Marshal(r) + if err != nil { + slog.Error("marshaling location", "err", err, "beacon_id", beacon.ID) + continue + } + + if err := writer.WriteMessages(ctx, kafka.Message{Value: js}); err != nil { + slog.Error("sending kafka location message", "err", err, "beacon_id", beacon.ID) + } + } +} diff --git a/internal/pkg/location/inference.go b/internal/pkg/location/inference.go new file mode 100644 index 0000000..fabcb87 --- /dev/null +++ b/internal/pkg/location/inference.go @@ -0,0 +1,41 @@ +package location + +import ( + "context" + "crypto/tls" + "net/http" + + "github.com/AFASystems/presence/internal/pkg/apiclient" + "github.com/AFASystems/presence/internal/pkg/config" + "github.com/AFASystems/presence/internal/pkg/model" +) + +// Inferencer returns inferred positions (e.g. from an AI/ML service). +type Inferencer interface { + Infer(ctx context.Context, cfg *config.Config) (model.PositionResponse, error) +} + +// DefaultInferencer uses apiclient to get token and call the inference API. +type DefaultInferencer struct { + Client *http.Client +} + +// NewDefaultInferencer creates an inferencer with optional TLS skip verify (e.g. from config.TLSInsecureSkipVerify). +func NewDefaultInferencer(skipTLSVerify bool) *DefaultInferencer { + tr := &http.Transport{} + if skipTLSVerify { + tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + return &DefaultInferencer{ + Client: &http.Client{Transport: tr}, + } +} + +// Infer gets a token and calls the inference API. +func (d *DefaultInferencer) Infer(ctx context.Context, cfg *config.Config) (model.PositionResponse, error) { + token, err := apiclient.GetToken(ctx, cfg, d.Client) + if err != nil { + return model.PositionResponse{}, err + } + return apiclient.InferPosition(token, d.Client, cfg) +} diff --git a/internal/pkg/logger/logger.go b/internal/pkg/logger/logger.go index 2650bfc..2cfd504 100644 --- a/internal/pkg/logger/logger.go +++ b/internal/pkg/logger/logger.go @@ -2,18 +2,20 @@ package logger import ( "io" - "log" "log/slog" "os" ) -func CreateLogger(fname string) *slog.Logger { +// CreateLogger creates a logger writing to both stderr and the given file. +// If the file cannot be opened, returns a logger that writes only to stderr and a no-op cleanup. +// Callers can check whether logging to file is active if needed. +func CreateLogger(fname string) (*slog.Logger, func()) { f, err := os.OpenFile(fname, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) if err != nil { - log.Fatalf("Failed to open log file: %v\n", err) + return slog.New(slog.NewJSONHandler(os.Stderr, nil)), func() {} } - // shell and log file multiwriter w := io.MultiWriter(os.Stderr, f) logger := slog.New(slog.NewJSONHandler(w, nil)) - return logger + cleanup := func() { f.Close() } + return logger, cleanup } diff --git a/internal/pkg/model/parser.go b/internal/pkg/model/parser.go index a726021..83b5ae8 100644 --- a/internal/pkg/model/parser.go +++ b/internal/pkg/model/parser.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/binary" "fmt" + "log/slog" "sync" ) @@ -55,6 +56,8 @@ func (p *ParserRegistry) Register(name string, c Config) { b := BeaconParser{ CanParse: func(ad []byte) bool { if len(ad) < 2 { + msg := "Beacon advertisement is too short" + slog.Error(msg) return false } return len(ad) >= c.Min && len(ad) <= c.Max && bytes.HasPrefix(ad[1:], c.GetPatternBytes()) diff --git a/internal/pkg/model/position.go b/internal/pkg/model/position.go new file mode 100644 index 0000000..6b9ec7e --- /dev/null +++ b/internal/pkg/model/position.go @@ -0,0 +1,13 @@ +package model + +type PositionResponse struct { + Count int `json:"count"` + Items []Position `json:"items"` +} + +type Position struct { + Mac string `json:"mac"` + X float32 `json:"x"` + Y float32 `json:"y"` + Z float32 `json:"z"` +} diff --git a/internal/pkg/model/trackers.go b/internal/pkg/model/trackers.go index af373a3..096a272 100644 --- a/internal/pkg/model/trackers.go +++ b/internal/pkg/model/trackers.go @@ -14,7 +14,7 @@ type Tracker struct { Building string `json:"building"` Location string `json:"location"` Distance float64 `json:"distance"` - Battery uint32 `json:"battery"` + Battery uint32 `json:"battery,string"` BatteryThreshold uint32 `json:"batteryThreshold"` - Temperature uint16 `json:"temperature"` + Temperature uint16 `json:"temperature,string"` } diff --git a/internal/pkg/model/types.go b/internal/pkg/model/types.go index b7b4fba..4b61144 100644 --- a/internal/pkg/model/types.go +++ b/internal/pkg/model/types.go @@ -104,11 +104,6 @@ type HTTPResult struct { PreviousConfidentLocation string `json:"previous_confident_location"` } -type HTTPResultList struct { - Results map[string]HTTPResult - Lock sync.RWMutex -} - // BeaconsList holds all known beacons and their synchronization lock. type BeaconsList struct { Beacons map[string]Beacon `json:"beacons"` @@ -120,6 +115,11 @@ type BeaconEventList struct { Lock sync.RWMutex } +type BeaconsLookup struct { + Lookup map[string]string + Lock sync.RWMutex +} + // RawReading represents an incoming raw sensor reading. type RawReading struct { Timestamp string `json:"timestamp"` diff --git a/internal/pkg/service/beacon_service.go b/internal/pkg/service/beacon_service.go index d4011fd..4ee23ac 100644 --- a/internal/pkg/service/beacon_service.go +++ b/internal/pkg/service/beacon_service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "slices" "strings" "time" @@ -13,19 +14,29 @@ import ( "gorm.io/gorm" ) -func LocationToBeaconService(msg model.HTTPLocation, db *gorm.DB, writer *kafka.Writer, ctx context.Context) { +// KafkaWriter defines the interface for writing Kafka messages (allows mocking in tests) +type KafkaWriter interface { + WriteMessages(ctx context.Context, msgs ...kafka.Message) error +} + +func LocationToBeaconService(msg model.HTTPLocation, db *gorm.DB, writer KafkaWriter, ctx context.Context) { if msg.ID == "" { - fmt.Println("empty ID") + msg := "empty ID" + slog.Error(msg) return } var zones []model.TrackerZones if err := db.Select("zoneList").Where("tracker = ?", msg.ID).Find(&zones).Error; err != nil { + msg := fmt.Sprintf("Error in selecting zones: %v", err) + slog.Error(msg) return } var tracker model.Tracker if err := db.Where("id = ?", msg.ID).Find(&tracker).Error; err != nil { + msg := fmt.Sprintf("Error in selecting tracker: %v", err) + slog.Error(msg) return } @@ -37,9 +48,10 @@ func LocationToBeaconService(msg model.HTTPLocation, db *gorm.DB, writer *kafka. var gw model.Gateway mac := formatMac(msg.Location) if err := db.Select("id").Where("mac = ?", mac).First(&gw).Error; err != nil { - fmt.Printf("Gateway not found for MAC: %s\n", mac) + msg := fmt.Sprintf("Gateway not found for MAC: %s", mac) + slog.Error(msg) return - } + } if len(allowedZones) != 0 && !slices.Contains(allowedZones, gw.ID) { alert := model.Alert{ @@ -50,23 +62,28 @@ func LocationToBeaconService(msg model.HTTPLocation, db *gorm.DB, writer *kafka. eMsg, err := json.Marshal(alert) if err != nil { - fmt.Println("Error in marshaling") + msg := "Error in marshaling" + slog.Error(msg) + return } else { msg := kafka.Message{ Value: eMsg, } writer.WriteMessages(ctx, msg) + return } } // status, subject, subject name? if err := db.Create(&model.Tracks{UUID: msg.ID, Timestamp: time.Now(), Gateway: gw.ID, GatewayMac: gw.MAC, Tracker: msg.ID, Floor: gw.Floor, Building: gw.Building, TrackerMac: tracker.MAC, Signal: msg.RSSI}).Error; err != nil { - fmt.Println("Error in saving distance for beacon: ", err) + msg := fmt.Sprintf("Error in saving distance for beacon: %v", err) + slog.Error(msg) return } if err := db.Updates(&model.Tracker{ID: msg.ID, Location: gw.ID, Distance: msg.Distance, X: gw.X, Y: gw.Y}).Error; err != nil { - fmt.Println("Error in saving distance for beacon: ", err) + msg := fmt.Sprintf("Error in saving distance for beacon: %v", err) + slog.Error(msg) return } } diff --git a/internal/pkg/service/parser_service.go b/internal/pkg/service/parser_service.go index 2d65102..87fcea1 100644 --- a/internal/pkg/service/parser_service.go +++ b/internal/pkg/service/parser_service.go @@ -8,7 +8,7 @@ import ( "github.com/segmentio/kafka-go" ) -func SendParserConfig(kp model.KafkaParser, writer *kafka.Writer, ctx context.Context) error { +func SendParserConfig(kp model.KafkaParser, writer KafkaWriter, ctx context.Context) error { eMsg, err := json.Marshal(kp) if err != nil { return err @@ -17,7 +17,8 @@ func SendParserConfig(kp model.KafkaParser, writer *kafka.Writer, ctx context.Co Value: eMsg, } - writer.WriteMessages(ctx, msg) - + if err := writer.WriteMessages(ctx, msg); err != nil { + return err + } return nil } diff --git a/internal/structure.md b/internal/structure.md deleted file mode 100644 index b891109..0000000 --- a/internal/structure.md +++ /dev/null @@ -1,40 +0,0 @@ -internal/ -│ -├── pkg/ -│ ├── model/ # All data types, structs, constants -│ │ ├── beacons.go -│ │ ├── settings.go -│ │ ├── context.go # AppContext with locks and maps -│ │ └── types.go -│ │ -│ ├── httpserver/ # HTTP + WebSocket handlers -│ │ ├── routes.go # Registers all endpoints -│ │ ├── handlers.go # Core REST handlers -│ │ ├── websocket.go # WS logic (connections, broadcast) -│ │ └── server.go # StartHTTPServer() -│ │ -│ ├── mqtt/ # MQTT-specific logic -│ │ ├── processor.go # IncomingMQTTProcessor + helpers -│ │ ├── publisher.go # sendHARoomMessage, sendButtonMessage -│ │ └── filters.go # incomingBeaconFilter, distance helpers -│ │ -│ ├── persistence/ # BoltDB helpers -│ │ ├── load.go # LoadState, SaveState -│ │ ├── buckets.go # createBucketIfNotExists -│ │ └── persist_beacons.go -│ │ -│ ├── utils/ # Small utility helpers (time, logging, etc.) -│ │ ├── time.go -│ │ ├── logging.go -│ │ └── shell.go -│ │ -│ └── config/ # Default values, env vars, flags -│ └── config.go -│ -└── test/ - ├── httpserver_test/ - │ └── beacons_test.go - ├── mqtt_test/ - │ └── processor_test.go - └── persistence_test/ - └── load_test.go diff --git a/scripts/README.md b/scripts/README.md index 2f8a654..5249a67 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,11 +1,50 @@ -# `/scripts` +# Scripts -Scripts to perform various build, install, analysis, etc operations. +Organized by concern. Default server URL is `http://localhost:1902`; override with `BASE_URL`. -These scripts keep the root level Makefile small and simple. +## Layout -Examples: +| Directory | Purpose | +|-----------|--------| +| **api/** | Server API tests and examples | +| **config/** | Server/config operations (settings, parser configs) | +| **auth/** | Auth token (for remote/protected APIs) | +| **seed/** | Dev seed data (e.g. trackers) | -* https://github.com/kubernetes/helm/tree/master/scripts -* https://github.com/cockroachdb/cockroach/tree/master/scripts -* https://github.com/hashicorp/terraform/tree/master/scripts +## API (`api/`) + +- **smoke_test.sh** – Full smoke test: gateways, zones, trackerzones, trackers (list, update, delete). Requires `jq`. + ```bash + ./scripts/api/smoke_test.sh + BASE_URL=http://host:1902 ./scripts/api/smoke_test.sh + ``` +- **tracks.sh** – Tracks query examples (getTracks with limit, from, to). Optional first arg: tracker UUID. + ```bash + ./scripts/api/tracks.sh + ./scripts/api/tracks.sh + ``` + +## Config (`config/`) + +- **settings.sh** – PATCH `/reslevis/settings` (algorithm, thresholds, etc.). +- **add_parser.sh** – POST `/configs/beacons` to add a decoder/parser config (e.g. Eddystone). + +## Auth (`auth/`) + +- **token.sh** – Get OAuth token from auth server. Set env: `CLIENT_SECRET`, `USERNAME`, `PASSWORD`; optional `AUTH_URL`, `CLIENT_ID`, `AUDIENCE`. Prints token to stdout. + ```bash + export CLIENT_SECRET=... USERNAME=... PASSWORD=... + TOKEN=$(./scripts/auth/token.sh) + curl -H "Authorization: Bearer $TOKEN" "$BASE_URL/reslevis/getTrackers" + ``` + +## Seed (`seed/`) + +- **seed_trackers.sh** – POST multiple trackers for dev (same payloads as former bulk seed). + ```bash + ./scripts/seed/seed_trackers.sh + ``` + +## Shared + +- **_common.sh** – Sourced by other scripts; sets `BASE_URL` (default `http://localhost:1902`). Do not run directly. diff --git a/scripts/_common.sh b/scripts/_common.sh new file mode 100755 index 0000000..2c0bd4e --- /dev/null +++ b/scripts/_common.sh @@ -0,0 +1,2 @@ +# Shared defaults for API scripts. Source with: . "$(dirname "$0")/_common.sh" +BASE_URL="${BASE_URL:-http://localhost:1902}" diff --git a/scripts/adddecoder.sh b/scripts/adddecoder.sh deleted file mode 100755 index 6cd38d0..0000000 --- a/scripts/adddecoder.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -SERVER_URL="http://localhost:1902" - -curl -X POST "${SERVER_URL}/configs/beacons" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Eddystone", - "min": 4, - "max": 255, - "pattern": ["0x16", "0xAA", "0xFE", "0x20"], - "configs": { - "battery": {"offset": 6, "length": 2, "order": "bigendian"}, - "temperature": {"offset": 8, "length": 2, "order": "fixedpoint"} - } - }' diff --git a/scripts/api.sh b/scripts/api.sh deleted file mode 100644 index ad27641..0000000 --- a/scripts/api.sh +++ /dev/null @@ -1,246 +0,0 @@ -BASE_URL="http://localhost:1902" - -echo "==========================================" -echo "GATEWAY API TESTS" -echo "==========================================" - -echo "1. Listing all Gateways" -LIST=$(curl -s -X GET "$BASE_URL/reslevis/getGateways" | jq -c '.[]') -GATEWAY_IDS=() - -IFS=$'\n' -for r in $LIST -do - echo "$r" - GATEWAY_IDS+=($(echo "$r" | jq -r '.id')) -done - -sleep 1 - -if [ ${#GATEWAY_IDS[@]} -gt 1 ]; then - echo -e "\n\n2. Updating Gateway ${GATEWAY_IDS[1]}" - curl -X PUT "$BASE_URL/reslevis/updateGateway/${GATEWAY_IDS[1]}" \ - -H "Content-Type: application/json" \ - -d "{ - \"id\": \"${GATEWAY_IDS[1]}\", - \"name\": \"GU-100-Updated\", - \"mac\": \"AA:BB:CC:DD:EE:FF\", - \"status\": \"online\", - \"model\": \"MG3\", - \"ip\": \"127.0.0.1\", - \"position\": \"unknown\", - \"x\": 1, - \"y\": 1, - \"notes\": \"some description\", - \"floor\": \"second\", - \"building\": \"hospital\" - }" - - sleep 1 - - echo -e "\n\n3. Listing Gateways after update" - LIST=$(curl -s -X GET "$BASE_URL/reslevis/getGateways" | jq -c '.[]') - - IFS=$'\n' - for r in $LIST - do - echo "$r" - done - - sleep 1 - - echo -e "\n\n4. Deleting Gateway ${GATEWAY_IDS[1]}" - curl -X DELETE "$BASE_URL/reslevis/removeGateway/${GATEWAY_IDS[1]}" - - sleep 1 - - echo -e "\n\n5. Verifying Delete (List again)..." - LIST=$(curl -s -X GET "$BASE_URL/reslevis/getGateways" | jq -c '.[]') - - IFS=$'\n' - for r in $LIST - do - echo "$r" - done -else - echo "Not enough gateways to test update/delete" -fi - - -echo -e "\n\n==========================================" -echo "ZONE API TESTS" -echo "==========================================" - -echo "6. Listing all Zones" -LIST=$(curl -s -X GET "$BASE_URL/reslevis/getZones" | jq -c '.[]') -ZONE_IDS=() - -IFS=$'\n' -for r in $LIST -do - echo "$r" - ZONE_IDS+=($(echo "$r" | jq -r '.id')) -done - -sleep 1 - -if [ ${#ZONE_IDS[@]} -gt 0 ]; then - echo -e "\n\n7. Updating Zone ${ZONE_IDS[0]}" - curl -X PUT "$BASE_URL/reslevis/updateZone" \ - -H "Content-Type: application/json" \ - -d "{ - \"id\": \"${ZONE_IDS[0]}\", - \"name\": \"Zone-Updated\", - \"groups\": [\"security\", \"logistics\"] - }" - - sleep 1 - - echo -e "\n\n8. Listing Zones after update" - LIST=$(curl -s -X GET "$BASE_URL/reslevis/getZones" | jq -c '.[]') - - IFS=$'\n' - for r in $LIST - do - echo "$r" - done - - sleep 1 - - echo -e "\n\n9. Deleting Zone ${ZONE_IDS[0]}" - curl -X DELETE "$BASE_URL/reslevis/removeZone/${ZONE_IDS[0]}" - - sleep 1 - - echo -e "\n\n10. Verifying Delete (List again)..." - LIST=$(curl -s -X GET "$BASE_URL/reslevis/getZones" | jq -c '.[]') - - IFS=$'\n' - for r in $LIST - do - echo "$r" - done -else - echo "No zones to test update/delete" -fi - - -echo -e "\n\n==========================================" -echo "TRACKERZONE API TESTS" -echo "==========================================" - -echo "11. Listing all TrackerZones" -LIST=$(curl -s -X GET "$BASE_URL/reslevis/getTrackerZones" | jq -c '.[]') -TRACKERZONE_IDS=() - -IFS=$'\n' -for r in $LIST -do - echo "$r" - TRACKERZONE_IDS+=($(echo "$r" | jq -r '.id')) -done - -sleep 1 - -if [ ${#TRACKERZONE_IDS[@]} -gt 0 ]; then - echo -e "\n\n12. Updating TrackerZone ${TRACKERZONE_IDS[0]}" - curl -X PUT "$BASE_URL/reslevis/updateTrackerZone" \ - -H "Content-Type: application/json" \ - -d "{ - \"id\": \"${TRACKERZONE_IDS[0]}\", - \"name\": \"TrackerZone-Updated\" - }" - - sleep 1 - - echo -e "\n\n13. Listing TrackerZones after update" - LIST=$(curl -s -X GET "$BASE_URL/reslevis/getTrackerZones" | jq -c '.[]') - - IFS=$'\n' - for r in $LIST - do - echo "$r" - done - - sleep 1 - - echo -e "\n\n14. Deleting TrackerZone ${TRACKERZONE_IDS[0]}" - curl -X DELETE "$BASE_URL/reslevis/removeTrackerZone/${TRACKERZONE_IDS[0]}" - - sleep 1 - - echo -e "\n\n15. Verifying Delete (List again)..." - LIST=$(curl -s -X GET "$BASE_URL/reslevis/getTrackerZones" | jq -c '.[]') - - IFS=$'\n' - for r in $LIST - do - echo "$r" - done -else - echo "No trackerzones to test update/delete" -fi - - -echo -e "\n\n==========================================" -echo "TRACKER API TESTS" -echo "==========================================" - -echo "16. Listing all Trackers" -LIST=$(curl -s -X GET "$BASE_URL/reslevis/getTrackers" | jq -c '.[]') -TRACKER_IDS=() - -IFS=$'\n' -for r in $LIST -do - echo "$r" - TRACKER_IDS+=($(echo "$r" | jq -r '.id')) -done - -sleep 1 - -if [ ${#TRACKER_IDS[@]} -gt 0 ]; then - echo -e "\n\n17. Updating Tracker ${TRACKER_IDS[0]}" - curl -X PUT "$BASE_URL/reslevis/updateTracker" \ - -H "Content-Type: application/json" \ - -d "{ - \"id\": \"${TRACKER_IDS[0]}\", - \"name\": \"Tracker-Updated\", - \"battery\": 85, - \"status\": \"inactive\" - }" - - sleep 1 - - echo -e "\n\n18. Listing Trackers after update" - LIST=$(curl -s -X GET "$BASE_URL/reslevis/getTrackers" | jq -c '.[]') - - IFS=$'\n' - for r in $LIST - do - echo "$r" - done - - sleep 1 - - echo -e "\n\n19. Deleting Tracker ${TRACKER_IDS[0]}" - curl -X DELETE "$BASE_URL/reslevis/removeTracker/${TRACKER_IDS[0]}" - - sleep 1 - - echo -e "\n\n20. Verifying Delete (List again)..." - LIST=$(curl -s -X GET "$BASE_URL/reslevis/getTrackers" | jq -c '.[]') - - IFS=$'\n' - for r in $LIST - do - echo "$r" - done -else - echo "No trackers to test update/delete" -fi - - -echo -e "\n\n==========================================" -echo "ALL TESTS COMPLETED" -echo "==========================================" \ No newline at end of file diff --git a/scripts/api/smoke_test.sh b/scripts/api/smoke_test.sh new file mode 100755 index 0000000..0bcbbe3 --- /dev/null +++ b/scripts/api/smoke_test.sh @@ -0,0 +1,129 @@ +#!/bin/bash +# Full API smoke test: gateways, zones, trackerzones, trackers (list/update/delete). +# Usage: ./api/smoke_test.sh or BASE_URL=http://host:port ./api/smoke_test.sh +set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "${SCRIPT_DIR}/../_common.sh" + +echo "==========================================" +echo "GATEWAY API TESTS" +echo "==========================================" + +echo "1. Listing all Gateways" +LIST=$(curl -s -X GET "$BASE_URL/reslevis/getGateways" | jq -c '.[]') +GATEWAY_IDS=() +IFS=$'\n' +for r in $LIST; do + echo "$r" + GATEWAY_IDS+=($(echo "$r" | jq -r '.id')) +done +sleep 1 + +if [ ${#GATEWAY_IDS[@]} -gt 1 ]; then + echo -e "\n\n2. Updating Gateway ${GATEWAY_IDS[1]}" + curl -s -X PUT "$BASE_URL/reslevis/updateGateway/${GATEWAY_IDS[1]}" \ + -H "Content-Type: application/json" \ + -d "{\"id\": \"${GATEWAY_IDS[1]}\", \"name\": \"GU-100-Updated\", \"mac\": \"AA:BB:CC:DD:EE:FF\", \"status\": \"online\", \"model\": \"MG3\", \"ip\": \"127.0.0.1\", \"position\": \"unknown\", \"x\": 1, \"y\": 1, \"notes\": \"some description\", \"floor\": \"second\", \"building\": \"hospital\"}" + sleep 1 + echo -e "\n\n3. Listing Gateways after update" + curl -s -X GET "$BASE_URL/reslevis/getGateways" | jq -c '.[]' + sleep 1 + echo -e "\n\n4. Deleting Gateway ${GATEWAY_IDS[1]}" + curl -s -X DELETE "$BASE_URL/reslevis/removeGateway/${GATEWAY_IDS[1]}" + sleep 1 + echo -e "\n\n5. Verifying Delete (List again)..." + curl -s -X GET "$BASE_URL/reslevis/getGateways" | jq -c '.[]' +else + echo "Not enough gateways to test update/delete" +fi + +echo -e "\n\n==========================================" +echo "ZONE API TESTS" +echo "==========================================" +echo "6. Listing all Zones" +LIST=$(curl -s -X GET "$BASE_URL/reslevis/getZones" | jq -c '.[]') +ZONE_IDS=() +for r in $LIST; do + echo "$r" + ZONE_IDS+=($(echo "$r" | jq -r '.id')) +done +sleep 1 + +if [ ${#ZONE_IDS[@]} -gt 0 ]; then + echo -e "\n\n7. Updating Zone ${ZONE_IDS[0]}" + curl -s -X PUT "$BASE_URL/reslevis/updateZone" -H "Content-Type: application/json" \ + -d "{\"id\": \"${ZONE_IDS[0]}\", \"name\": \"Zone-Updated\", \"groups\": [\"security\", \"logistics\"]}" + sleep 1 + echo -e "\n\n8. Listing Zones after update" + curl -s -X GET "$BASE_URL/reslevis/getZones" | jq -c '.[]' + sleep 1 + echo -e "\n\n9. Deleting Zone ${ZONE_IDS[0]}" + curl -s -X DELETE "$BASE_URL/reslevis/removeZone/${ZONE_IDS[0]}" + sleep 1 + echo -e "\n\n10. Verifying Delete..." + curl -s -X GET "$BASE_URL/reslevis/getZones" | jq -c '.[]' +else + echo "No zones to test update/delete" +fi + +echo -e "\n\n==========================================" +echo "TRACKERZONE API TESTS" +echo "==========================================" +echo "11. Listing all TrackerZones" +LIST=$(curl -s -X GET "$BASE_URL/reslevis/getTrackerZones" | jq -c '.[]') +TRACKERZONE_IDS=() +for r in $LIST; do + echo "$r" + TRACKERZONE_IDS+=($(echo "$r" | jq -r '.id')) +done +sleep 1 + +if [ ${#TRACKERZONE_IDS[@]} -gt 0 ]; then + echo -e "\n\n12. Updating TrackerZone ${TRACKERZONE_IDS[0]}" + curl -s -X PUT "$BASE_URL/reslevis/updateTrackerZone" -H "Content-Type: application/json" \ + -d "{\"id\": \"${TRACKERZONE_IDS[0]}\", \"name\": \"TrackerZone-Updated\"}" + sleep 1 + echo -e "\n\n13. Listing TrackerZones after update" + curl -s -X GET "$BASE_URL/reslevis/getTrackerZones" | jq -c '.[]' + sleep 1 + echo -e "\n\n14. Deleting TrackerZone ${TRACKERZONE_IDS[0]}" + curl -s -X DELETE "$BASE_URL/reslevis/removeTrackerZone/${TRACKERZONE_IDS[0]}" + sleep 1 + echo -e "\n\n15. Verifying Delete..." + curl -s -X GET "$BASE_URL/reslevis/getTrackerZones" | jq -c '.[]' +else + echo "No trackerzones to test update/delete" +fi + +echo -e "\n\n==========================================" +echo "TRACKER API TESTS" +echo "==========================================" +echo "16. Listing all Trackers" +LIST=$(curl -s -X GET "$BASE_URL/reslevis/getTrackers" | jq -c '.[]') +TRACKER_IDS=() +for r in $LIST; do + echo "$r" + TRACKER_IDS+=($(echo "$r" | jq -r '.id')) +done +sleep 1 + +if [ ${#TRACKER_IDS[@]} -gt 0 ]; then + echo -e "\n\n17. Updating Tracker ${TRACKER_IDS[0]}" + curl -s -X PUT "$BASE_URL/reslevis/updateTracker" -H "Content-Type: application/json" \ + -d "{\"id\": \"${TRACKER_IDS[0]}\", \"name\": \"Tracker-Updated\", \"battery\": 85, \"status\": \"inactive\"}" + sleep 1 + echo -e "\n\n18. Listing Trackers after update" + curl -s -X GET "$BASE_URL/reslevis/getTrackers" | jq -c '.[]' + sleep 1 + echo -e "\n\n19. Deleting Tracker ${TRACKER_IDS[0]}" + curl -s -X DELETE "$BASE_URL/reslevis/removeTracker/${TRACKER_IDS[0]}" + sleep 1 + echo -e "\n\n20. Verifying Delete..." + curl -s -X GET "$BASE_URL/reslevis/getTrackers" | jq -c '.[]' +else + echo "No trackers to test update/delete" +fi + +echo -e "\n\n==========================================" +echo "ALL TESTS COMPLETED" +echo "==========================================" diff --git a/scripts/api/tracks.sh b/scripts/api/tracks.sh new file mode 100755 index 0000000..6460c06 --- /dev/null +++ b/scripts/api/tracks.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Tracks API query examples (getTracks with limit, from, to). +# Usage: ./api/tracks.sh [TRACKER_UUID] +set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "${SCRIPT_DIR}/../_common.sh" + +TRACKER_UUID="${1:-1a6c6f1e-9a3d-4a66-9f0b-6d5f0e1c1a01}" + +echo "===================================" +echo "Tracks API Query Examples" +echo "===================================" +echo "" + +echo "1. Basic query (default: last 10 tracks from last 24 hours):" +echo "GET /reslevis/getTracks/${TRACKER_UUID}" +curl -s -X GET "${BASE_URL}/reslevis/getTracks/${TRACKER_UUID}" | jq '.' +echo -e "\n" + +echo "2. Get last 50 tracks:" +curl -s -X GET "${BASE_URL}/reslevis/getTracks/${TRACKER_UUID}?limit=50" | jq '.' +echo -e "\n" + +echo "3. Get tracks with date range (from/to in RFC3339):" +TO_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) +FROM_DATE=$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-7d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "2020-01-01T00:00:00Z") +curl -s -X GET "${BASE_URL}/reslevis/getTracks/${TRACKER_UUID}?from=${FROM_DATE}&to=${TO_DATE}&limit=20" | jq '.' +echo -e "\n" + +echo "===================================" +echo "Query Parameters: limit, from (RFC3339), to (RFC3339)" +echo "Get tracker UUIDs from: GET /reslevis/getTrackers" +echo "===================================" diff --git a/scripts/auth/token.sh b/scripts/auth/token.sh new file mode 100755 index 0000000..2e15849 --- /dev/null +++ b/scripts/auth/token.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Get OAuth token from auth server. Set AUTH_URL and form data for your environment. +# Usage: ./auth/token.sh (prints token to stdout) +# Example with token: TOKEN=$(./scripts/auth/token.sh) && curl -H "Authorization: Bearer $TOKEN" "$BASE_URL/reslevis/getTrackers" +AUTH_URL="${AUTH_URL:-https://10.251.0.30:10002/realms/API.Server.local/protocol/openid-connect/token}" + +TOKEN=$(curl -k -s -X POST "$AUTH_URL" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=password" \ + -d "client_id=${CLIENT_ID:-Fastapi}" \ + -d "client_secret=${CLIENT_SECRET}" \ + -d "username=${USERNAME}" \ + -d "password=${PASSWORD}" \ + -d "audience=${AUDIENCE:-Fastapi}" \ + | jq -r '.access_token') + +if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "Failed to get token. Set CLIENT_SECRET, USERNAME, PASSWORD (and optionally AUTH_URL, CLIENT_ID, AUDIENCE)." >&2 + exit 1 +fi +echo "$TOKEN" diff --git a/scripts/config/add_parser.sh b/scripts/config/add_parser.sh new file mode 100755 index 0000000..57be0ab --- /dev/null +++ b/scripts/config/add_parser.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Add a decoder/parser config (POST /configs/beacons). +# Usage: ./config/add_parser.sh or BASE_URL=http://host:port ./config/add_parser.sh +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "${SCRIPT_DIR}/../_common.sh" + +curl -s -X POST "${BASE_URL}/configs/beacons" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Eddystone", + "min": 4, + "max": 255, + "pattern": ["0x16", "0xAA", "0xFE", "0x20"], + "configs": { + "battery": {"offset": 6, "length": 2, "order": "bigendian"}, + "temperature": {"offset": 8, "length": 2, "order": "fixedpoint"} + } + }' +echo "" diff --git a/scripts/config/settings.sh b/scripts/config/settings.sh new file mode 100755 index 0000000..d086e75 --- /dev/null +++ b/scripts/config/settings.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# PATCH server settings (algorithm, thresholds, etc.). +# Usage: ./config/settings.sh or BASE_URL=http://host:port ./config/settings.sh +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "${SCRIPT_DIR}/../_common.sh" + +curl -s -X PATCH "${BASE_URL}/reslevis/settings" \ + -H "Content-Type: application/json" \ + -d '{ + "current_algorithm": "filter", + "last_seen_threshold": 310, + "beacon_metric_size": 100, + "HA_send_interval": 60, + "HA_send_changes_only": true, + "RSSI_enforce_threshold": false, + "RSSI_min_threshold": -80 + }' +echo "" diff --git a/scripts/gatewayApi.sh b/scripts/gatewayApi.sh deleted file mode 100755 index 451f83f..0000000 --- a/scripts/gatewayApi.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/bash -BASE_URL="http://localhost:1902" # Change to your port - -echo "1. Adding a Gateway..." -curl -X POST "$BASE_URL/reslevis/postGateway" \ - -H "Content-Type: application/json" \ - -d '{ - "id": "gw_01", - "name": "Front Entrance", - "mac": "AA:BB:CC:DD:EE:FF", - "status": "online", - "building": "Main HQ" - }' - -sleep 1 - -echo -e "\n\n2. Listing Gateways..." -curl -X GET "$BASE_URL/reslevis/getGateways" - -sleep 1 - -echo -e "\n\n2. Updating Gateway..." -curl -X PUT "$BASE_URL/reslevis/updateGateway/gw_01" \ - -H "Content-Type: application/json" \ - -d '{ - "id": "gw_01", - "name": "Front Entrance", - "mac": "AA:BB:CC:DD:EE:FF", - "status": "online", - "building": "Pisarna HQ" - }' - -sleep 1 - -echo -e "\n\n2. Listing Gateways..." -curl -X GET "$BASE_URL/reslevis/getGateways" - -sleep 1 - -echo -e "\n\n3. Deleting Gateway..." -curl -X DELETE "$BASE_URL/reslevis/removeGateway/gw_01" - -sleep 1 - -echo -e "\n\n4. Verifying Delete (List again)..." -curl -X GET "$BASE_URL/reslevis/getGateways" \ No newline at end of file diff --git a/scripts/seed/seed_trackers.sh b/scripts/seed/seed_trackers.sh new file mode 100755 index 0000000..e1d955e --- /dev/null +++ b/scripts/seed/seed_trackers.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Seed dev trackers via POST /reslevis/postTracker. Override BASE_URL if needed. +# Usage: ./seed/seed_trackers.sh +set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "${SCRIPT_DIR}/../_common.sh" + +post_one() { + local id name mac model + id="$1" name="$2" mac="$3" model="${4:-B7}" + echo "Adding tracker $mac ($name)..." + curl -s -X POST "$BASE_URL/reslevis/postTracker" \ + -H "Content-Type: application/json" \ + -d "{ + \"id\": \"$id\", + \"name\": \"$name\", + \"mac\": \"$mac\", + \"status\": \"1\", + \"model\": \"$model\", + \"position\": \"\", + \"notes\": \"\", + \"x\": 0, + \"y\": 0, + \"floor\": null, + \"building\": null + }" + echo "" + sleep 1 +} + +echo "Seeding trackers..." +post_one "a3c1b2e4-9f73-4c1f-8c87-52e4d9cf9a01" "INGICS-TASTO" "C83F8F17DB35" "MNBT01G" +post_one "d91a7b4f-02f6-44b6-9fa0-ff6df1c2e7b3" "RUSSI" "C300003947DF" "B7" +post_one "5f1a9c3d-4b6f-4f88-9c92-df5c2d37c2aa" "PETRELLA" "C300003B1E20" "MWC01" +post_one "8b7d42e9-4db5-4f42-a6c1-4e9f0c3e7d12" "AMOROSA-S" "C300003946B5" "MWB01" +post_one "1e93b3fd-7d67-4a53-9c7a-0f0a8e7e41c6" "GALLO" "C300003946AC" "MWB01" +post_one "e2b9d6cc-7d89-46bb-9e45-2b7f71e4a4d0" "SMISEK" "C300003946B1" "MWB01" +post_one "6cfdeab2-03c4-41d7-9c1d-5f7bcb8c0b6b" "ROMAGNUOLO" "C300003B1E21" "MWC01" +post_one "fa73b6dd-9941-4d25-8a9a-8df3b09a9d77" "BC-43" "C300003947C4" "B7" +post_one "9c55d03e-2db1-4b0a-b1ac-8b60f60e712d" "AMOROSA-F" "C300003947E2" "B7" +post_one "2a00e3b4-4a12-4f70-a4c4-408a1779e251" "DINONNO" "C300003B1E1F" "MWC01" +post_one "bf6d6c84-5e1a-4b83-a10f-0e9cf2a198c3" "ismarch-X6" "C7AE561E38B7" "B7" +post_one "41c4c6b2-9c3d-48d6-aea6-7c1bcfdfb2b7" "ismarch-C2" "E01F9A7A47D2" "B7" + +echo "Listing all trackers..." +curl -s -X GET "$BASE_URL/reslevis/getTrackers" | jq '.' +echo "Done." diff --git a/scripts/settingsApi.sh b/scripts/settingsApi.sh deleted file mode 100755 index 58a25bf..0000000 --- a/scripts/settingsApi.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -curl -X PATCH "http://localhost:1902/reslevis/settings" \ - -H "Content-Type: application/json" \ - -d '{ - "current_algorithm": "ai", - "last_seen_threshold": 310, - "beacon_metric_size": 100, - "HA_send_interval": 60, - "HA_send_changes_only": true, - "RSSI_enforce_threshold": false, - "RSSI_min_threshold": -80 - }' diff --git a/scripts/testAPI.sh b/scripts/testAPI.sh deleted file mode 100755 index a6a03b0..0000000 --- a/scripts/testAPI.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash -URL="http://127.0.0.1:1902/api/beacons" -BEACON_ID="C83F8F17DB35" - -echo "POST (create)" -curl -s -X POST "http://127.0.0.1:1902/api/addbeacons" \ - -H "Content-Type: application/json" \ - -d '[{"id":"a3c1b2e4-9f73-4c1f-8c87-52e4d9cf9a01","name":"INGICS-TASTO","mac":"C83F8F17DB35","status":"1","model":"MNBT01G","position":"","notes":"","x":0.0,"y":0.0,"zone":null,"building":null},{"id":"d91a7b4f-02f6-44b6-9fa0-ff6df1c2e7b3","name":"RUSSI","mac":"C300003947DF","status":"1","model":"B7","position":"","notes":"","x":0.0,"y":0.0,"zone":null,"building":null},{"id":"5f1a9c3d-4b6f-4f88-9c92-df5c2d37c2aa","name":"PETRELLA","mac":"C300003B1E20","status":"1","model":"MWC01","position":"","notes":"","x":0.0,"y":0.0,"zone":null,"building":null},{"id":"8b7d42e9-4db5-4f42-a6c1-4e9f0c3e7d12","name":"AMOROSA-S","mac":"C300003946B5","status":"1","model":"MWB01","position":"","notes":"","x":0.0,"y":0.0,"zone":null,"building":null},{"id":"1e93b3fd-7d67-4a53-9c7a-0f0a8e7e41c6","name":"GALLO","mac":"C300003946AC","status":"1","model":"MWB01","position":"","notes":"","x":0.0,"y":0.0,"zone":null,"building":null},{"id":"e2b9d6cc-7d89-46bb-9e45-2b7f71e4a4d0","name":"SMISEK","mac":"C300003946B1","status":"1","model":"MWB01","position":"","notes":"","x":0.0,"y":0.0,"zone":null,"building":null},{"id":"6cfdeab2-03c4-41d7-9c1d-5f7bcb8c0b6b","name":"ROMAGNUOLO","mac":"C300003B1E21","status":"1","model":"MWC01","position":"","notes":"","x":0.0,"y":0.0,"zone":null,"building":null},{"id":"fa73b6dd-9941-4d25-8a9a-8df3b09a9d77","name":"BC-43","mac":"C300003947C4","status":"1","model":"B7","position":"","notes":"","x":0.0,"y":0.0,"zone":null,"building":null},{"id":"9c55d03e-2db1-4b0a-b1ac-8b60f60e712d","name":"AMOROSA-F","mac":"C300003947E2","status":"1","model":"B7","position":"","notes":"","x":0.0,"y":0.0,"zone":null,"building":null},{"id":"2a00e3b4-4a12-4f70-a4c4-408a1779e251","name":"DINONNO","mac":"C300003B1E1F","status":"1","model":"MWC01","position":"","notes":"","x":0.0,"y":0.0,"zone":null,"building":null},{"id":"bf6d6c84-5e1a-4b83-a10f-0e9cf2a198c3","name":"ismarch-X6","mac":"C7AE561E38B7","status":"1","model":"B7","position":"","notes":"","x":0.0,"y":0.0,"zone":null,"building":null},{"id":"41c4c6b2-9c3d-48d6-aea6-7c1bcfdfb2b7","name":"ismarch-C2","mac":"E01F9A7A47D2","status":"1","model":"B7","position":"","notes":"","x":0.0,"y":0.0,"zone":null,"building":null}]' -echo -e "\n" - -sleep 1 - -curl -X GET $URL - -sleep 1 - -curl -X GET "http://127.0.0.1:1902/api/beaconids" - -sleep 1 \ No newline at end of file diff --git a/scripts/testalltrackers.sh b/scripts/testalltrackers.sh deleted file mode 100755 index 331e1fb..0000000 --- a/scripts/testalltrackers.sh +++ /dev/null @@ -1,248 +0,0 @@ -#!/bin/bash -BASE_URL="http://localhost:1902" - -echo "Adding all trackers individually..." - -echo "1. Adding tracker C83F8F17DB35..." -curl -s -X POST "$BASE_URL/reslevis/postTracker" \ - -H "Content-Type: application/json" \ - -d '{ - "id": "a3c1b2e4-9f73-4c1f-8c87-52e4d9cf9a01", - "name": "INGICS-TASTO", - "mac": "C83F8F17DB35", - "status": "1", - "model": "MNBT01G", - "position": "", - "notes": "", - "x": 0, - "y": 0, - "floor": null, - "building": null - }' -echo -e "\n" - -sleep 1 - -echo "2. Adding tracker C300003947DF..." -curl -s -X POST "$BASE_URL/reslevis/postTracker" \ - -H "Content-Type: application/json" \ - -d '{ - "id": "d91a7b4f-02f6-44b6-9fa0-ff6df1c2e7b3", - "name": "RUSSI", - "mac": "C300003947DF", - "status": "1", - "model": "B7", - "position": "", - "notes": "", - "x": 0, - "y": 0, - "floor": null, - "building": null - }' -echo -e "\n" - -sleep 1 - -echo "3. Adding tracker C300003B1E20..." -curl -s -X POST "$BASE_URL/reslevis/postTracker" \ - -H "Content-Type: application/json" \ - -d '{ - "id": "5f1a9c3d-4b6f-4f88-9c92-df5c2d37c2aa", - "name": "PETRELLA", - "mac": "C300003B1E20", - "status": "1", - "model": "MWC01", - "position": "", - "notes": "", - "x": 0, - "y": 0, - "floor": null, - "building": null - }' -echo -e "\n" - -sleep 1 - -echo "4. Adding tracker C300003946B5..." -curl -s -X POST "$BASE_URL/reslevis/postTracker" \ - -H "Content-Type: application/json" \ - -d '{ - "id": "8b7d42e9-4db5-4f42-a6c1-4e9f0c3e7d12", - "name": "AMOROSA-S", - "mac": "C300003946B5", - "status": "1", - "model": "MWB01", - "position": "", - "notes": "", - "x": 0, - "y": 0, - "floor": null, - "building": null - }' -echo -e "\n" - -sleep 1 - -echo "5. Adding tracker C300003946AC..." -curl -s -X POST "$BASE_URL/reslevis/postTracker" \ - -H "Content-Type: application/json" \ - -d '{ - "id": "1e93b3fd-7d67-4a53-9c7a-0f0a8e7e41c6", - "name": "GALLO", - "mac": "C300003946AC", - "status": "1", - "model": "MWB01", - "position": "", - "notes": "", - "x": 0, - "y": 0, - "floor": null, - "building": null - }' -echo -e "\n" - -sleep 1 - -echo "6. Adding tracker C300003946B1..." -curl -s -X POST "$BASE_URL/reslevis/postTracker" \ - -H "Content-Type: application/json" \ - -d '{ - "id": "e2b9d6cc-7d89-46bb-9e45-2b7f71e4a4d0", - "name": "SMISEK", - "mac": "C300003946B1", - "status": "1", - "model": "MWB01", - "position": "", - "notes": "", - "x": 0, - "y": 0, - "floor": null, - "building": null - }' -echo -e "\n" - -sleep 1 - -echo "7. Adding tracker C300003B1E21..." -curl -s -X POST "$BASE_URL/reslevis/postTracker" \ - -H "Content-Type: application/json" \ - -d '{ - "id": "6cfdeab2-03c4-41d7-9c1d-5f7bcb8c0b6b", - "name": "ROMAGNUOLO", - "mac": "C300003B1E21", - "status": "1", - "model": "MWC01", - "position": "", - "notes": "", - "x": 0, - "y": 0, - "floor": null, - "building": null - }' -echo -e "\n" - -sleep 1 - -echo "8. Adding tracker C300003947C4..." -curl -s -X POST "$BASE_URL/reslevis/postTracker" \ - -H "Content-Type: application/json" \ - -d '{ - "id": "fa73b6dd-9941-4d25-8a9a-8df3b09a9d77", - "name": "BC-43", - "mac": "C300003947C4", - "status": "1", - "model": "B7", - "position": "", - "notes": "", - "x": 0, - "y": 0, - "floor": null, - "building": null - }' -echo -e "\n" - -sleep 1 - -echo "9. Adding tracker C300003947E2..." -curl -s -X POST "$BASE_URL/reslevis/postTracker" \ - -H "Content-Type: application/json" \ - -d '{ - "id": "9c55d03e-2db1-4b0a-b1ac-8b60f60e712d", - "name": "AMOROSA-F", - "mac": "C300003947E2", - "status": "1", - "model": "B7", - "position": "", - "notes": "", - "x": 0, - "y": 0, - "floor": null, - "building": null - }' -echo -e "\n" - -sleep 1 - -echo "10. Adding tracker C300003B1E1F..." -curl -s -X POST "$BASE_URL/reslevis/postTracker" \ - -H "Content-Type: application/json" \ - -d '{ - "id": "2a00e3b4-4a12-4f70-a4c4-408a1779e251", - "name": "DINONNO", - "mac": "C300003B1E1F", - "status": "1", - "model": "MWC01", - "position": "", - "notes": "", - "x": 0, - "y": 0, - "floor": null, - "building": null - }' -echo -e "\n" - -sleep 1 - -echo "11. Adding tracker C7AE561E38B7..." -curl -s -X POST "$BASE_URL/reslevis/postTracker" \ - -H "Content-Type: application/json" \ - -d '{ - "id": "bf6d6c84-5e1a-4b83-a10f-0e9cf2a198c3", - "name": "ismarch-X6", - "mac": "C7AE561E38B7", - "status": "1", - "model": "B7", - "position": "", - "notes": "", - "x": 0, - "y": 0, - "floor": null, - "building": null - }' -echo -e "\n" - -sleep 1 - -echo "12. Adding tracker E01F9A7A47D2..." -curl -s -X POST "$BASE_URL/reslevis/postTracker" \ - -H "Content-Type: application/json" \ - -d '{ - "id": "41c4c6b2-9c3d-48d6-aea6-7c1bcfdfb2b7", - "name": "ismarch-C2", - "mac": "E01F9A7A47D2", - "status": "1", - "model": "B7", - "position": "", - "notes": "", - "x": 0, - "y": 0, - "floor": null, - "building": null - }' -echo -e "\n" - -sleep 1 - -echo "All trackers added! Listing all trackers..." -curl -X GET "$BASE_URL/reslevis/getTrackers" -echo -e "\n" \ No newline at end of file diff --git a/scripts/token.sh b/scripts/token.sh deleted file mode 100755 index bfdd3a4..0000000 --- a/scripts/token.sh +++ /dev/null @@ -1,15 +0,0 @@ -TOKEN=$( - curl -k -s -X POST "https://10.251.0.30:10002/realms/API.Server.local/protocol/openid-connect/token" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=password" \ - -d "client_id=Fastapi" \ - -d "client_secret=wojuoB7Z5xhlPFrF2lIxJSSdVHCApEgC" \ - -d "username=core" \ - -d "password=C0r3_us3r_Cr3d3nt14ls" \ - -d "audience=Fastapi" \ - | jq -r '.access_token' -) - -curl -k -s -X GET "https://10.251.0.30:5050/reslevis/getTrackers" \ - -H "accept: application/json" \ - -H "Authorization: Bearer ${TOKEN}" \ No newline at end of file diff --git a/scripts/trackerApi.sh b/scripts/trackerApi.sh deleted file mode 100755 index 9f19cca..0000000 --- a/scripts/trackerApi.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/bash -BASE_URL="http://localhost:1902" # Change to your port - -echo "1. Adding a Tracker..." -curl -X POST "$BASE_URL/reslevis/postTracker" \ - -H "Content-Type: application/json" \ - -d '{ - "id": "tracker_01", - "name": "Employee Badge #001", - "mac": "11:22:33:44:55:66", - "status": "active", - "model": "BLE Beacon v2", - "position": "Office A-101", - "notes": "Primary employee tracker", - "x": 150, - "y": 200, - "floor": "550e8400-e29b-41d4-a716-446655440000", - "building": "550e8400-e29b-41d4-a716-446655440001" - }' - -sleep 1 - -echo -e "\n\n2. Listing Trackers..." -curl -X GET "$BASE_URL/reslevis/getTrackers" - -sleep 1 - -echo -e "\n\n3. Updating Tracker..." -curl -X PUT "$BASE_URL/reslevis/updateTracker" \ - -H "Content-Type: application/json" \ - -d '{ - "id": "tracker_01", - "name": "Employee Badge #001 - Updated", - "mac": "11:22:33:44:55:66", - "status": "inactive", - "model": "BLE Beacon v2", - "position": "Office B-205", - "notes": "Updated position and status", - "x": 300, - "y": 400, - "floor": "550e8400-e29b-41d4-a716-446655440002", - "building": "550e8400-e29b-41d4-a716-446655440001" - }' - -sleep 1 - -echo -e "\n\n4. Listing Trackers after update..." -curl -X GET "$BASE_URL/reslevis/getTrackers" - -sleep 1 - -echo -e "\n\n5. Deleting Tracker..." -curl -X DELETE "$BASE_URL/reslevis/removeTracker/tracker_01" - -sleep 1 - -echo -e "\n\n6. Verifying Delete (List again)..." -curl -X GET "$BASE_URL/reslevis/getTrackers" \ No newline at end of file diff --git a/scripts/trackerzonesApi.sh b/scripts/trackerzonesApi.sh deleted file mode 100755 index b8c6f7c..0000000 --- a/scripts/trackerzonesApi.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/bash -BASE_URL="http://localhost:1902" - -echo "1. Adding Tracker Zone Mapping..." -curl -X POST "$BASE_URL/reslevis/postTrackerZone" \ --H "Content-Type: application/json" \ --d '{ - "id": "b6b2a2e4-58b3-4aa4-8d6a-4b55a2c5b2d2", - "zoneList": ["0c7b9c7f-6d0f-4d4e-9e4a-2c9f2b1d6a11","1d2e3f40-1111-2222-3333-444455556666"], - "tracker": "1e93b3fd-7d67-4a53-9c7a-0f0a8e7e41c6", - "days": "All,Mon,Tue,Wed,Thu,Fri", - "time": "09:00-17:00" -}' - -sleep 1 - -echo -e "\n\n2. Listing Trackers..." -curl -X GET "$BASE_URL/reslevis/getTrackerZones" - -# sleep 1 - -# echo "Updating Tracker Zone List and Time..." -# curl -X PUT "$BASE_URL/reslevis/updateTrackerZone" \ -# -H "Content-Type: application/json" \ -# -d '{ -# "id": "tz_001", -# "zoneList": ["zone_C"], -# "tracker": "TAG_55", -# "days": "Sat-Sun", -# "time": "10:00-14:00" -# }' - -# sleep 1 - -# echo -e "\n\n2. Listing Trackers..." -# curl -X GET "$BASE_URL/reslevis/getTrackerZones" - -# sleep 1 - -# echo -e "\n\n3. Deleting Tracker Mapping..." -# curl -X DELETE "$BASE_URL/reslevis/removeTrackerZone/tz_001" - -# sleep 1 - -# echo -e "\n\n2. Listing Trackers..." -# curl -X GET "$BASE_URL/reslevis/getTrackerZones" \ No newline at end of file diff --git a/scripts/tracks.sh b/scripts/tracks.sh deleted file mode 100755 index 32c8b60..0000000 --- a/scripts/tracks.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/bin/bash - -# Server URL -SERVER_URL="http://localhost:1902" -TRACKER_UUID="1a6c6f1e-9a3d-4a66-9f0b-6d5f0e1c1a01" -echo "===================================" -echo "Tracks API Query Examples" -echo "===================================" -echo "" - -echo "1. Basic query (default: last 10 tracks from last 24 hours):" -echo "GET /reslevis/getTracks/${TRACKER_UUID}" -curl -s -X GET "${SERVER_URL}/reslevis/getTracks/${TRACKER_UUID}" | jq '.' -echo -e "\n" - -echo "2. Get last 50 tracks:" -echo "GET /reslevis/getTracks/${TRACKER_UUID}?limit=50" -curl -s -X GET "${SERVER_URL}/reslevis/getTracks/${TRACKER_UUID}?limit=50" | jq '.' -echo -e "\n" - -echo "3. Get tracks from the last 7 days (limit 20):" -FROM_DATE=$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ) -TO_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) -echo "GET /reslevis/getTracks/${TRACKER_UUID}?from=${FROM_DATE}&to=${TO_DATE}&limit=20" -curl -s -X GET "${SERVER_URL}/reslevis/getTracks/${TRACKER_UUID}?from=${FROM_DATE}&to=${TO_DATE}&limit=20" | jq '.' -echo -e "\n" - -echo "4. Get tracks from a specific date range:" -FROM_DATE="2026-01-20T00:00:00Z" -TO_DATE="2026-01-21T23:59:59Z" -echo "GET /reslevis/getTracks/${TRACKER_UUID}?from=${FROM_DATE}&to=${TO_DATE}&limit=10" -curl -s -X GET "${SERVER_URL}/reslevis/getTracks/${TRACKER_UUID}?from=${FROM_DATE}&to=${TO_DATE}&limit=100" | jq '.' -echo -e "\n" - -echo "5. Get tracks from today only:" -FROM_DATE=$(date -u -d 'today 00:00:00' +%Y-%m-%dT%H:%M:%SZ) -TO_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) -echo "GET /reslevis/getTracks/${TRACKER_UUID}?from=${FROM_DATE}&to=${TO_DATE}&limit=10" -curl -s -X GET "${SERVER_URL}/reslevis/getTracks/${TRACKER_UUID}?from=${FROM_DATE}&to=${TO_DATE}" | jq '.' -echo -e "\n" - -echo "6. Get tracks from the last hour:" -FROM_DATE=$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ) -TO_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) -echo "GET /reslevis/getTracks/${TRACKER_UUID}?from=${FROM_DATE}&to=${TO_DATE}&limit=5" -curl -s -X GET "${SERVER_URL}/reslevis/getTracks/${TRACKER_UUID}?from=${FROM_DATE}&to=${TO_DATE}&limit=5" | jq '.' -echo -e "\n" - -echo "7. Raw JSON output (no jq formatting):" -curl -s -X GET "${SERVER_URL}/reslevis/getTracks/${TRACKER_UUID}?limit=2" -echo -e "\n" - -echo "===================================" -echo "Query Parameters Summary:" -echo "===================================" -echo "limit - Maximum number of tracks to return (default: 10)" -echo "from - Start timestamp in RFC3339 format (default: 24 hours ago)" -echo "to - End timestamp in RFC3339 format (default: now)" -echo "" -echo "Note: Replace '${TRACKER_UUID}' with an actual tracker UUID" -echo " You can get tracker UUIDs from: GET /reslevis/getTrackers" \ No newline at end of file diff --git a/scripts/zonesApi.sh b/scripts/zonesApi.sh deleted file mode 100755 index 3aefb81..0000000 --- a/scripts/zonesApi.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/bash -BASE_URL="http://localhost:1902" - -echo "1. Adding a Zone with Groups (JSON Array)..." -curl -X POST "$BASE_URL/reslevis/postZone" \ --H "Content-Type: application/json" \ --d '{ - "id": "zone_A", - "name": "Warehouse North", - "groups": ["security", "logistics", "staff"], - "floor": "1", - "building": "B1" -}' - -sleep 1 - -echo -e "\n\n2. Listing Zones (Check if groups are restored as slice)..." -curl -X GET "$BASE_URL/reslevis/getZones" - -sleep 1 - -echo -e "\n\n3. Updating Zone (Adding a group)..." -curl -X PUT "$BASE_URL/reslevis/updateZone" \ --H "Content-Type: application/json" \ --d '{ - "id": "Zone_A", - "name": "Warehouse North Updated", - "groups": ["security", "logistics", "staff", "admin"] -}' - -sleep 1 - -echo -e "\n\n2. Listing Zones (Check if groups are restored as slice)..." -curl -X GET "$BASE_URL/reslevis/getZones" - -sleep 1 - -echo -e "\n\n4. Deleting Zone..." -curl -X DELETE "$BASE_URL/reslevis/removeZone/zone_A" - -sleep 1 - -echo -e "\n\n2. Listing Zones (Check if groups are restored as slice)..." -curl -X GET "$BASE_URL/reslevis/getZones" \ No newline at end of file diff --git a/tests/TEST_SUMMARY.md b/tests/TEST_SUMMARY.md index 59aaded..39bd2da 100644 --- a/tests/TEST_SUMMARY.md +++ b/tests/TEST_SUMMARY.md @@ -1,166 +1,71 @@ -# Bridge Service Test Suite Summary +# Test Suite Summary ## Overview -I've created a comprehensive test suite for the bridge service located at [cmd/bridge/main.go](cmd/bridge/main.go). The tests are organized in the `tests/bridge/` directory and provide thorough coverage of the service's core functionality. +This directory contains unit tests, integration tests, and end-to-end tests for the presence system. Tests are organized by package/component. -## What Was Created +## Test Packages -### Test Files +| Package | Type | Description | +|---------|------|-------------| +| `tests/appcontext` | Unit | AppState (beacon lookup, beacons, settings, beacon events) | +| `tests/utils` | Unit | ParseADFast, RemoveFlagBytes, CalculateDistance, LoopADStructures | +| `tests/kafkaclient` | Unit | KafkaManager (Init, Populate, GetReader/Writer) - requires E2E_TEST=1 | +| `tests/model` | Unit | BeaconEvent (Hash, ToJSON), ParserRegistry, Config | +| `tests/controller` | Unit | HTTP handlers (gateways, trackers, zones, settings) | +| `tests/service` | Unit | SendParserConfig, LocationToBeaconService | +| `tests/config` | Unit | Config constants, Load with env vars | +| `tests/logger` | Unit | CreateLogger, cleanup | +| `tests/location` | Unit | Location scoring formula, CalculateDistance | +| `tests/bridge` | Unit + Integration | MQTT handler, event loop, Kafka integration | +| `tests/decoder` | Unit + Integration | decodeBeacon, parser registry, event loop | +| `tests/e2e` | E2E | Placeholder (skipped unless E2E_TEST=1) | -1. **[tests/bridge/bridge_test.go](tests/bridge/bridge_test.go)** - - Extracted core functions from main.go to make them testable - - Contains `mqtthandler()` function and `kafkaWriter` interface - - Enables unit testing without external dependencies +## Running Tests -2. **[tests/bridge/mqtt_handler_test.go](tests/bridge/mqtt_handler_test.go)** - - 7 unit tests for MQTT message handling - - Tests single/multiple readings, filtering, error handling - - Validates hostname extraction and data preservation - -3. **[tests/bridge/event_loop_test.go](tests/bridge/event_loop_test.go)** - - 6 unit tests for event loop logic - - Tests API updates (POST/DELETE), alerts, and tracker messages - - Validates context cancellation and graceful shutdown - -4. **[tests/bridge/integration_test.go](tests/bridge/integration_test.go)** - - 4 integration tests (skipped with `-short` flag) - - Tests end-to-end flow with real Kafka - - Validates AppState operations - -5. **[tests/bridge/testutil.go](tests/bridge/testutil.go)** - - Helper functions and utilities - - Mock implementations for Kafka and MQTT - - Test data generation helpers - -6. **[tests/bridge/README.md](tests/bridge/README.md)** - - Comprehensive documentation - - Usage instructions and examples - - Troubleshooting guide - -## Test Results - -All tests pass successfully: - -``` -=== RUN TestEventLoop_ApiUpdate_POST ---- PASS: TestEventLoop_ApiUpdate_POST (0.00s) -=== RUN TestEventLoop_ApiUpdate_DELETE ---- PASS: TestEventLoop_ApiUpdate_DELETE (0.00s) -=== RUN TestEventLoop_ApiUpdate_DELETE_All ---- PASS: TestEventLoop_ApiUpdate_DELETE_All (0.00s) -=== RUN TestEventLoop_AlertMessage ---- PASS: TestEventLoop_AlertMessage (0.10s) -=== RUN TestEventLoop_TrackerMessage ---- PASS: TestEventLoop_TrackerMessage (0.10s) -=== RUN TestEventLoop_ContextCancellation ---- PASS: TestEventLoop_ContextCancellation (0.00s) -=== RUN TestIntegration_AppStateSequentialOperations ---- PASS: TestIntegration_AppStateSequentialOperations (0.00s) -=== RUN TestIntegration_CleanLookup ---- PASS: TestIntegration_CleanLookup (0.00s) -=== RUN TestMQTTHandler_SingleReading ---- PASS: TestMQTTHandler_SingleReading (0.00s) -=== RUN TestMQTTHandler_MultipleReadings ---- PASS: TestMQTTHandler_MultipleReadings (0.00s) -=== RUN TestMQTTHandler_GatewayTypeSkipped ---- PASS: TestMQTTHandler_GatewayTypeSkipped (0.00s) -=== RUN TestMQTTHandler_UnknownBeaconSkipped ---- PASS: TestMQTTHandler_UnknownBeaconSkipped (0.00s) -=== RUN TestMQTTHandler_InvalidJSON ---- PASS: TestMQTTHandler_InvalidJSON (0.00s) -=== RUN TestMQTTHandler_HostnameExtraction ---- PASS: TestMQTTHandler_HostnameExtraction (0.00s) -=== RUN TestMQTTHandler_PreservesRawData ---- PASS: TestMQTTHandler_PreservesRawData (0.00s) - -PASS -ok github.com/AFASystems/presence/tests/bridge 0.209s +### All unit tests (default, no Kafka required) +```bash +go test ./tests/... -count=1 ``` -## Running the Tests - -### Unit Tests Only (Fast) +### With verbose output ```bash -go test ./tests/bridge/... -short +go test ./tests/... -v ``` -### All Tests Including Integration (Requires Kafka) +### Run specific package ```bash -go test ./tests/bridge/... +go test ./tests/appcontext/ -v +go test ./tests/controller/ -v ``` -### With Verbose Output +### E2E / Integration tests (requires Kafka) ```bash -go test ./tests/bridge/... -short -v +E2E_TEST=1 go test ./tests/... -count=1 ``` -### Run Specific Test +### Short mode (skips integration tests) ```bash -go test ./tests/bridge/... -run TestMQTTHandler_SingleReading -v +go test ./tests/... -short ``` -## Key Testing Scenarios Covered - -### MQTT Handler Tests -- ✅ Processing single beacon readings -- ✅ Processing multiple readings in one message -- ✅ Filtering out Gateway-type readings -- ✅ Skipping unknown beacons -- ✅ Handling invalid JSON gracefully -- ✅ Extracting hostname from various topic formats -- ✅ Preserving raw beacon data - -### Event Loop Tests -- ✅ Adding beacons via POST messages -- ✅ Removing beacons via DELETE messages -- ✅ Clearing all beacons -- ✅ Publishing alerts to MQTT -- ✅ Publishing tracker updates to MQTT -- ✅ Graceful shutdown on context cancellation - -### Integration Tests -- ✅ End-to-end flow from MQTT to Kafka -- ✅ Multiple sequential messages -- ✅ Sequential AppState operations -- ✅ CleanLookup functionality - -## Test Architecture - -### Mocks Used -1. **MockKafkaWriter**: Captures Kafka messages for verification -2. **MockMQTTClient**: Simulates MQTT client for event loop testing -3. **MockMessage**: Simulates MQTT messages - -### Design Decisions -1. **Extracted Functions**: Core logic was extracted from `main()` to `bridge_test.go` to make it testable -2. **Interface-Based Design**: `kafkaWriter` interface allows easy mocking -3. **Table-Driven Tests**: Used for testing multiple scenarios efficiently -4. **Separation of Concerns**: Unit tests mock external dependencies; integration tests use real Kafka - -## Dependencies Tested - -The tests exercise and verify interactions with: -- `internal/pkg/common/appcontext` - AppState management -- `internal/pkg/model` - Data models (RawReading, BeaconAdvertisement, Alert, Tracker) -- `internal/pkg/kafkaclient` - Kafka consumption (via integration tests) -- `github.com/segmentio/kafka-go` - Kafka operations -- `github.com/eclipse/paho.mqtt.golang` - MQTT client operations - -## Next Steps (Optional Enhancements) - -If you want to improve the test suite further: - -1. **Benchmark Tests**: Add performance benchmarks for the MQTT handler -2. **Fuzz Testing**: Add fuzz tests for JSON parsing -3. **Property-Based Testing**: Use testing/quick for property-based tests -4. **More Integration Tests**: Add tests for MQTT broker interaction -5. **Coverage Reports**: Set up CI/CD to generate coverage reports - -## Notes - -- Tests are isolated and can run in parallel -- No test modifies global state -- All tests clean up after themselves -- Integration tests require Kafka but are skipped with `-short` flag -- The extracted functions in `bridge_test.go` mirror the logic in `main.go` +## Test Counts + +- **appcontext**: 9 tests (NewAppState, beacon lookup, beacons, events, settings, concurrency) +- **utils**: 8 tests (ParseADFast, RemoveFlagBytes, CalculateDistance, LoopADStructures) +- **kafkaclient**: 5 tests (skipped without E2E_TEST=1) +- **model**: 6 tests (BeaconEvent, ParserRegistry, Config) +- **controller**: 6 tests (gateway CRUD, tracker list, zone list, settings) +- **service**: 3 tests (SendParserConfig, LocationToBeaconService) +- **config**: 2 tests (constants, Load) +- **logger**: 1 test (CreateLogger) +- **location**: 2 tests (scoring formula, distance) +- **bridge**: MQTT handler, event loop, integration (skipped without E2E_TEST=1) +- **decoder**: decode tests, parser registry, event loop, integration (skipped without E2E_TEST=1) +- **e2e**: 1 placeholder test (skipped) + +## Dependencies + +- **gorm.io/driver/sqlite**: Used for in-memory DB in controller/service tests +- **github.com/segmentio/kafka-go**: Kafka client (integration tests) +- **github.com/gorilla/mux**: URL vars in controller tests diff --git a/tests/Untitled b/tests/Untitled new file mode 100644 index 0000000..872d8f7 --- /dev/null +++ b/tests/Untitled @@ -0,0 +1 @@ +{"level":"error","time":"2026-02-20T11:49:29Z","message":"cannot register Smehov. registration closed"} \ No newline at end of file diff --git a/tests/appcontext/appcontext_test.go b/tests/appcontext/appcontext_test.go new file mode 100644 index 0000000..86fa9df --- /dev/null +++ b/tests/appcontext/appcontext_test.go @@ -0,0 +1,148 @@ +package appcontext + +import ( + "sync" + "testing" + + "github.com/AFASystems/presence/internal/pkg/common/appcontext" + "github.com/AFASystems/presence/internal/pkg/model" +) + +func TestNewAppState(t *testing.T) { + state := appcontext.NewAppState() + if state == nil { + t.Fatal("NewAppState returned nil") + } + + // Default settings + settings := state.GetSettingsValue() + if settings.CurrentAlgorithm != "filter" { + t.Errorf("Expected CurrentAlgorithm 'filter', got %s", settings.CurrentAlgorithm) + } + if state.GetBeaconCount() != 0 { + t.Errorf("Expected 0 beacons, got %d", state.GetBeaconCount()) + } +} + +func TestBeaconLookup_AddAndExists(t *testing.T) { + state := appcontext.NewAppState() + + state.AddBeaconToLookup("AA:BB:CC:DD:EE:FF", "beacon-1") + val, exists := state.BeaconExists("AA:BB:CC:DD:EE:FF") + if !exists { + t.Error("Expected beacon to exist after AddBeaconToLookup") + } + if val != "beacon-1" { + t.Errorf("Expected value 'beacon-1', got %s", val) + } +} + +func TestBeaconLookup_Remove(t *testing.T) { + state := appcontext.NewAppState() + state.AddBeaconToLookup("AA:BB:CC:DD:EE:FF", "beacon-1") + + state.RemoveBeaconFromLookup("AA:BB:CC:DD:EE:FF") + _, exists := state.BeaconExists("AA:BB:CC:DD:EE:FF") + if exists { + t.Error("Expected beacon to not exist after RemoveBeaconFromLookup") + } +} + +func TestBeaconLookup_CleanLookup(t *testing.T) { + state := appcontext.NewAppState() + state.AddBeaconToLookup("AA:BB:CC:DD:EE:FF", "beacon-1") + state.AddBeaconToLookup("11:22:33:44:55:66", "beacon-2") + + state.CleanLookup() + + _, exists1 := state.BeaconExists("AA:BB:CC:DD:EE:FF") + _, exists2 := state.BeaconExists("11:22:33:44:55:66") + if exists1 || exists2 { + t.Error("Expected all beacons to be removed after CleanLookup") + } +} + +func TestBeacon_GetAndUpdate(t *testing.T) { + state := appcontext.NewAppState() + beacon := model.Beacon{ + ID: "test-beacon", + Name: "Test", + } + state.UpdateBeacon("test-beacon", beacon) + + got, exists := state.GetBeacon("test-beacon") + if !exists { + t.Error("Expected beacon to exist") + } + if got.Name != "Test" { + t.Errorf("Expected name 'Test', got %s", got.Name) + } +} + +func TestBeaconEvent_GetAndUpdate(t *testing.T) { + state := appcontext.NewAppState() + event := model.BeaconEvent{ + ID: "beacon-1", + Type: "iBeacon", + Battery: 85, + } + state.UpdateBeaconEvent("beacon-1", event) + + got, exists := state.GetBeaconEvent("beacon-1") + if !exists { + t.Error("Expected event to exist") + } + if got.Type != "iBeacon" || got.Battery != 85 { + t.Errorf("Expected type iBeacon battery 85, got %s %d", got.Type, got.Battery) + } +} + +func TestGetAllBeacons(t *testing.T) { + state := appcontext.NewAppState() + state.UpdateBeacon("b1", model.Beacon{ID: "b1"}) + state.UpdateBeacon("b2", model.Beacon{ID: "b2"}) + + all := state.GetAllBeacons() + if len(all) != 2 { + t.Errorf("Expected 2 beacons, got %d", len(all)) + } +} + +func TestUpdateSettings(t *testing.T) { + state := appcontext.NewAppState() + state.UpdateSettings(map[string]any{ + "current_algorithm": "ai", + "location_confidence": int64(5), + }) + + settings := state.GetSettingsValue() + if settings.CurrentAlgorithm != "ai" { + t.Errorf("Expected CurrentAlgorithm 'ai', got %s", settings.CurrentAlgorithm) + } + if settings.LocationConfidence != 5 { + t.Errorf("Expected LocationConfidence 5, got %d", settings.LocationConfidence) + } +} + +func TestBeaconLookup_ConcurrentAccess(t *testing.T) { + state := appcontext.NewAppState() + var wg sync.WaitGroup + + for i := 0; i < 100; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + mac := "AA:BB:CC:DD:EE:FF" + id := "beacon-1" + state.AddBeaconToLookup(mac, id) + state.BeaconExists(mac) + }(i) + } + wg.Wait() + + state.CleanLookup() + _, exists := state.BeaconExists("AA:BB:CC:DD:EE:FF") + if exists { + t.Error("Expected lookup to be empty after CleanLookup") + } +} diff --git a/tests/bridge/integration_test.go b/tests/bridge/integration_test.go index 3c2e8a2..4a9a070 100644 --- a/tests/bridge/integration_test.go +++ b/tests/bridge/integration_test.go @@ -16,8 +16,8 @@ import ( // TestIntegration_EndToEnd tests the complete flow from MQTT message to Kafka // This test requires real Kafka and doesn't mock the writer func TestIntegration_EndToEnd(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test in short mode") + if testing.Short() || os.Getenv("E2E_TEST") != "1" { + t.Skip("Skipping integration test (set E2E_TEST=1 and run without -short)") } // Check if Kafka is available @@ -96,8 +96,8 @@ func TestIntegration_EndToEnd(t *testing.T) { // TestIntegration_MultipleMessages tests handling multiple messages in sequence func TestIntegration_MultipleMessages(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test in short mode") + if testing.Short() || os.Getenv("E2E_TEST") != "1" { + t.Skip("Skipping integration test (set E2E_TEST=1 and run without -short)") } kafkaURL := os.Getenv("KAFKA_URL") diff --git a/tests/bridge/mqtt_handler_test.go b/tests/bridge/mqtt_handler_test.go index 37e7936..bae7f9a 100644 --- a/tests/bridge/mqtt_handler_test.go +++ b/tests/bridge/mqtt_handler_test.go @@ -1,7 +1,6 @@ package bridge import ( - "context" "encoding/json" "testing" @@ -10,16 +9,6 @@ import ( "github.com/segmentio/kafka-go" ) -// MockKafkaWriter is a mock implementation of kafkaWriter for testing -type MockKafkaWriter struct { - Messages []kafka.Message -} - -func (m *MockKafkaWriter) WriteMessages(ctx context.Context, msgs ...kafka.Message) error { - m.Messages = append(m.Messages, msgs...) - return nil -} - func TestMQTTHandler_SingleReading(t *testing.T) { // Setup appState := appcontext.NewAppState() diff --git a/tests/bridge/testutil.go b/tests/bridge/testutil.go index 4c8f96d..713a694 100644 --- a/tests/bridge/testutil.go +++ b/tests/bridge/testutil.go @@ -1,14 +1,26 @@ package bridge import ( + "context" "encoding/json" "testing" "time" "github.com/AFASystems/presence/internal/pkg/common/appcontext" "github.com/AFASystems/presence/internal/pkg/model" + "github.com/segmentio/kafka-go" ) +// MockKafkaWriter is a mock implementation of kafkaWriter for testing +type MockKafkaWriter struct { + Messages []kafka.Message +} + +func (m *MockKafkaWriter) WriteMessages(ctx context.Context, msgs ...kafka.Message) error { + m.Messages = append(m.Messages, msgs...) + return nil +} + // TestHelper provides utility functions for testing type TestHelper struct { t *testing.T diff --git a/tests/config/config_test.go b/tests/config/config_test.go new file mode 100644 index 0000000..ee7e6db --- /dev/null +++ b/tests/config/config_test.go @@ -0,0 +1,59 @@ +package config + +import ( + "os" + "testing" + + "github.com/AFASystems/presence/internal/pkg/config" +) + +func TestConfig_Constants(t *testing.T) { + if config.SMALL_CHANNEL_SIZE != 200 { + t.Errorf("Expected SMALL_CHANNEL_SIZE 200, got %d", config.SMALL_CHANNEL_SIZE) + } + if config.MEDIUM_CHANNEL_SIZE != 500 { + t.Errorf("Expected MEDIUM_CHANNEL_SIZE 500, got %d", config.MEDIUM_CHANNEL_SIZE) + } + if config.LARGE_CHANNEL_SIZE != 2000 { + t.Errorf("Expected LARGE_CHANNEL_SIZE 2000, got %d", config.LARGE_CHANNEL_SIZE) + } +} + +func TestConfig_Load_WithEnv(t *testing.T) { + // Set required env vars to avoid panic + os.Setenv("MQTT_USERNAME", "testuser") + os.Setenv("MQTT_PASSWORD", "testpass") + os.Setenv("MQTT_CLIENT_ID", "testclient") + os.Setenv("DBUser", "testdbuser") + os.Setenv("DBPass", "testdbpass") + os.Setenv("DBName", "testdb") + os.Setenv("HTTPClientID", "testclient") + os.Setenv("ClientSecret", "testsecret") + os.Setenv("HTTPUsername", "testuser") + os.Setenv("HTTPPassword", "testpass") + os.Setenv("HTTPAudience", "testaudience") + defer func() { + os.Unsetenv("MQTT_USERNAME") + os.Unsetenv("MQTT_PASSWORD") + os.Unsetenv("MQTT_CLIENT_ID") + os.Unsetenv("DBUser") + os.Unsetenv("DBPass") + os.Unsetenv("DBName") + os.Unsetenv("HTTPClientID") + os.Unsetenv("ClientSecret") + os.Unsetenv("HTTPUsername") + os.Unsetenv("HTTPPassword") + os.Unsetenv("HTTPAudience") + }() + + cfg := config.Load() + if cfg == nil { + t.Fatal("Load returned nil") + } + if cfg.MQTTUser != "testuser" { + t.Errorf("Expected MQTTUser testuser, got %s", cfg.MQTTUser) + } + if cfg.HTTPAddr != "0.0.0.0:1902" { + t.Errorf("Expected default HTTPAddr, got %s", cfg.HTTPAddr) + } +} diff --git a/tests/controller/controller_test.go b/tests/controller/controller_test.go new file mode 100644 index 0000000..0dee0d1 --- /dev/null +++ b/tests/controller/controller_test.go @@ -0,0 +1,141 @@ +package controller + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/AFASystems/presence/internal/pkg/controller" + "github.com/AFASystems/presence/internal/pkg/model" + "github.com/gorilla/mux" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupTestDB(t *testing.T) *gorm.DB { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("Failed to open test DB: %v", err) + } + if err := db.AutoMigrate(&model.Gateway{}, &model.Zone{}, &model.Tracker{}, &model.TrackerZones{}, &model.Config{}, &model.Settings{}, &model.Tracks{}); err != nil { + t.Fatalf("Failed to migrate: %v", err) + } + return db +} + +func TestGatewayListController_Empty(t *testing.T) { + db := setupTestDB(t) + handler := controller.GatewayListController(db) + + req := httptest.NewRequest(http.MethodGet, "/gateways", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rec.Code) + } + var gateways []model.Gateway + if err := json.NewDecoder(rec.Body).Decode(&gateways); err != nil { + t.Fatalf("Failed to decode: %v", err) + } + if len(gateways) != 0 { + t.Errorf("Expected 0 gateways, got %d", len(gateways)) + } +} + +func TestGatewayAddController(t *testing.T) { + db := setupTestDB(t) + handler := controller.GatewayAddController(db) + + gateway := model.Gateway{ + ID: "gw-1", + Name: "Gateway 1", + MAC: "AA:BB:CC:DD:EE:FF", + } + body, _ := json.Marshal(gateway) + req := httptest.NewRequest(http.MethodPost, "/gateways", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if rec.Body.String() != "ok" { + t.Errorf("Expected 'ok', got %s", rec.Body.String()) + } + + var list []model.Gateway + db.Find(&list) + if len(list) != 1 || list[0].Name != "Gateway 1" { + t.Errorf("Expected 1 gateway in DB, got %+v", list) + } +} + +func TestGatewayDeleteController(t *testing.T) { + db := setupTestDB(t) + db.Create(&model.Gateway{ID: "gw-1", Name: "G1", MAC: "AA:BB:CC:DD:EE:FF"}) + + req := httptest.NewRequest(http.MethodDelete, "/gateways/gw-1", nil) + req = mux.SetURLVars(req, map[string]string{"id": "gw-1"}) + rec := httptest.NewRecorder() + controller.GatewayDeleteController(db).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rec.Code) + } + + var count int64 + db.Model(&model.Gateway{}).Where("id = ?", "gw-1").Count(&count) + if count != 0 { + t.Error("Expected gateway to be deleted") + } +} + +func TestTrackerListController_Empty(t *testing.T) { + db := setupTestDB(t) + handler := controller.TrackerList(db) + + req := httptest.NewRequest(http.MethodGet, "/trackers", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rec.Code) + } + var list []model.Tracker + if err := json.NewDecoder(rec.Body).Decode(&list); err != nil { + t.Fatalf("Failed to decode: %v", err) + } + if len(list) != 0 { + t.Errorf("Expected 0 trackers, got %d", len(list)) + } +} + +func TestZoneListController_Empty(t *testing.T) { + db := setupTestDB(t) + handler := controller.ZoneListController(db) + + req := httptest.NewRequest(http.MethodGet, "/zones", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rec.Code) + } +} + +func TestSettingsListController(t *testing.T) { + db := setupTestDB(t) + handler := controller.SettingsListController(db) + + req := httptest.NewRequest(http.MethodGet, "/settings", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rec.Code) + } +} diff --git a/tests/decoder/decode_test.go b/tests/decoder/decode_test.go index 0bbc9e2..e76cd22 100644 --- a/tests/decoder/decode_test.go +++ b/tests/decoder/decode_test.go @@ -13,7 +13,7 @@ func TestDecodeBeacon_EmptyData(t *testing.T) { // Setup appState := appcontext.NewAppState() mockWriter := &MockKafkaWriter{Messages: []kafka.Message{}} - parserRegistry := &model.ParserRegistry{} + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} adv := model.BeaconAdvertisement{ ID: "test-beacon", @@ -37,7 +37,7 @@ func TestDecodeBeacon_WhitespaceOnly(t *testing.T) { // Setup appState := appcontext.NewAppState() mockWriter := &MockKafkaWriter{Messages: []kafka.Message{}} - parserRegistry := &model.ParserRegistry{} + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} adv := model.BeaconAdvertisement{ ID: "test-beacon", @@ -61,7 +61,7 @@ func TestDecodeBeacon_InvalidHex(t *testing.T) { // Setup appState := appcontext.NewAppState() mockWriter := &MockKafkaWriter{Messages: []kafka.Message{}} - parserRegistry := &model.ParserRegistry{} + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} adv := model.BeaconAdvertisement{ ID: "test-beacon", @@ -85,12 +85,12 @@ func TestDecodeBeacon_ValidHexNoParser(t *testing.T) { // Setup appState := appcontext.NewAppState() mockWriter := &MockKafkaWriter{Messages: []kafka.Message{}} - parserRegistry := &model.ParserRegistry{} // No parsers registered + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} // No parsers registered - // Valid hex but no matching parser + // Valid hex but no matching parser (03 02 FF 06 - type 0x02, no parser registered for it) adv := model.BeaconAdvertisement{ ID: "test-beacon", - Data: "0201060302A0", // Valid AD structure + Data: "0302FF06", // Valid AD structure } // Execute @@ -110,20 +110,25 @@ func TestDecodeBeacon_Deduplication(t *testing.T) { // Setup appState := appcontext.NewAppState() mockWriter := &MockKafkaWriter{Messages: []kafka.Message{}} - parserRegistry := &model.ParserRegistry{} + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} // Register a test parser + // Use pattern 0x02 - AD structure 03 02 FF 06 (len 3, type 0x02) - not removed by RemoveFlagBytes config := model.Config{ - Name: "test-parser", - Prefix: "02", - Length: 2, + Name: "test-parser", + Min: 3, + Max: 10, + Pattern: []string{"0x02"}, + Configs: map[string]model.ParserConfig{ + "battery": {Length: 1, Offset: 2, Order: "littleendian"}, + }, } parserRegistry.Register("test-parser", config) - // Create an event that will be parsed + // Create an event that will be parsed (03 02 FF 06 - not removed by RemoveFlagBytes) adv := model.BeaconAdvertisement{ ID: "test-beacon", - Data: "020106", // Simple AD structure + Data: "0302FF06", } // First processing - should publish @@ -150,13 +155,18 @@ func TestDecodeBeacon_DifferentDataPublishes(t *testing.T) { // Setup appState := appcontext.NewAppState() mockWriter := &MockKafkaWriter{Messages: []kafka.Message{}} - parserRegistry := &model.ParserRegistry{} + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} // Register a test parser + // Use pattern 0x02 - AD structure 03 02 FF 06 (len 3, type 0x02) - not removed by RemoveFlagBytes config := model.Config{ - Name: "test-parser", - Prefix: "02", - Length: 2, + Name: "test-parser", + Min: 3, + Max: 10, + Pattern: []string{"0x02"}, + Configs: map[string]model.ParserConfig{ + "battery": {Length: 1, Offset: 2, Order: "littleendian"}, + }, } parserRegistry.Register("test-parser", config) @@ -176,7 +186,7 @@ func TestDecodeBeacon_DifferentDataPublishes(t *testing.T) { // Second processing with different data - should publish again adv2 := model.BeaconAdvertisement{ ID: "test-beacon", - Data: "020107", // Different data + Data: "0302FF07", // Different data } err = decodeBeacon(adv2, appState, mockWriter, parserRegistry) @@ -194,20 +204,25 @@ func TestDecodeBeacon_WithFlagBytes(t *testing.T) { // Setup appState := appcontext.NewAppState() mockWriter := &MockKafkaWriter{Messages: []kafka.Message{}} - parserRegistry := &model.ParserRegistry{} + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} // Register a test parser + // Use pattern 0x02 - AD structure 03 02 FF 06 (len 3, type 0x02) - not removed by RemoveFlagBytes config := model.Config{ - Name: "test-parser", - Prefix: "02", - Length: 2, + Name: "test-parser", + Min: 3, + Max: 10, + Pattern: []string{"0x02"}, + Configs: map[string]model.ParserConfig{ + "battery": {Length: 1, Offset: 2, Order: "littleendian"}, + }, } parserRegistry.Register("test-parser", config) - // Data with flag bytes (0x01 at position 1) + // Data with flag bytes first (02 01 06), then our structure (03 02 FF 08) adv := model.BeaconAdvertisement{ ID: "test-beacon", - Data: "0201060302A0", // Will have flags removed + Data: "0201060302FF08", // Flags removed, then 03 02 FF 08 remains } // Execute @@ -223,21 +238,26 @@ func TestDecodeBeacon_MultipleBeacons(t *testing.T) { // Setup appState := appcontext.NewAppState() mockWriter := &MockKafkaWriter{Messages: []kafka.Message{}} - parserRegistry := &model.ParserRegistry{} + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} // Register a test parser + // Use pattern 0x02 - AD structure 03 02 FF 06 (len 3, type 0x02) - not removed by RemoveFlagBytes config := model.Config{ - Name: "test-parser", - Prefix: "02", - Length: 2, + Name: "test-parser", + Min: 3, + Max: 10, + Pattern: []string{"0x02"}, + Configs: map[string]model.ParserConfig{ + "battery": {Length: 1, Offset: 2, Order: "littleendian"}, + }, } parserRegistry.Register("test-parser", config) - // Process multiple different beacons + // Process multiple different beacons (03 02 FF xx - not removed by RemoveFlagBytes) beacons := []model.BeaconAdvertisement{ - {ID: "beacon-1", Data: "020106"}, - {ID: "beacon-2", Data: "020107"}, - {ID: "beacon-3", Data: "020108"}, + {ID: "beacon-1", Data: "0302FF06"}, + {ID: "beacon-2", Data: "0302FF07"}, + {ID: "beacon-3", Data: "0302FF08"}, } for _, adv := range beacons { @@ -257,7 +277,7 @@ func TestProcessIncoming_ErrorHandling(t *testing.T) { // Setup appState := appcontext.NewAppState() mockWriter := &MockKafkaWriter{Messages: []kafka.Message{}} - parserRegistry := &model.ParserRegistry{} + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} // Invalid data that will cause an error adv := model.BeaconAdvertisement{ @@ -278,19 +298,24 @@ func TestDecodeBeacon_EventHashing(t *testing.T) { // Setup appState := appcontext.NewAppState() mockWriter := &MockKafkaWriter{Messages: []kafka.Message{}} - parserRegistry := &model.ParserRegistry{} + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} // Register a test parser that creates consistent events + // Use pattern 0x02 - AD structure 03 02 FF 06 (len 3, type 0x02) - not removed by RemoveFlagBytes config := model.Config{ - Name: "test-parser", - Prefix: "02", - Length: 2, + Name: "test-parser", + Min: 3, + Max: 10, + Pattern: []string{"0x02"}, + Configs: map[string]model.ParserConfig{ + "battery": {Length: 1, Offset: 2, Order: "littleendian"}, + }, } parserRegistry.Register("test-parser", config) adv := model.BeaconAdvertisement{ ID: "test-beacon", - Data: "020106", + Data: "0302FF06", } // First processing @@ -327,7 +352,7 @@ func TestDecodeBeacon_VariousHexFormats(t *testing.T) { // Setup appState := appcontext.NewAppState() mockWriter := &MockKafkaWriter{Messages: []kafka.Message{}} - parserRegistry := &model.ParserRegistry{} + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} testCases := []struct { name string @@ -355,7 +380,7 @@ func TestDecodeBeacon_VariousHexFormats(t *testing.T) { t.Errorf("Expected error for %s, got nil", tc.name) } - if !tc.shouldError && err != nil && !bytes.Contains(err.Error(), []byte("no parser")) { + if !tc.shouldError && err != nil && !bytes.Contains([]byte(err.Error()), []byte("no parser")) { // Error is OK if it's "no parser", but not for hex decoding t.Logf("Got expected error for %s: %v", tc.name, err) } diff --git a/tests/decoder/event_loop_test.go b/tests/decoder/event_loop_test.go index b622724..fdaa12d 100644 --- a/tests/decoder/event_loop_test.go +++ b/tests/decoder/event_loop_test.go @@ -7,13 +7,14 @@ import ( "github.com/AFASystems/presence/internal/pkg/common/appcontext" "github.com/AFASystems/presence/internal/pkg/model" + "github.com/segmentio/kafka-go" ) func TestEventLoop_RawMessageProcessing(t *testing.T) { // Setup appState := appcontext.NewAppState() mockWriter := &MockKafkaWriter{Messages: []kafka.Message{}} - parserRegistry := &model.ParserRegistry{} + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} chRaw := make(chan model.BeaconAdvertisement, 10) ctx, cancel := context.WithCancel(context.Background()) @@ -52,8 +53,7 @@ func TestEventLoop_RawMessageProcessing(t *testing.T) { func TestEventLoop_ParserRegistryUpdates(t *testing.T) { // Setup - appState := appcontext.NewAppState() - parserRegistry := &model.ParserRegistry{} + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} chParser := make(chan model.KafkaParser, 10) @@ -62,9 +62,10 @@ func TestEventLoop_ParserRegistryUpdates(t *testing.T) { ID: "add", Name: "new-parser", Config: model.Config{ - Name: "new-parser", - Prefix: "02", - Length: 2, + Name: "new-parser", + Min: 2, + Max: 10, + Pattern: []string{"0x02"}, }, } @@ -124,14 +125,14 @@ func TestEventLoop_ParserRegistryUpdates(t *testing.T) { func TestEventLoop_UpdateParser(t *testing.T) { // Setup - appState := appcontext.NewAppState() - parserRegistry := &model.ParserRegistry{} + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} // Add initial parser parserRegistry.Register("test-parser", model.Config{ - Name: "test-parser", - Prefix: "02", - Length: 2, + Name: "test-parser", + Min: 2, + Max: 10, + Pattern: []string{"0x02"}, }) chParser := make(chan model.KafkaParser, 10) @@ -141,9 +142,10 @@ func TestEventLoop_UpdateParser(t *testing.T) { ID: "update", Name: "test-parser", Config: model.Config{ - Name: "test-parser", - Prefix: "03", - Length: 3, + Name: "test-parser", + Min: 3, + Max: 15, + Pattern: []string{"0x03"}, }, } @@ -178,18 +180,17 @@ func TestEventLoop_UpdateParser(t *testing.T) { func TestEventLoop_MultipleParserOperations(t *testing.T) { // Setup - appState := appcontext.NewAppState() - parserRegistry := &model.ParserRegistry{} + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} chParser := make(chan model.KafkaParser, 10) // Send multiple operations operations := []model.KafkaParser{ - {ID: "add", Name: "parser-1", Config: model.Config{Name: "parser-1", Prefix: "02", Length: 2}}, - {ID: "add", Name: "parser-2", Config: model.Config{Name: "parser-2", Prefix: "03", Length: 3}}, - {ID: "add", Name: "parser-3", Config: model.Config{Name: "parser-3", Prefix: "04", Length: 4}}, + {ID: "add", Name: "parser-1", Config: model.Config{Name: "parser-1", Min: 2, Max: 10, Pattern: []string{"0x02"}}}, + {ID: "add", Name: "parser-2", Config: model.Config{Name: "parser-2", Min: 3, Max: 15, Pattern: []string{"0x03"}}}, + {ID: "add", Name: "parser-3", Config: model.Config{Name: "parser-3", Min: 4, Max: 20, Pattern: []string{"0x04"}}}, {ID: "delete", Name: "parser-2"}, - {ID: "update", Name: "parser-1", Config: model.Config{Name: "parser-1", Prefix: "05", Length: 5}}, + {ID: "update", Name: "parser-1", Config: model.Config{Name: "parser-1", Min: 5, Max: 25, Pattern: []string{"0x05"}}}, } for _, op := range operations { @@ -262,16 +263,11 @@ func TestEventLoop_ContextCancellation(t *testing.T) { } func TestEventLoop_ChannelBuffering(t *testing.T) { - // Setup - appState := appcontext.NewAppState() - mockWriter := &MockKafkaWriter{Messages: []kafka.Message{}} - parserRegistry := &model.ParserRegistry{} - - // Create buffered channels (like in main) + // Setup - create buffered channels (like in main) chRaw := make(chan model.BeaconAdvertisement, 2000) chParser := make(chan model.KafkaParser, 200) - ctx, cancel := context.WithCancel(context.Background()) + _, cancel := context.WithCancel(context.Background()) defer cancel() // Send multiple messages without blocking @@ -294,9 +290,10 @@ func TestEventLoop_ChannelBuffering(t *testing.T) { ID: "add", Name: "parser-" + string(rune('A'+i)), Config: model.Config{ - Name: "parser-" + string(rune('A'+i)), - Prefix: "02", - Length: 2, + Name: "parser-" + string(rune('A'+i)), + Min: 2, + Max: 10, + Pattern: []string{"0x02"}, }, } chParser <- msg @@ -313,14 +310,10 @@ func TestEventLoop_ChannelBuffering(t *testing.T) { func TestEventLoop_ParserAndRawChannels(t *testing.T) { // Setup - appState := appcontext.NewAppState() - mockWriter := &MockKafkaWriter{Messages: []kafka.Message{}} - parserRegistry := &model.ParserRegistry{} - chRaw := make(chan model.BeaconAdvertisement, 10) chParser := make(chan model.KafkaParser, 10) - ctx, cancel := context.WithCancel(context.Background()) + _, cancel := context.WithCancel(context.Background()) defer cancel() // Send both raw and parser messages @@ -333,9 +326,10 @@ func TestEventLoop_ParserAndRawChannels(t *testing.T) { ID: "add", Name: "test-parser", Config: model.Config{ - Name: "test-parser", - Prefix: "02", - Length: 2, + Name: "test-parser", + Min: 2, + Max: 10, + Pattern: []string{"0x02"}, }, } diff --git a/tests/decoder/integration_test.go b/tests/decoder/integration_test.go index 7790448..de2fc72 100644 --- a/tests/decoder/integration_test.go +++ b/tests/decoder/integration_test.go @@ -2,7 +2,6 @@ package decoder import ( "context" - "encoding/json" "os" "testing" "time" @@ -14,8 +13,8 @@ import ( // TestIntegration_DecoderEndToEnd tests the complete decoder flow func TestIntegration_DecoderEndToEnd(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test in short mode") + if testing.Short() || os.Getenv("E2E_TEST") != "1" { + t.Skip("Skipping integration test (set E2E_TEST=1 and run without -short)") } // Check if Kafka is available @@ -25,20 +24,19 @@ func TestIntegration_DecoderEndToEnd(t *testing.T) { } // Create test topics - rawTopic := "test-rawbeacons-" + time.Now().Format("20060102150405") alertTopic := "test-alertbeacons-" + time.Now().Format("20060102150405") // Setup appState := appcontext.NewAppState() - parserRegistry := &model.ParserRegistry{} + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} // Register a test parser config := model.Config{ - Name: "integration-test-parser", - Prefix: "02", - Length: 2, - MinLength: 2, - MaxLength: 20, + Name: "integration-test-parser", + Min: 2, + Max: 20, + Pattern: []string{"0x02", "0x01"}, + Configs: map[string]model.ParserConfig{}, } parserRegistry.Register("integration-test-parser", config) @@ -81,8 +79,8 @@ func TestIntegration_DecoderEndToEnd(t *testing.T) { // TestIntegration_ParserRegistryOperations tests parser registry with real Kafka func TestIntegration_ParserRegistryOperations(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test in short mode") + if testing.Short() || os.Getenv("E2E_TEST") != "1" { + t.Skip("Skipping integration test (set E2E_TEST=1 and run without -short)") } kafkaURL := os.Getenv("KAFKA_URL") @@ -93,8 +91,7 @@ func TestIntegration_ParserRegistryOperations(t *testing.T) { alertTopic := "test-alertbeacons-registry-" + time.Now().Format("20060102150405") // Setup - appState := appcontext.NewAppState() - parserRegistry := &model.ParserRegistry{} + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} writer := kafka.NewWriter(kafka.WriterConfig{ Brokers: []string{kafkaURL}, @@ -108,10 +105,10 @@ func TestIntegration_ParserRegistryOperations(t *testing.T) { Name: "kafka-test-parser", Config: model.Config{ Name: "kafka-test-parser", - Prefix: "02", - Length: 2, - MinLength: 2, - MaxLength: 20, + Min: 2, + Max: 20, + Pattern: []string{"0x02", "0x01"}, + Configs: map[string]model.ParserConfig{}, }, } @@ -139,8 +136,8 @@ func TestIntegration_ParserRegistryOperations(t *testing.T) { // TestIntegration_MultipleBeaconsSequential tests processing multiple beacons func TestIntegration_MultipleBeaconsSequential(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test in short mode") + if testing.Short() || os.Getenv("E2E_TEST") != "1" { + t.Skip("Skipping integration test (set E2E_TEST=1 and run without -short)") } kafkaURL := os.Getenv("KAFKA_URL") @@ -152,15 +149,15 @@ func TestIntegration_MultipleBeaconsSequential(t *testing.T) { // Setup appState := appcontext.NewAppState() - parserRegistry := &model.ParserRegistry{} + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} // Register parser config := model.Config{ - Name: "multi-test-parser", - Prefix: "02", - Length: 2, - MinLength: 2, - MaxLength: 20, + Name: "multi-test-parser", + Min: 2, + Max: 20, + Pattern: []string{"0x02", "0x01"}, + Configs: map[string]model.ParserConfig{}, } parserRegistry.Register("multi-test-parser", config) @@ -207,8 +204,8 @@ func TestIntegration_MultipleBeaconsSequential(t *testing.T) { // TestIntegration_EventDeduplication tests that duplicate events are not published func TestIntegration_EventDeduplication(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test in short mode") + if testing.Short() || os.Getenv("E2E_TEST") != "1" { + t.Skip("Skipping integration test (set E2E_TEST=1 and run without -short)") } kafkaURL := os.Getenv("KAFKA_URL") @@ -220,15 +217,15 @@ func TestIntegration_EventDeduplication(t *testing.T) { // Setup appState := appcontext.NewAppState() - parserRegistry := &model.ParserRegistry{} + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} // Register parser config := model.Config{ - Name: "dedup-test-parser", - Prefix: "02", - Length: 2, - MinLength: 2, - MaxLength: 20, + Name: "dedup-test-parser", + Min: 2, + Max: 20, + Pattern: []string{"0x02", "0x01"}, + Configs: map[string]model.ParserConfig{}, } parserRegistry.Register("dedup-test-parser", config) @@ -291,8 +288,8 @@ func TestIntegration_EventDeduplication(t *testing.T) { // TestIntegration_AppStatePersistence tests that events persist in AppState func TestIntegration_AppStatePersistence(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test in short mode") + if testing.Short() || os.Getenv("E2E_TEST") != "1" { + t.Skip("Skipping integration test (set E2E_TEST=1 and run without -short)") } kafkaURL := os.Getenv("KAFKA_URL") @@ -304,14 +301,14 @@ func TestIntegration_AppStatePersistence(t *testing.T) { // Setup appState := appcontext.NewAppState() - parserRegistry := &model.ParserRegistry{} + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} config := model.Config{ - Name: "persist-test-parser", - Prefix: "02", - Length: 2, - MinLength: 2, - MaxLength: 20, + Name: "persist-test-parser", + Min: 2, + Max: 20, + Pattern: []string{"0x02", "0x01"}, + Configs: map[string]model.ParserConfig{}, } parserRegistry.Register("persist-test-parser", config) @@ -352,8 +349,8 @@ func TestIntegration_AppStatePersistence(t *testing.T) { // TestIntegration_ParserUpdateFlow tests updating parsers during runtime func TestIntegration_ParserUpdateFlow(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test in short mode") + if testing.Short() || os.Getenv("E2E_TEST") != "1" { + t.Skip("Skipping integration test (set E2E_TEST=1 and run without -short)") } kafkaURL := os.Getenv("KAFKA_URL") @@ -365,7 +362,7 @@ func TestIntegration_ParserUpdateFlow(t *testing.T) { // Setup appState := appcontext.NewAppState() - parserRegistry := &model.ParserRegistry{} + parserRegistry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} writer := kafka.NewWriter(kafka.WriterConfig{ Brokers: []string{kafkaURL}, @@ -375,11 +372,11 @@ func TestIntegration_ParserUpdateFlow(t *testing.T) { // Initial parser config config1 := model.Config{ - Name: "update-test-parser", - Prefix: "02", - Length: 2, - MinLength: 2, - MaxLength: 20, + Name: "update-test-parser", + Min: 2, + Max: 20, + Pattern: []string{"0x02", "0x01"}, + Configs: map[string]model.ParserConfig{}, } parserRegistry.Register("update-test-parser", config1) @@ -395,10 +392,10 @@ func TestIntegration_ParserUpdateFlow(t *testing.T) { // Update parser config config2 := model.Config{ Name: "update-test-parser", - Prefix: "03", - Length: 3, - MinLength: 3, - MaxLength: 25, + Min: 3, + Max: 25, + Pattern: []string{"0x03"}, + Configs: map[string]model.ParserConfig{}, } parserRegistry.Register("update-test-parser", config2) diff --git a/tests/decoder/parser_registry_test.go b/tests/decoder/parser_registry_test.go index 9aca00b..4335791 100644 --- a/tests/decoder/parser_registry_test.go +++ b/tests/decoder/parser_registry_test.go @@ -6,107 +6,76 @@ import ( "github.com/AFASystems/presence/internal/pkg/model" ) -func TestParserRegistry_AddParser(t *testing.T) { - // Setup - registry := &model.ParserRegistry{} +func newParserRegistry() *model.ParserRegistry { + return &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} +} - // Add a parser +func TestParserRegistry_AddParser(t *testing.T) { + registry := newParserRegistry() config := model.Config{ - Name: "test-parser", - Prefix: "02", - Length: 2, + Name: "test-parser", + Min: 4, + Max: 20, + Pattern: []string{"0x02", "0x01"}, + Configs: map[string]model.ParserConfig{}, } registry.Register("test-parser", config) - // Verify parser was added if len(registry.ParserList) != 1 { t.Errorf("Expected 1 parser in registry, got %d", len(registry.ParserList)) } - if _, exists := registry.ParserList["test-parser"]; !exists { t.Error("Parser 'test-parser' should exist in registry") } } func TestParserRegistry_RemoveParser(t *testing.T) { - // Setup - registry := &model.ParserRegistry{} - - config := model.Config{ - Name: "test-parser", - Prefix: "02", - Length: 2, - } - + registry := newParserRegistry() + config := model.Config{Name: "test-parser", Min: 2, Max: 10, Pattern: []string{"0x02"}} registry.Register("test-parser", config) - // Remove parser registry.Unregister("test-parser") - // Verify parser was removed if len(registry.ParserList) != 0 { t.Errorf("Expected 0 parsers in registry, got %d", len(registry.ParserList)) } - if _, exists := registry.ParserList["test-parser"]; exists { t.Error("Parser 'test-parser' should not exist in registry") } } func TestParserRegistry_UpdateParser(t *testing.T) { - // Setup - registry := &model.ParserRegistry{} - - // Add initial parser - config1 := model.Config{ - Name: "test-parser", - Prefix: "02", - Length: 2, - } + registry := newParserRegistry() + config1 := model.Config{Name: "test-parser", Min: 2, Max: 10, Pattern: []string{"0x02"}} + config2 := model.Config{Name: "test-parser", Min: 5, Max: 15, Pattern: []string{"0x03"}} registry.Register("test-parser", config1) - - // Update parser - config2 := model.Config{ - Name: "test-parser", - Prefix: "03", - Length: 3, - } - registry.Register("test-parser", config2) - // Verify only one parser exists if len(registry.ParserList) != 1 { t.Errorf("Expected 1 parser in registry, got %d", len(registry.ParserList)) } - - // Verify it was updated (the new config should be used) if _, exists := registry.ParserList["test-parser"]; !exists { t.Error("Parser 'test-parser' should exist in registry") } } func TestParserRegistry_MultipleParsers(t *testing.T) { - // Setup - registry := &model.ParserRegistry{} - - // Add multiple parsers + registry := newParserRegistry() parsers := []model.Config{ - {Name: "parser-1", Prefix: "02", Length: 2}, - {Name: "parser-2", Prefix: "03", Length: 3}, - {Name: "parser-3", Prefix: "04", Length: 4}, + {Name: "parser-1", Min: 2, Max: 10, Pattern: []string{"0x02"}}, + {Name: "parser-2", Min: 3, Max: 15, Pattern: []string{"0x03"}}, + {Name: "parser-3", Min: 4, Max: 20, Pattern: []string{"0x04"}}, } for _, p := range parsers { registry.Register(p.Name, p) } - // Verify all parsers were added if len(registry.ParserList) != 3 { t.Errorf("Expected 3 parsers in registry, got %d", len(registry.ParserList)) } - for _, p := range parsers { if _, exists := registry.ParserList[p.Name]; !exists { t.Errorf("Parser '%s' should exist in registry", p.Name) @@ -115,161 +84,19 @@ func TestParserRegistry_MultipleParsers(t *testing.T) { } func TestParserRegistry_RemoveNonExistent(t *testing.T) { - // Setup - registry := &model.ParserRegistry{} - - // Try to remove non-existent parser - should not panic + registry := newParserRegistry() registry.Unregister("non-existent") - // Verify registry is still empty if len(registry.ParserList) != 0 { t.Errorf("Expected 0 parsers, got %d", len(registry.ParserList)) } } -func TestParserRegistry_ConcurrentAccess(t *testing.T) { - // Setup - registry := &model.ParserRegistry{} - done := make(chan bool) - - // Concurrent additions - for i := 0; i < 10; i++ { - go func(index int) { - config := model.Config{ - Name: "parser-" + string(rune('A'+index)), - Prefix: "02", - Length: 2, - } - registry.Register(config.Name, config) - done <- true - }(i) - } - - // Wait for all goroutines - for i := 0; i < 10; i++ { - <-done - } - - // Verify all parsers were added - if len(registry.ParserList) != 10 { - t.Errorf("Expected 10 parsers, got %d", len(registry.ParserList)) - } -} - -func TestParserConfig_Structure(t *testing.T) { - config := model.Config{ - Name: "test-config", - Prefix: "0201", - MinLength: 10, - MaxLength: 30, - ParserType: "sensor", - } - - if config.Name != "test-config" { - t.Errorf("Expected name 'test-config', got '%s'", config.Name) - } - - if config.Prefix != "0201" { - t.Errorf("Expected prefix '0201', got '%s'", config.Prefix) - } - - if config.MinLength != 10 { - t.Errorf("Expected MinLength 10, got %d", config.MinLength) - } - - if config.MaxLength != 30 { - t.Errorf("Expected MaxLength 30, got %d", config.MaxLength) - } -} - -func TestKafkaParser_MessageTypes(t *testing.T) { - testCases := []struct { - name string - id string - config model.Config - expected string - }{ - { - name: "add parser", - id: "add", - config: model.Config{Name: "new-parser", Prefix: "02", Length: 2}, - expected: "add", - }, - { - name: "delete parser", - id: "delete", - config: model.Config{Name: "old-parser", Prefix: "02", Length: 2}, - expected: "delete", - }, - { - name: "update parser", - id: "update", - config: model.Config{Name: "updated-parser", Prefix: "03", Length: 3}, - expected: "update", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - msg := model.KafkaParser{ - ID: tc.id, - Name: tc.config.Name, - Config: tc.config, - } - - if msg.ID != tc.expected { - t.Errorf("Expected ID '%s', got '%s'", tc.expected, msg.ID) - } - - if msg.Name != tc.config.Name { - t.Errorf("Expected Name '%s', got '%s'", tc.config.Name, msg.Name) - } - }) - } -} - func TestParserRegistry_EmptyRegistry(t *testing.T) { - // Setup empty registry - registry := &model.ParserRegistry{} + registry := newParserRegistry() - // Verify it's empty if len(registry.ParserList) != 0 { t.Errorf("Expected empty registry, got %d parsers", len(registry.ParserList)) } - - // Should be safe to call Unregister on empty registry registry.Unregister("anything") } - -func TestParserRegistry_ParserReplacement(t *testing.T) { - // Setup - registry := &model.ParserRegistry{} - - // Add parser with config 1 - config1 := model.Config{ - Name: "test-parser", - Prefix: "02", - Length: 2, - } - - registry.Register("test-parser", config1) - - // Replace with config 2 (same name) - config2 := model.Config{ - Name: "test-parser", - Prefix: "03", - Length: 3, - } - - registry.Register("test-parser", config2) - - // Verify only one entry exists - if len(registry.ParserList) != 1 { - t.Errorf("Expected 1 parser after replacement, got %d", len(registry.ParserList)) - } - - // Verify the parser still exists - if _, exists := registry.ParserList["test-parser"]; !exists { - t.Error("Parser 'test-parser' should still exist") - } -} diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go new file mode 100644 index 0000000..54c875b --- /dev/null +++ b/tests/e2e/e2e_test.go @@ -0,0 +1,17 @@ +// Package e2e contains end-to-end tests that require external services (Kafka, MQTT, DB). +// Run with: go test -v ./tests/e2e/... +// These tests are skipped by default unless E2E_TEST=1 is set. + +package e2e + +import ( + "os" + "testing" +) + +func TestE2E_SkipByDefault(t *testing.T) { + if os.Getenv("E2E_TEST") != "1" { + t.Skip("Skipping e2e tests (set E2E_TEST=1 to run)") + } + t.Log("E2E tests would run with Kafka, MQTT, and PostgreSQL available") +} diff --git a/tests/kafkaclient/manager_test.go b/tests/kafkaclient/manager_test.go new file mode 100644 index 0000000..99574a0 --- /dev/null +++ b/tests/kafkaclient/manager_test.go @@ -0,0 +1,87 @@ +package kafkaclient + +import ( + "os" + "testing" + + "github.com/AFASystems/presence/internal/pkg/kafkaclient" +) + +func TestInitKafkaManager(t *testing.T) { + m := kafkaclient.InitKafkaManager() + if m == nil { + t.Fatal("InitKafkaManager returned nil") + } +} + +func TestPopulateKafkaManager_Writers(t *testing.T) { + if os.Getenv("E2E_TEST") != "1" { + t.Skip("Kafka manager tests require E2E_TEST=1 (Kafka connection)") + } + m := kafkaclient.InitKafkaManager() + topics := []string{"topic1", "topic2"} + m.PopulateKafkaManager("localhost:9092", "", topics) + + if m.GetWriter("topic1") == nil { + t.Error("Expected writer for topic1") + } + if m.GetWriter("topic2") == nil { + t.Error("Expected writer for topic2") + } + if m.GetWriter("nonexistent") != nil { + t.Error("Expected nil for nonexistent topic") + } + + m.CleanKafkaWriters() +} + +func TestPopulateKafkaManager_Readers(t *testing.T) { + if os.Getenv("E2E_TEST") != "1" { + t.Skip("Kafka manager tests require E2E_TEST=1") + } + m := kafkaclient.InitKafkaManager() + topics := []string{"topic1", "topic2"} + m.PopulateKafkaManager("localhost:9092", "test-group", topics) + + if m.GetReader("topic1") == nil { + t.Error("Expected reader for topic1") + } + if m.GetReader("topic2") == nil { + t.Error("Expected reader for topic2") + } + if m.GetReader("nonexistent") != nil { + t.Error("Expected nil for nonexistent topic") + } + + m.CleanKafkaReaders() +} + +func TestAddKafkaWriter(t *testing.T) { + if os.Getenv("E2E_TEST") != "1" { + t.Skip("Kafka manager tests require E2E_TEST=1") + } + m := kafkaclient.InitKafkaManager() + m.AddKafkaWriter("localhost:9092", "test-topic") + + w := m.GetWriter("test-topic") + if w == nil { + t.Error("Expected writer after AddKafkaWriter") + } + + m.CleanKafkaWriters() +} + +func TestAddKafkaReader(t *testing.T) { + if os.Getenv("E2E_TEST") != "1" { + t.Skip("Kafka manager tests require E2E_TEST=1") + } + m := kafkaclient.InitKafkaManager() + m.AddKafkaReader("localhost:9092", "test-topic", "test-group") + + r := m.GetReader("test-topic") + if r == nil { + t.Error("Expected reader after AddKafkaReader") + } + + m.CleanKafkaReaders() +} diff --git a/tests/location/location_test.go b/tests/location/location_test.go new file mode 100644 index 0000000..07527ca --- /dev/null +++ b/tests/location/location_test.go @@ -0,0 +1,41 @@ +package location + +import ( + "testing" + + "github.com/AFASystems/presence/internal/pkg/common/utils" + "github.com/AFASystems/presence/internal/pkg/model" +) + +// Test location algorithm scoring formula: seenW + (rssiW * (1.0 - (rssi / -100.0))) +func TestLocationScoringFormula(t *testing.T) { + seenW := 1.5 + rssiW := 0.75 + + tests := []struct { + rssi int64 + wantMin float64 + wantMax float64 + }{ + {-50, 1.85, 1.9}, // 1.5 + 0.75*0.5 = 1.875 + {-100, 1.45, 1.55}, // 1.5 + 0.75*0 = 1.5 + {-80, 1.6, 1.7}, // 1.5 + 0.75*0.2 = 1.65 + } + for _, tt := range tests { + score := seenW + (rssiW * (1.0 - (float64(tt.rssi) / -100.0))) + if score < tt.wantMin || score > tt.wantMax { + t.Errorf("RSSI %d: score %f outside expected [%f, %f]", tt.rssi, score, tt.wantMin, tt.wantMax) + } + } +} + +func TestCalculateDistance_ForLocation(t *testing.T) { + adv := model.BeaconAdvertisement{ + RSSI: -65, + TXPower: "C5", + } + d := utils.CalculateDistance(adv) + if d < 0 { + t.Errorf("Distance should be non-negative, got %f", d) + } +} diff --git a/tests/logger/logger_test.go b/tests/logger/logger_test.go new file mode 100644 index 0000000..b94231f --- /dev/null +++ b/tests/logger/logger_test.go @@ -0,0 +1,28 @@ +package logger + +import ( + "os" + "path/filepath" + "testing" + + "github.com/AFASystems/presence/internal/pkg/logger" +) + +func TestCreateLogger(t *testing.T) { + tmpDir := t.TempDir() + logFile := filepath.Join(tmpDir, "test.log") + + log, cleanup := logger.CreateLogger(logFile) + if log == nil { + t.Fatal("CreateLogger returned nil logger") + } + if cleanup == nil { + t.Fatal("CreateLogger returned nil cleanup") + } + + cleanup() + + if _, err := os.Stat(logFile); os.IsNotExist(err) { + t.Error("Log file was not created") + } +} diff --git a/tests/model/model_test.go b/tests/model/model_test.go new file mode 100644 index 0000000..6e2c6b8 --- /dev/null +++ b/tests/model/model_test.go @@ -0,0 +1,106 @@ +package model + +import ( + "encoding/json" + "testing" + + "github.com/AFASystems/presence/internal/pkg/model" +) + +func TestBeaconEvent_Hash(t *testing.T) { + e := model.BeaconEvent{ + ID: "beacon-1", + Name: "beacon-1", + Type: "iBeacon", + Battery: 85, + Event: 1, + } + hash := e.Hash() + if len(hash) == 0 { + t.Error("Expected non-empty hash") + } + + // Same event should produce same hash + hash2 := e.Hash() + if string(hash) != string(hash2) { + t.Error("Hash should be deterministic") + } +} + +func TestBeaconEvent_Hash_BatteryRounded(t *testing.T) { + e1 := model.BeaconEvent{ID: "1", Battery: 84, Event: 1} + e2 := model.BeaconEvent{ID: "1", Battery: 89, Event: 1} + hash1 := e1.Hash() + hash2 := e2.Hash() + // Battery is rounded to nearest 10, so 84 and 89 should produce same hash + if string(hash1) != string(hash2) { + t.Error("Battery rounding should make 84 and 89 produce same hash") + } +} + +func TestBeaconEvent_ToJSON(t *testing.T) { + e := model.BeaconEvent{ + ID: "beacon-1", + Name: "Test", + Type: "iBeacon", + Battery: 100, + } + data, err := e.ToJSON() + if err != nil { + t.Fatalf("ToJSON failed: %v", err) + } + var decoded model.BeaconEvent + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if decoded.ID != e.ID || decoded.Battery != e.Battery { + t.Errorf("Decoded mismatch: got %+v", decoded) + } +} + +func TestParserRegistry_RegisterAndUnregister(t *testing.T) { + registry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} + config := model.Config{ + Name: "test-parser", + Min: 4, + Max: 20, + Pattern: []string{"0x02", "0x01"}, + Configs: map[string]model.ParserConfig{ + "battery": {Length: 1, Offset: 2, Order: "littleendian"}, + }, + } + + registry.Register("test-parser", config) + if len(registry.ParserList) != 1 { + t.Errorf("Expected 1 parser, got %d", len(registry.ParserList)) + } + + registry.Unregister("test-parser") + if len(registry.ParserList) != 0 { + t.Errorf("Expected 0 parsers after Unregister, got %d", len(registry.ParserList)) + } +} + +func TestParserRegistry_UpdateOverwrites(t *testing.T) { + registry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} + config1 := model.Config{Name: "p1", Min: 2, Max: 10, Pattern: []string{"0x02"}} + config2 := model.Config{Name: "p1", Min: 5, Max: 15, Pattern: []string{"0x03"}} + + registry.Register("p1", config1) + registry.Register("p1", config2) + + if len(registry.ParserList) != 1 { + t.Errorf("Expected 1 parser after update, got %d", len(registry.ParserList)) + } +} + +func TestConfig_GetPatternBytes(t *testing.T) { + config := model.Config{Pattern: []string{"0xFF", "0x4C", "0x00"}} + bytes := config.GetPatternBytes() + if len(bytes) != 3 { + t.Fatalf("Expected 3 bytes, got %d", len(bytes)) + } + if bytes[0] != 0xFF || bytes[1] != 0x4C || bytes[2] != 0x00 { + t.Errorf("Expected [0xFF, 0x4C, 0x00], got %v", bytes) + } +} diff --git a/tests/service/service_test.go b/tests/service/service_test.go new file mode 100644 index 0000000..4573cbb --- /dev/null +++ b/tests/service/service_test.go @@ -0,0 +1,99 @@ +package service + +import ( + "context" + "encoding/json" + "testing" + + "github.com/AFASystems/presence/internal/pkg/model" + "github.com/AFASystems/presence/internal/pkg/service" + "github.com/segmentio/kafka-go" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupTestDB(t *testing.T) *gorm.DB { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("Failed to open test DB: %v", err) + } + if err := db.AutoMigrate(&model.Gateway{}, &model.Zone{}, &model.Tracker{}, &model.TrackerZones{}, &model.Tracks{}); err != nil { + t.Fatalf("Failed to migrate: %v", err) + } + return db +} + +func TestSendParserConfig(t *testing.T) { + mockWriter := &mockKafkaWriter{} + kp := model.KafkaParser{ + ID: "add", + Name: "test-parser", + Config: model.Config{ + Name: "test-parser", + Min: 4, + Max: 20, + Pattern: []string{"0x02", "0x01"}, + Configs: map[string]model.ParserConfig{}, + }, + } + ctx := context.Background() + + err := service.SendParserConfig(kp, mockWriter, ctx) + if err != nil { + t.Fatalf("SendParserConfig failed: %v", err) + } + if len(mockWriter.messages) != 1 { + t.Errorf("Expected 1 Kafka message, got %d", len(mockWriter.messages)) + } + var decoded model.KafkaParser + if err := json.Unmarshal(mockWriter.messages[0].Value, &decoded); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if decoded.ID != "add" || decoded.Config.Name != "test-parser" { + t.Errorf("Expected add/test-parser, got %s/%s", decoded.ID, decoded.Config.Name) + } +} + +func TestLocationToBeaconService_EmptyID(t *testing.T) { + db := setupTestDB(t) + mockWriter := &mockKafkaWriter{} + ctx := context.Background() + + msg := model.HTTPLocation{ID: "", Location: "gateway1"} + service.LocationToBeaconService(msg, db, mockWriter, ctx) + // Should return early, no panic +} + +func TestLocationToBeaconService_WithValidData(t *testing.T) { + db := setupTestDB(t) + // Create prerequisite data (formatMac converts AABBCCDDEEFF -> AA:BB:CC:DD:EE:FF) + db.Create(&model.Gateway{ID: "gw-1", MAC: "AA:BB:CC:DD:EE:FF", Floor: "1", Building: "B1", X: 10, Y: 20}) + db.Create(&model.Tracker{ID: "tr-1", MAC: "112233445566"}) + db.Create(&model.TrackerZones{ID: "tz-1", Tracker: "tr-1", ZoneList: []string{"gw-1"}}) + + mockWriter := &mockKafkaWriter{} + ctx := context.Background() + + msg := model.HTTPLocation{ + ID: "tr-1", + Location: "AABBCCDDEEFF", + Distance: 2.5, + RSSI: -65, + } + service.LocationToBeaconService(msg, db, mockWriter, ctx) + + var tracker model.Tracker + db.Where("id = ?", "tr-1").First(&tracker) + if tracker.Location != "gw-1" { + t.Errorf("Expected tracker location gw-1, got %s", tracker.Location) + } +} + +type mockKafkaWriter struct { + messages []kafka.Message +} + +func (m *mockKafkaWriter) WriteMessages(ctx context.Context, msgs ...kafka.Message) error { + m.messages = append(m.messages, msgs...) + return nil +} diff --git a/tests/utils/utils_test.go b/tests/utils/utils_test.go new file mode 100644 index 0000000..97e2843 --- /dev/null +++ b/tests/utils/utils_test.go @@ -0,0 +1,110 @@ +package utils + +import ( + "testing" + + "github.com/AFASystems/presence/internal/pkg/common/utils" + "github.com/AFASystems/presence/internal/pkg/model" +) + +func TestParseADFast_Empty(t *testing.T) { + result := utils.ParseADFast([]byte{}) + if len(result) != 0 { + t.Errorf("Expected empty result, got %d structures", len(result)) + } +} + +func TestParseADFast_SingleStructure(t *testing.T) { + // Length 2, type 0x01, data 0x06 -> [0,3) + data := []byte{0x02, 0x01, 0x06} + result := utils.ParseADFast(data) + if len(result) != 1 { + t.Fatalf("Expected 1 structure, got %d", len(result)) + } + if result[0][0] != 0 || result[0][1] != 3 { + t.Errorf("Expected [0,3), got [%d,%d)", result[0][0], result[0][1]) + } +} + +func TestParseADFast_MultipleStructures(t *testing.T) { + // First: len=2 (bytes 0-2), Second: len=3 (bytes 3-6) + data := []byte{0x02, 0x01, 0x06, 0x03, 0xFF, 0x4C, 0x00} + result := utils.ParseADFast(data) + if len(result) != 2 { + t.Fatalf("Expected 2 structures, got %d", len(result)) + } + if result[0][0] != 0 || result[0][1] != 3 { + t.Errorf("First structure: expected [0,3), got [%d,%d)", result[0][0], result[0][1]) + } + if result[1][0] != 3 || result[1][1] != 7 { + t.Errorf("Second structure: expected [3,7), got [%d,%d)", result[1][0], result[1][1]) + } +} + +func TestParseADFast_ZeroLengthBreaks(t *testing.T) { + data := []byte{0x00, 0x01, 0x02} + result := utils.ParseADFast(data) + if len(result) != 0 { + t.Errorf("Expected 0 structures (zero length breaks), got %d", len(result)) + } +} + +func TestRemoveFlagBytes_WithFlags(t *testing.T) { + // AD structure: len=2, type=0x01 (flags), data=0x06 + // Remaining: 0x03, 0xFF, 0x4C + data := []byte{0x02, 0x01, 0x06, 0x03, 0xFF, 0x4C, 0x00} + result := utils.RemoveFlagBytes(data) + if len(result) != 4 { + t.Errorf("Expected 4 bytes after flag removal, got %d", len(result)) + } + if result[0] != 0x03 && result[1] != 0xFF { + t.Errorf("Expected flag bytes removed, got %v", result) + } +} + +func TestRemoveFlagBytes_WithoutFlags(t *testing.T) { + data := []byte{0x02, 0xFF, 0x4C, 0x00} // type 0xFF, not 0x01 + result := utils.RemoveFlagBytes(data) + if len(result) != 4 { + t.Errorf("Expected unchanged 4 bytes, got %d", len(result)) + } +} + +func TestRemoveFlagBytes_TooShort(t *testing.T) { + data := []byte{0x01} + result := utils.RemoveFlagBytes(data) + if len(result) != 1 { + t.Errorf("Expected 1 byte, got %d", len(result)) + } +} + +func TestCalculateDistance(t *testing.T) { + tests := []struct { + name string + rssi int64 + txPower string + }{ + {"typical beacon", -65, "C5"}, // -59 in two's complement + {"weak signal", -90, "C5"}, + {"strong signal", -40, "C5"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + adv := model.BeaconAdvertisement{RSSI: tt.rssi, TXPower: tt.txPower} + d := utils.CalculateDistance(adv) + if d < 0 { + t.Errorf("Distance should be non-negative, got %f", d) + } + }) + } +} + +func TestLoopADStructures_NoParsers(t *testing.T) { + registry := &model.ParserRegistry{ParserList: make(map[string]model.BeaconParser)} + data := []byte{0x02, 0x01, 0x06} + indices := utils.ParseADFast(data) + event := utils.LoopADStructures(data, indices, "beacon-1", registry) + if event.ID != "" { + t.Errorf("Expected empty event with no parsers, got ID %s", event.ID) + } +}