diff --git a/.gitignore b/.gitignore index 6f72f89..1071b66 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ go.work.sum # env file .env +vivotek_events.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b2ff521 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM golang:1.22-alpine AS builder +EXPOSE 8080 + +WORKDIR /app + +# Copy go.mod and go.sum files +COPY go.mod ./ +COPY go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy source code +#COPY *.go ./ + +# Copy source code +COPY ./cmd ./cmd + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -o nvr-api cmd/apisrv/main.go + +# Create a minimal production image +FROM alpine:latest + +WORKDIR /app + +# Copy the executable from the builder stage +COPY --from=builder /app/nvr-api . + +# Copy config file +COPY config.sample.json /app/config.json + +# Create directory for logs +RUN mkdir -p /app/logs + + +# Run the application +CMD ["./nvr-api"] \ No newline at end of file diff --git a/README.md b/README.md index 7405e6e..4df0f96 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,120 @@ # nvr-notify-api -A Notify API rest service that can be used with an NVR + +A Go-based API server that receives and processes HTTP event notifications from Vivotek Network Video Recorders (NVRs). + +## Features + +- Receives event notifications from Vivotek NVR devices +- Processes different event types (motion detection, video loss, device connection) +- Configurable logging +- Optional HTTP Basic Authentication +- Event forwarding to external notification services +- Telegram integration for instant notifications +- Health check endpoint +- Docker support + +## Configuration + +The application can be configured using the `config.json` file: + +```json +{ + "server_port": "8080", + "log_file": "vivotek_events.log", + "notify_url": "https://your-notification-service.com/webhook", + "auth_username": "admin", + "auth_password": "your-secure-password", + "telegram_enabled": true, + "telegram_token": "YOUR_TELEGRAM_BOT_TOKEN", + "telegram_chat_id": "YOUR_CHAT_ID" +} +``` + +Configuration options: +- `server_port`: Port the HTTP server will listen on +- `log_file`: Path to log file (use "stdout" to log to console) +- `notify_url`: Optional URL to forward events to +- `auth_username` and `auth_password`: Optional Basic Authentication credentials +- `telegram_enabled`: Set to true to enable Telegram notifications +- `telegram_token`: Your Telegram bot token (obtained from @BotFather) +- `telegram_chat_id`: Your Telegram chat ID where notifications should be sent + +## API Endpoints + +- `/event` or `/events`: POST endpoint for receiving event notifications +- `/health`: GET endpoint to check service status + +## Event Format + +The API expects events in JSON format: + +```json +{ + "eventType": "MotionDetection", + "eventTime": "2023-06-15T14:30:00Z", + "deviceId": "NVR123456", + "channelId": "Camera01", + "eventDetails": { + "zoneId": "Zone1", + "confidence": 85 + } +} +``` + +## Running the Application + +### Directly + +```bash +go mod tidy +go build +./vivotek-nvr-api +``` + +### Using Docker + +```bash +# Build the Docker image +docker build -t vivotek-nvr-api . + +# Run the container +docker run -p 8080:8080 -v ./config.json:/app/config.json -v ./logs:/app/logs vivotek-nvr-api +``` + +## Configuring Vivotek NVR + +To configure your Vivotek NVR to send events to this API: + +1. Access your NVR's web interface +2. Navigate to Configuration > Event > HTTP Notification +3. Enable HTTP notifications +4. Set the URL to `http://your-server-ip:8080/event` +5. Set the authentication method if you've configured it in the API +6. Select the events you want to be notified about +7. Save the configuration + +## Extending the API + +To handle additional event types, modify the `processEvent` function in the main Go file and add appropriate handler functions. + +## Setting Up Telegram Notifications + +1. Create a Telegram bot: + - Start a chat with [@BotFather](https://t.me/botfather) on Telegram + - Send the command `/newbot` and follow the instructions + - Once created, BotFather will provide a token - copy this to your config file + +2. Get your chat ID: + - Option 1: Start a chat with your bot and send a message to it + - Option 2: Send a message to [@userinfobot](https://t.me/userinfobot) to get your chat ID + - Option 3: For group chats, add [@RawDataBot](https://t.me/RawDataBot) to your group briefly + +3. Update your config file: + - Set `telegram_enabled` to `true` + - Add your bot token to `telegram_token` + - Add your chat ID to `telegram_chat_id` + +4. Test the configuration: + - Start the API server + - Trigger an event from your Vivotek NVR + - You should receive a formatted message in your Telegram chat \ No newline at end of file diff --git a/cmd/apisrv/main.go b/cmd/apisrv/main.go new file mode 100644 index 0000000..fac30f8 --- /dev/null +++ b/cmd/apisrv/main.go @@ -0,0 +1,316 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "time" +) + +// Configuration for the application +type Config struct { + ServerPort string `json:"server_port"` + LogFile string `json:"log_file"` + NotifyURL string `json:"notify_url"` + AuthUsername string `json:"auth_username"` + AuthPassword string `json:"auth_password"` + TelegramEnabled bool `json:"telegram_enabled"` + TelegramToken string `json:"telegram_token"` + TelegramChatID string `json:"telegram_chat_id"` +} + +// VivotekEvent represents the event data structure from Vivotek NVR +type VivotekEvent struct { + EventType string `json:"eventType"` + EventTime time.Time `json:"eventTime"` + DeviceID string `json:"deviceId"` + ChannelID string `json:"channelId"` + EventDetails map[string]interface{} `json:"eventDetails"` + // Add more fields as needed based on Vivotek's event structure +} + +// GlobalState maintains the application state +type GlobalState struct { + Config Config + EventCount int + Logger *log.Logger +} + +var state GlobalState + +// initConfig loads configuration from a JSON file +func initConfig() error { + // Default configuration + state.Config = Config{ + ServerPort: "8080", + LogFile: "vivotek_events.log", + } + + // Try to load from config file if it exists + configFile, err := os.Open("config.json") + if err == nil { + defer configFile.Close() + decoder := json.NewDecoder(configFile) + err = decoder.Decode(&state.Config) + if err != nil { + return fmt.Errorf("error parsing config file: %v", err) + } + } + + // Initialize logger + var logOutput io.Writer + if state.Config.LogFile == "stdout" { + logOutput = os.Stdout + } else { + file, err := os.OpenFile(state.Config.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + return fmt.Errorf("failed to open log file: %v", err) + } + logOutput = file + } + + state.Logger = log.New(logOutput, "VIVOTEK-API: ", log.LstdFlags) + return nil +} + +// basicAuth implements HTTP Basic Authentication middleware +func basicAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Skip auth if credentials are not configured + if state.Config.AuthUsername == "" || state.Config.AuthPassword == "" { + next(w, r) + return + } + + username, password, ok := r.BasicAuth() + if !ok || username != state.Config.AuthUsername || password != state.Config.AuthPassword { + w.Header().Set("WWW-Authenticate", `Basic realm="Vivotek NVR API"`) + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("Unauthorized")) + return + } + + next(w, r) + } +} + +// handleEvent processes events from Vivotek NVR +func handleEvent(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write([]byte("Only POST method is supported")) + return + } + + // Read the request body + body, err := io.ReadAll(r.Body) + if err != nil { + state.Logger.Printf("Error reading request body: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + // Parse the event + var event VivotekEvent + if err := json.Unmarshal(body, &event); err != nil { + state.Logger.Printf("Error parsing event JSON: %v", err) + state.Logger.Printf("Raw payload: %s", string(body)) + w.WriteHeader(http.StatusBadRequest) + return + } + + // Log the event + state.EventCount++ + state.Logger.Printf("Received event #%d: Type=%s, Device=%s, Channel=%s", + state.EventCount, event.EventType, event.DeviceID, event.ChannelID) + + // Process the event based on type + processEvent(&event) + + // Respond with success + w.WriteHeader(http.StatusOK) + response := map[string]interface{}{ + "status": "success", + "message": "Event processed successfully", + "eventId": state.EventCount, + } + + json.NewEncoder(w).Encode(response) +} + +// processEvent handles different event types +func processEvent(event *VivotekEvent) { + switch event.EventType { + case "MotionDetection": + handleMotionEvent(event) + case "VideoLoss": + handleVideoLossEvent(event) + case "DeviceConnection": + handleConnectionEvent(event) + default: + state.Logger.Printf("Unhandled event type: %s", event.EventType) + } + + // Forward to notification URL if configured + if state.Config.NotifyURL != "" { + forwardEvent(event) + } + + // Send to Telegram if enabled + if state.Config.TelegramEnabled && state.Config.TelegramToken != "" && state.Config.TelegramChatID != "" { + sendTelegramNotification(event) + } +} + +// handleMotionEvent processes motion detection events +func handleMotionEvent(event *VivotekEvent) { + state.Logger.Printf("Motion detected on device %s, channel %s", event.DeviceID, event.ChannelID) + // Add custom processing for motion events +} + +// handleVideoLossEvent processes video loss events +func handleVideoLossEvent(event *VivotekEvent) { + state.Logger.Printf("Video lost on device %s, channel %s", event.DeviceID, event.ChannelID) + // Add custom processing for video loss events +} + +// handleConnectionEvent processes device connection/disconnection events +func handleConnectionEvent(event *VivotekEvent) { + state.Logger.Printf("Connection event for device %s", event.DeviceID) + // Add custom processing for connection events +} + +// forwardEvent sends the event to a configured notification URL +func forwardEvent(event *VivotekEvent) { + eventJSON, err := json.Marshal(event) + if err != nil { + state.Logger.Printf("Error serializing event for forwarding: %v", err) + return + } + + resp, err := http.Post(state.Config.NotifyURL, "application/json", bytes.NewBuffer(eventJSON)) + if err != nil { + state.Logger.Printf("Error forwarding event: %v", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + state.Logger.Printf("Error response from notification URL: %d", resp.StatusCode) + } +} + +// sendTelegramNotification sends event information to a Telegram chat/bot +func sendTelegramNotification(event *VivotekEvent) { + // Format the message + message := formatTelegramMessage(event) + + // Construct the Telegram Bot API URL + apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", state.Config.TelegramToken) + + // Prepare the request data + data := url.Values{} + data.Set("chat_id", state.Config.TelegramChatID) + data.Set("text", message) + data.Set("parse_mode", "HTML") // Enable HTML formatting + + // Send the request + resp, err := http.PostForm(apiURL, data) + if err != nil { + state.Logger.Printf("Error sending Telegram notification: %v", err) + return + } + defer resp.Body.Close() + + // Check for error response + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + state.Logger.Printf("Telegram API error: status=%d, response=%s", resp.StatusCode, string(body)) + } else { + state.Logger.Printf("Telegram notification sent successfully for event type %s", event.EventType) + } +} + +// formatTelegramMessage creates a human-readable message for Telegram +func formatTelegramMessage(event *VivotekEvent) string { + // Basic message with event details + message := fmt.Sprintf("🚨 Vivotek NVR Alert\n\n"+ + "Event: %s\n"+ + "Time: %s\n"+ + "Device: %s\n"+ + "Channel: %s\n", + event.EventType, + event.EventTime.Format("2006-01-02 15:04:05"), + event.DeviceID, + event.ChannelID) + + // Add custom message based on event type + switch event.EventType { + case "MotionDetection": + message += "📹 Motion detected!" + + // Add zone info if available + if zone, ok := event.EventDetails["zoneId"].(string); ok { + message += fmt.Sprintf(" (Zone: %s)", zone) + } + + case "VideoLoss": + message += "⚠️ Video signal lost! Please check camera connection." + + case "DeviceConnection": + if status, ok := event.EventDetails["status"].(string); ok && status == "disconnected" { + message += "❌ Device disconnected! Network issue possible." + } else { + message += "✅ Device connected and operating normally." + } + + default: + // Add any available details for unknown event types + detailsJSON, _ := json.Marshal(event.EventDetails) + if len(detailsJSON) > 0 { + message += fmt.Sprintf("\n
%s
", string(detailsJSON)) + } + } + + return message +} + +// healthCheck provides a simple endpoint to verify the service is running +func healthCheck(w http.ResponseWriter, r *http.Request) { + response := map[string]interface{}{ + "status": "ok", + "eventCount": state.EventCount, + "uptime": time.Since(startTime).String(), + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +var startTime time.Time + +func main() { + startTime = time.Now() + + // Initialize configuration + if err := initConfig(); err != nil { + log.Fatalf("Failed to initialize configuration: %v", err) + } + + // Set up HTTP routes + http.HandleFunc("/health", healthCheck) + http.HandleFunc("/event", basicAuth(handleEvent)) + http.HandleFunc("/events", basicAuth(handleEvent)) // Alternative endpoint + + // Start the HTTP server + serverAddr := fmt.Sprintf(":%s", state.Config.ServerPort) + state.Logger.Printf("Starting Vivotek NVR Event Handler API on %s", serverAddr) + if err := http.ListenAndServe(serverAddr, nil); err != nil { + state.Logger.Fatalf("Failed to start server: %v", err) + } +} diff --git a/cmd/client/batch/main.go b/cmd/client/batch/main.go new file mode 100644 index 0000000..6bfe1cd --- /dev/null +++ b/cmd/client/batch/main.go @@ -0,0 +1,232 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "sync" + "time" +) + +// Command line flags +var ( + serverURL string + username string + password string + concurrency int + scenarioFile string + outputResults bool +) + +// TestScenario represents a collection of test events to send +type TestScenario struct { + Name string `json:"name"` + Description string `json:"description"` + Events []EventConfig `json:"events"` +} + +// EventConfig represents a single event configuration +type EventConfig struct { + EventType string `json:"eventType"` + DeviceID string `json:"deviceId"` + ChannelID string `json:"channelId"` + DelaySeconds int `json:"delaySeconds"` + EventDetails map[string]interface{} `json:"eventDetails"` +} + +// VivotekEvent matches the structure expected by the API +type VivotekEvent struct { + EventType string `json:"eventType"` + EventTime time.Time `json:"eventTime"` + DeviceID string `json:"deviceId"` + ChannelID string `json:"channelId"` + EventDetails map[string]interface{} `json:"eventDetails"` +} + +// Result tracks the outcome of sending an event +type Result struct { + Event EventConfig `json:"event"` + StatusCode int `json:"statusCode"` + Response string `json:"response"` + Error string `json:"error,omitempty"` + Duration int64 `json:"durationMs"` +} + +func init() { + // Define command line flags + flag.StringVar(&serverURL, "url", "http://localhost:8080/event", "API server URL") + flag.StringVar(&username, "user", "", "Basic auth username") + flag.StringVar(&password, "pass", "", "Basic auth password") + flag.IntVar(&concurrency, "concurrency", 1, "Number of concurrent requests") + flag.StringVar(&scenarioFile, "scenario", "test_scenario.json", "JSON file with test scenarios") + flag.BoolVar(&outputResults, "output", false, "Output results to results.json") +} + +func main() { + flag.Parse() + + // Load test scenario + scenario, err := loadScenario(scenarioFile) + if err != nil { + log.Fatalf("Failed to load scenario: %v", err) + } + + fmt.Printf("Running scenario: %s\n", scenario.Name) + fmt.Printf("Description: %s\n", scenario.Description) + fmt.Printf("Events: %d\n", len(scenario.Events)) + fmt.Printf("Concurrency: %d\n", concurrency) + fmt.Println("=======================") + + // Create a channel to hold the work and results + jobs := make(chan EventConfig, len(scenario.Events)) + results := make(chan Result, len(scenario.Events)) + + // Start worker pool + var wg sync.WaitGroup + for w := 1; w <= concurrency; w++ { + wg.Add(1) + go worker(w, jobs, results, &wg) + } + + // Add jobs to the queue + for _, event := range scenario.Events { + jobs <- event + } + close(jobs) // Close the jobs channel when all jobs are added + + // Wait for all workers to finish in a separate goroutine + go func() { + wg.Wait() + close(results) // Close results when all workers are done + }() + + // Collect results + var allResults []Result + for result := range results { + allResults = append(allResults, result) + if result.Error != "" { + fmt.Printf("❌ Error sending %s event: %s\n", result.Event.EventType, result.Error) + } else { + fmt.Printf("✅ Sent %s event to %s: Status %d (%dms)\n", + result.Event.EventType, + result.Event.DeviceID, + result.StatusCode, + result.Duration) + } + } + + // Output results if requested + if outputResults && len(allResults) > 0 { + resultsJSON, err := json.MarshalIndent(allResults, "", " ") + if err != nil { + log.Printf("Failed to marshal results: %v", err) + } else { + err = os.WriteFile("results.json", resultsJSON, 0644) + if err != nil { + log.Printf("Failed to write results file: %v", err) + } else { + fmt.Println("Results written to results.json") + } + } + } + + fmt.Println("=======================") + fmt.Printf("Test scenario completed: %d events sent\n", len(allResults)) +} + +// worker processes jobs from the jobs channel +func worker(id int, jobs <-chan EventConfig, results chan<- Result, wg *sync.WaitGroup) { + defer wg.Done() + + for j := range jobs { + // Apply configured delay + if j.DelaySeconds > 0 { + time.Sleep(time.Duration(j.DelaySeconds) * time.Second) + } + + // Send the event + result := sendEvent(j) + results <- result + } +} + +// sendEvent sends a single event to the API +func sendEvent(config EventConfig) Result { + startTime := time.Now() + result := Result{ + Event: config, + } + + // Create the event + event := VivotekEvent{ + EventType: config.EventType, + EventTime: time.Now(), + DeviceID: config.DeviceID, + ChannelID: config.ChannelID, + EventDetails: config.EventDetails, + } + + // Marshal to JSON + payload, err := json.Marshal(event) + if err != nil { + result.Error = fmt.Sprintf("error creating JSON payload: %v", err) + return result + } + + // Create the request + req, err := http.NewRequest("POST", serverURL, bytes.NewBuffer(payload)) + if err != nil { + result.Error = fmt.Sprintf("error creating request: %v", err) + return result + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + + // Add basic auth if credentials were provided + if username != "" { + req.SetBasicAuth(username, password) + } + + // Send the request + client := &http.Client{ + Timeout: 10 * time.Second, + } + resp, err := client.Do(req) + if err != nil { + result.Error = fmt.Sprintf("error sending request: %v", err) + return result + } + defer resp.Body.Close() + + // Record status code + result.StatusCode = resp.StatusCode + result.Duration = time.Since(startTime).Milliseconds() + + // Record error for non-2xx responses + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + result.Error = fmt.Sprintf("server returned status code %d", resp.StatusCode) + } + + return result +} + +// loadScenario loads a test scenario from a JSON file +func loadScenario(filename string) (*TestScenario, error) { + file, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("error reading scenario file: %v", err) + } + + var scenario TestScenario + err = json.Unmarshal(file, &scenario) + if err != nil { + return nil, fmt.Errorf("error parsing scenario file: %v", err) + } + + return &scenario, nil +} diff --git a/cmd/client/batch/test_scenarion.json b/cmd/client/batch/test_scenarion.json new file mode 100644 index 0000000..c3bf059 --- /dev/null +++ b/cmd/client/batch/test_scenarion.json @@ -0,0 +1,88 @@ +{ + "name": "Comprehensive Vivotek NVR Event Test", + "description": "Tests various event types and scenarios for the Vivotek NVR API", + "events": [ + { + "eventType": "MotionDetection", + "deviceId": "NVR001", + "channelId": "Camera01", + "delaySeconds": 0, + "eventDetails": { + "zoneId": "MainEntrance", + "confidence": 95 + } + }, + { + "eventType": "MotionDetection", + "deviceId": "NVR001", + "channelId": "Camera02", + "delaySeconds": 1, + "eventDetails": { + "zoneId": "BackYard", + "confidence": 75 + } + }, + { + "eventType": "VideoLoss", + "deviceId": "NVR001", + "channelId": "Camera03", + "delaySeconds": 2, + "eventDetails": { + "duration": 15, + "cause": "cable disconnected" + } + }, + { + "eventType": "DeviceConnection", + "deviceId": "NVR002", + "channelId": "", + "delaySeconds": 3, + "eventDetails": { + "status": "disconnected", + "reason": "network failure" + } + }, + { + "eventType": "DeviceConnection", + "deviceId": "NVR002", + "channelId": "", + "delaySeconds": 5, + "eventDetails": { + "status": "connected", + "reason": "network restored" + } + }, + { + "eventType": "MotionDetection", + "deviceId": "NVR001", + "channelId": "Camera01", + "delaySeconds": 2, + "eventDetails": { + "zoneId": "MainEntrance", + "confidence": 98, + "objectType": "person" + } + }, + { + "eventType": "TamperDetection", + "deviceId": "NVR001", + "channelId": "Camera04", + "delaySeconds": 3, + "eventDetails": { + "type": "covered", + "severity": "high" + } + }, + { + "eventType": "StorageFailure", + "deviceId": "NVR001", + "channelId": "", + "delaySeconds": 2, + "eventDetails": { + "disk": "HDD1", + "errorCode": "S-404", + "remainingSpace": "50MB" + } + } + ] + } \ No newline at end of file diff --git a/cmd/client/test.readme.md b/cmd/client/test.readme.md new file mode 100644 index 0000000..807b438 --- /dev/null +++ b/cmd/client/test.readme.md @@ -0,0 +1,133 @@ +# Vivotek NVR API Test Client + +This repository contains two test clients for the Vivotek NVR Event Handler API: + +1. **Single Event Test Client** (`vivotek-test-client.go`) - For sending individual test events with customizable parameters +2. **Batch Test Client** (`vivotek-test-batch.go`) - For running complex test scenarios defined in JSON + +## Prerequisites + +- Go 1.18 or higher +- Network access to your Vivotek NVR API server + +## Single Event Test Client + +### Building + +```bash +go build -o vivotek-test vivotek-test-client.go +``` + +### Usage + +```bash +./vivotek-test [options] +``` + +### Options + +| Flag | Description | Default | +|------|-------------|---------| +| `-url` | API server URL | `http://localhost:8080/event` | +| `-type` | Event type | `MotionDetection` | +| `-device` | Device ID | `NVR12345` | +| `-channel` | Channel ID | `Camera01` | +| `-zone` | Detection zone (for motion events) | `Zone1` | +| `-user` | Basic auth username | `""` | +| `-pass` | Basic auth password | `""` | +| `-insecure` | Skip TLS verification | `false` | +| `-repeat` | Number of events to send | `1` | +| `-interval` | Interval between events in seconds | `5` | + +### Examples + +Test a motion detection event: +```bash +./vivotek-test -type=MotionDetection -device=NVR001 -channel=Camera01 -zone=FrontDoor +``` + +Test video loss with authentication: +```bash +./vivotek-test -type=VideoLoss -device=NVR001 -channel=Camera02 -user=admin -pass=password +``` + +Generate multiple events: +```bash +./vivotek-test -type=DeviceConnection -device=NVR002 -repeat=5 -interval=10 +``` + +## Batch Test Client + +The batch test client allows you to define complex test scenarios in a JSON file and execute them with a single command. + +### Building + +```bash +go build -o vivotek-batch vivotek-test-batch.go +``` + +### Usage + +```bash +./vivotek-batch [options] +``` + +### Options + +| Flag | Description | Default | +|------|-------------|---------| +| `-url` | API server URL | `http://localhost:8080/event` | +| `-user` | Basic auth username | `""` | +| `-pass` | Basic auth password | `""` | +| `-concurrency` | Number of concurrent requests | `1` | +| `-scenario` | JSON file with test scenarios | `test_scenario.json` | +| `-output` | Output results to results.json | `false` | + +### Test Scenario Format + +The test scenario file uses the following format: + +```json +{ + "name": "Test Scenario Name", + "description": "Description of the test scenario", + "events": [ + { + "eventType": "MotionDetection", + "deviceId": "NVR001", + "channelId": "Camera01", + "delaySeconds": 0, + "eventDetails": { + "zoneId": "Zone1", + "confidence": 95 + } + }, + ... + ] +} +``` + +### Examples + +Run the default test scenario: +```bash +./vivotek-batch +``` + +Run a custom scenario with authentication: +```bash +./vivotek-batch -scenario=my_scenario.json -user=admin -pass=password +``` + +Run with multiple concurrent requests: +```bash +./vivotek-batch -concurrency=5 -output +``` + +## Tips for Testing + +1. **Start with Single Events**: Use the single event client first to verify basic connectivity +2. **Check Server Logs**: Monitor the server logs while running tests to see how events are being processed +3. **Verify Telegram**: If you've configured Telegram notifications, check that they're being sent +4. **Increase Load Gradually**: When testing performance, start with low concurrency and gradually increase +5. **Custom Scenarios**: Create different scenarios for different testing purposes (basic functionality, stress testing, etc.) \ No newline at end of file diff --git a/cmd/client/test/main.go b/cmd/client/test/main.go new file mode 100644 index 0000000..1b9cabb --- /dev/null +++ b/cmd/client/test/main.go @@ -0,0 +1,190 @@ +package main + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "time" +) + +// Command line flags +var ( + serverURL string + eventType string + deviceID string + channelID string + zone string + username string + password string + insecure bool + repeatCount int + interval int +) + +// VivotekEvent matches the structure expected by the API +type VivotekEvent struct { + EventType string `json:"eventType"` + EventTime time.Time `json:"eventTime"` + DeviceID string `json:"deviceId"` + ChannelID string `json:"channelId"` + EventDetails map[string]interface{} `json:"eventDetails"` +} + +func init() { + // Define command line flags + flag.StringVar(&serverURL, "url", "http://localhost:8080/event", "API server URL") + flag.StringVar(&eventType, "type", "MotionDetection", "Event type (MotionDetection, VideoLoss, DeviceConnection)") + flag.StringVar(&deviceID, "device", "NVR12345", "Device ID") + flag.StringVar(&channelID, "channel", "Camera01", "Channel ID") + flag.StringVar(&zone, "zone", "Zone1", "Detection zone (for motion events)") + flag.StringVar(&username, "user", "", "Basic auth username") + flag.StringVar(&password, "pass", "", "Basic auth password") + flag.BoolVar(&insecure, "insecure", false, "Skip TLS verification") + flag.IntVar(&repeatCount, "repeat", 1, "Number of events to send") + flag.IntVar(&interval, "interval", 5, "Interval between events in seconds") +} + +func main() { + flag.Parse() + + // Print client configuration + fmt.Println("Vivotek API Test Client") + fmt.Println("=======================") + fmt.Printf("Server URL: %s\n", serverURL) + fmt.Printf("Event Type: %s\n", eventType) + fmt.Printf("Device ID: %s\n", deviceID) + fmt.Printf("Channel ID: %s\n", channelID) + if eventType == "MotionDetection" { + fmt.Printf("Zone: %s\n", zone) + } + fmt.Printf("Auth: %v\n", username != "") + fmt.Printf("Sending %d events with %d second intervals\n", repeatCount, interval) + fmt.Println("=======================") + + // Configure HTTP client with optional TLS settings + httpClient := &http.Client{} + if insecure { + httpClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + } + + // Send events + for i := 1; i <= repeatCount; i++ { + if i > 1 { + fmt.Printf("Waiting %d seconds...\n", interval) + time.Sleep(time.Duration(interval) * time.Second) + } + + fmt.Printf("Sending event %d of %d\n", i, repeatCount) + err := sendEvent(httpClient) + if err != nil { + log.Fatalf("Failed to send event: %v", err) + } + } + + fmt.Println("All events sent successfully!") +} + +func sendEvent(client *http.Client) error { + // Create event details based on event type + eventDetails := make(map[string]interface{}) + + switch eventType { + case "MotionDetection": + eventDetails["zoneId"] = zone + eventDetails["confidence"] = 85 + case "VideoLoss": + eventDetails["duration"] = 30 + eventDetails["cause"] = "cable disconnected" + case "DeviceConnection": + eventDetails["status"] = "disconnected" + eventDetails["reason"] = "network failure" + } + + // Create the event payload + event := VivotekEvent{ + EventType: eventType, + EventTime: time.Now(), + DeviceID: deviceID, + ChannelID: channelID, + EventDetails: eventDetails, + } + + // Marshal to JSON + payload, err := json.MarshalIndent(event, "", " ") + if err != nil { + return fmt.Errorf("error creating JSON payload: %v", err) + } + + // Print the payload for debugging + fmt.Println("Event payload:") + fmt.Println(string(payload)) + + // Create the request + req, err := http.NewRequest("POST", serverURL, bytes.NewBuffer(payload)) + if err != nil { + return fmt.Errorf("error creating request: %v", err) + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + + // Add basic auth if credentials were provided + if username != "" { + req.SetBasicAuth(username, password) + } + + // Send the request + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("error sending request: %v", err) + } + defer resp.Body.Close() + + // Read the response + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("error reading response: %v", err) + } + + // Check the status code + if resp.StatusCode >= 400 { + return fmt.Errorf("server returned error: %d - %s", resp.StatusCode, string(respBody)) + } + + // Print the response + fmt.Printf("Response status: %d\n", resp.StatusCode) + fmt.Printf("Response: %s\n", string(respBody)) + + return nil +} + +// EventGenerator returns a function that creates custom events +func EventGenerator() func(string, string, string) VivotekEvent { + return func(eventType, deviceID, channelID string) VivotekEvent { + eventDetails := make(map[string]interface{}) + switch eventType { + case "MotionDetection": + eventDetails["zoneId"] = "Zone1" + eventDetails["confidence"] = 85 + case "VideoLoss": + eventDetails["duration"] = 30 + case "DeviceConnection": + eventDetails["status"] = "connected" + } + + return VivotekEvent{ + EventType: eventType, + EventTime: time.Now(), + DeviceID: deviceID, + ChannelID: channelID, + EventDetails: eventDetails, + } + } +} diff --git a/config.sample.json b/config.sample.json new file mode 100644 index 0000000..1f0dfee --- /dev/null +++ b/config.sample.json @@ -0,0 +1,11 @@ +{ + "server_port": "8080", + "log_file": "vivotek_events.log", + "notify_url": "https://your-notification-service.com/webhook", + "auth_username": "admin", + "auth_password": "your-secure-password", + "telegram_enabled": true, + "telegram_token": "YOUR_TELEGRAM_BOT_TOKEN", + "telegram_chat_id": "YOUR_CHAT_ID" + } + \ No newline at end of file diff --git a/docker_build_and_install.sh b/docker_build_and_install.sh new file mode 100755 index 0000000..378cd45 --- /dev/null +++ b/docker_build_and_install.sh @@ -0,0 +1,12 @@ +#!/bin/sh + + +docker build . --build-arg CACHEBUST=$(date +%s) -t localdev/nvr-api +echo Installing.... +docker stop nvr-api +docker rm nvr-api +#docker volume create --name nvr-api + +#docker run -d -p 8082:8080 -v /tmp/config.json:/app/config.json --name nvr-api --restart unless-stopped --memory=2G --cpus=1 localdev/nvr-api +docker run -d -p 8082:8080 --name nvr-api --restart unless-stopped --memory=2G --cpus=1 localdev/nvr-api + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4489b1d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/Warky-Devs/nvr-notify-api + +go 1.22.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/telegram-integration.svg b/telegram-integration.svg new file mode 100644 index 0000000..41fa5c5 --- /dev/null +++ b/telegram-integration.svg @@ -0,0 +1,54 @@ + + + + + + + Vivotek NVR + + + + Go API Server + + + + Telegram + Bot API + + + + + + + + + + + + + HTTP Event + + + + Notification + + + + 🚨 Motion + Detected! + + + + + + + + + + Vivotek NVR Telegram Integration + + + Event Source + Event Processor + Notification + \ No newline at end of file