mirror of
https://github.com/bluenviron/mediamtx.git
synced 2025-12-20 02:00:05 -08:00
feat(adapter): add adapter and api server; add client UI
This commit is contained in:
parent
b8e8d8edab
commit
ae9cde4400
23 changed files with 6790 additions and 1 deletions
1
VERSION
Normal file
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v0.0.0-local
|
||||||
97
adapter.mk
Normal file
97
adapter.mk
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
# FPT Camera WebRTC Adapter Makefile
|
||||||
|
|
||||||
|
.PHONY: all build-adapter run-adapter clean test
|
||||||
|
|
||||||
|
# Go parameters
|
||||||
|
GOCMD=go
|
||||||
|
GOBUILD=$(GOCMD) build
|
||||||
|
GORUN=$(GOCMD) run
|
||||||
|
GOTEST=$(GOCMD) test
|
||||||
|
GOMOD=$(GOCMD) mod
|
||||||
|
GOFMT=$(GOCMD) fmt
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
ADAPTER_BINARY=bin/fpt-adapter
|
||||||
|
ADAPTER_BINARY_WIN=bin/fpt-adapter.exe
|
||||||
|
|
||||||
|
# Main packages
|
||||||
|
ADAPTER_MAIN=./cmd/adapter
|
||||||
|
|
||||||
|
# Version info
|
||||||
|
VERSION?=1.0.0
|
||||||
|
BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S')
|
||||||
|
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME)"
|
||||||
|
|
||||||
|
all: build-adapter
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
deps:
|
||||||
|
$(GOMOD) download
|
||||||
|
$(GOMOD) tidy
|
||||||
|
|
||||||
|
# Build the adapter
|
||||||
|
build-adapter: deps
|
||||||
|
$(GOBUILD) $(LDFLAGS) -o $(ADAPTER_BINARY) $(ADAPTER_MAIN)
|
||||||
|
|
||||||
|
# Build for Windows
|
||||||
|
build-adapter-win: deps
|
||||||
|
GOOS=windows GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(ADAPTER_BINARY_WIN) $(ADAPTER_MAIN)
|
||||||
|
|
||||||
|
# Run the adapter
|
||||||
|
run-adapter:
|
||||||
|
$(GORUN) $(ADAPTER_MAIN)
|
||||||
|
|
||||||
|
# Run adapter with .env file
|
||||||
|
run-adapter-env:
|
||||||
|
@if [ -f .env ]; then \
|
||||||
|
export $$(cat .env | grep -v '^#' | xargs) && $(GORUN) $(ADAPTER_MAIN); \
|
||||||
|
else \
|
||||||
|
echo "No .env file found"; \
|
||||||
|
$(GORUN) $(ADAPTER_MAIN); \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test adapter package
|
||||||
|
test-adapter:
|
||||||
|
$(GOTEST) -v ./internal/adapter/...
|
||||||
|
|
||||||
|
# Test all packages
|
||||||
|
test:
|
||||||
|
$(GOTEST) -v ./...
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
fmt:
|
||||||
|
$(GOFMT) ./internal/adapter/...
|
||||||
|
$(GOFMT) ./cmd/adapter/...
|
||||||
|
|
||||||
|
# Clean build artifacts
|
||||||
|
clean:
|
||||||
|
rm -rf bin/
|
||||||
|
rm -f $(ADAPTER_BINARY)
|
||||||
|
rm -f $(ADAPTER_BINARY_WIN)
|
||||||
|
|
||||||
|
# Show environment example
|
||||||
|
env-example:
|
||||||
|
$(GORUN) $(ADAPTER_MAIN) -env-example
|
||||||
|
|
||||||
|
# Show version
|
||||||
|
version:
|
||||||
|
$(GORUN) $(ADAPTER_MAIN) -version
|
||||||
|
|
||||||
|
# Help
|
||||||
|
help:
|
||||||
|
@echo "FPT Camera WebRTC Adapter Makefile"
|
||||||
|
@echo ""
|
||||||
|
@echo "Targets:"
|
||||||
|
@echo " all - Build the adapter (default)"
|
||||||
|
@echo " deps - Download and tidy dependencies"
|
||||||
|
@echo " build-adapter - Build the adapter binary"
|
||||||
|
@echo " build-adapter-win- Build the adapter for Windows"
|
||||||
|
@echo " run-adapter - Run the adapter"
|
||||||
|
@echo " run-adapter-env - Run the adapter with .env file"
|
||||||
|
@echo " test-adapter - Run adapter tests"
|
||||||
|
@echo " test - Run all tests"
|
||||||
|
@echo " fmt - Format code"
|
||||||
|
@echo " clean - Clean build artifacts"
|
||||||
|
@echo " env-example - Show example environment variables"
|
||||||
|
@echo " version - Show version"
|
||||||
|
@echo " help - Show this help"
|
||||||
206
client/README.md
Normal file
206
client/README.md
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
# FPT Camera WebRTC Client
|
||||||
|
|
||||||
|
Client application for connecting to FPT cameras via WebRTC with MQTT signaling.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ MQTT ┌─────────────┐ Local ┌─────────────┐
|
||||||
|
│ Client │◄─────────────────►│ Server │◄──────────────────►│ MediaMTX │
|
||||||
|
│ (Browser) │ │ (MQTT) │ │ (WebRTC) │
|
||||||
|
└─────────────┘ └─────────────┘ └─────────────┘
|
||||||
|
│ │ │
|
||||||
|
│ 1. Create Offer SDP │ │
|
||||||
|
│ 2. Gather ICE Candidates │ │
|
||||||
|
│ 3. Publish Offer ──────────────► │ │
|
||||||
|
│ │ 4. Relay to MediaMTX ───────────►│
|
||||||
|
│ │ │
|
||||||
|
│ │◄───────── 5. Create Answer ──────│
|
||||||
|
│◄───────── 6. Publish Answer ─────│◄───────── 6. Send Answer ────────│
|
||||||
|
│ │ │
|
||||||
|
│ 7. Set Remote Description │ │
|
||||||
|
│ 8. WebRTC Connection Established │ │
|
||||||
|
└──────────────────────────────────┼──────────────────────────────────┘
|
||||||
|
Media Stream
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
|
||||||
|
1. **Client tạo Offer SDP** - Client bắt đầu quá trình WebRTC
|
||||||
|
2. **Client thực hiện Gathering ICE** - Thu thập ICE Candidates
|
||||||
|
3. **Client gửi Offer + ICE qua MQTT → Server** - Signaling qua MQTT
|
||||||
|
4. **Server relay tới MediaMTX** - Server chuyển thông tin
|
||||||
|
5. **MediaMTX tạo Answer SDP** - MediaMTX xử lý và sinh Answer
|
||||||
|
6. **MediaMTX gửi Answer về Server** - Thông tin đáp ứng
|
||||||
|
7. **Server publish Answer qua MQTT → Client** - Client nhận signaling
|
||||||
|
8. **Client thiết lập WebRTC** - setRemoteDescription và kết nối
|
||||||
|
|
||||||
|
## Topics MQTT
|
||||||
|
|
||||||
|
| Topic Pattern | Description |
|
||||||
|
|--------------|-------------|
|
||||||
|
| `ipc/discovery` | Danh sách cameras |
|
||||||
|
| `ipc/<brand>/<serial>/credential` | Username/password camera |
|
||||||
|
| `ipc/<brand>/<serial>/request/signaling` | Client → Server signaling |
|
||||||
|
| `ipc/<brand>/<serial>/response/signaling` | Server → Client signaling |
|
||||||
|
|
||||||
|
## Message Types
|
||||||
|
|
||||||
|
### Signaling Request
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Method": "ACT",
|
||||||
|
"MessageType": "Signaling",
|
||||||
|
"Serial": "c05o24110000021",
|
||||||
|
"Data": {
|
||||||
|
"Type": "request",
|
||||||
|
"ClientId": "client-123"
|
||||||
|
},
|
||||||
|
"Timestamp": 1589861099
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signaling Offer (from Camera)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Method": "ACT",
|
||||||
|
"MessageType": "Signaling",
|
||||||
|
"Serial": "c05o24110000021",
|
||||||
|
"Data": {
|
||||||
|
"Type": "offer",
|
||||||
|
"ClientId": "client-123",
|
||||||
|
"Sdp": "v=0\r\n...",
|
||||||
|
"IceServers": ["stun.l.google.com", "turn-test-1.fcam.vn"]
|
||||||
|
},
|
||||||
|
"Result": { "Ret": 100, "Message": "Success" },
|
||||||
|
"Timestamp": 1589861099
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signaling Answer (from Client)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Method": "ACT",
|
||||||
|
"MessageType": "Signaling",
|
||||||
|
"Serial": "c05o24110000021",
|
||||||
|
"Data": {
|
||||||
|
"Type": "answer",
|
||||||
|
"Sdp": "v=0\r\n...",
|
||||||
|
"ClientId": "client-123"
|
||||||
|
},
|
||||||
|
"Timestamp": 1589861099,
|
||||||
|
"Result": { "Ret": 100, "Message": "Success" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Channel - Stream Request
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Id": "nvrtnqd07",
|
||||||
|
"Command": "Stream",
|
||||||
|
"Type": "Request",
|
||||||
|
"Content": {
|
||||||
|
"ChannelMask": 3,
|
||||||
|
"ResolutionMask": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **ChannelMask**: 64-bit bitmask for channels (bit0=CH1...bit15=CH16)
|
||||||
|
- **ResolutionMask**: 64-bit bitmask for resolution (0=sub-stream, 1=main-stream)
|
||||||
|
|
||||||
|
## Files Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
client/
|
||||||
|
├── index.html # Main HTML UI
|
||||||
|
├── app.js # Application controller
|
||||||
|
├── config/
|
||||||
|
│ └── config.js # Configuration (MQTT, WebRTC, Topics)
|
||||||
|
├── messages/
|
||||||
|
│ └── message-types.js # Message definitions and helpers
|
||||||
|
├── mqtt/
|
||||||
|
│ └── mqtt-client.js # MQTT client wrapper
|
||||||
|
└── webrtc/
|
||||||
|
└── webrtc-client.js # WebRTC client wrapper
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Edit `config/config.js` to update:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const CONFIG = {
|
||||||
|
mqtt: {
|
||||||
|
brokerUrl: 'wss://beta-broker-mqtt.fcam.vn:8084/mqtt',
|
||||||
|
username: 'hoangbd7',
|
||||||
|
password: 'Hoangbd7'
|
||||||
|
},
|
||||||
|
webrtc: {
|
||||||
|
iceServers: [
|
||||||
|
{ urls: 'stun:stun-connect.fcam.vn:3478' },
|
||||||
|
{
|
||||||
|
urls: 'turn:turn-connect.fcam.vn:3478',
|
||||||
|
username: 'turnuser',
|
||||||
|
credential: 'camfptvnturn133099'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Option 1: Open directly in browser
|
||||||
|
Simply open `index.html` in a modern browser (Chrome, Firefox, Edge).
|
||||||
|
|
||||||
|
### Option 2: Run with local server
|
||||||
|
```bash
|
||||||
|
# Using Python
|
||||||
|
python -m http.server 8080
|
||||||
|
|
||||||
|
# Using Node.js
|
||||||
|
npx serve .
|
||||||
|
|
||||||
|
# Using PHP
|
||||||
|
php -S localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open http://localhost:8080
|
||||||
|
|
||||||
|
### Steps to Connect
|
||||||
|
|
||||||
|
1. Click **Connect MQTT** to connect to the MQTT broker
|
||||||
|
2. Enter camera **Brand** and **Serial** number
|
||||||
|
3. (Optional) Enter camera **Username/Password** and click **Send Credentials**
|
||||||
|
4. Click **Start WebRTC** to begin signaling
|
||||||
|
5. Once connected, use **Data Channel** controls to request streams
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Modern browser with WebRTC support
|
||||||
|
- Network access to:
|
||||||
|
- MQTT broker: `wss://beta-broker-mqtt.fcam.vn:8084/mqtt`
|
||||||
|
- STUN servers: `stun-connect.fcam.vn:3478`
|
||||||
|
- TURN servers: `turn-connect.fcam.vn:3478`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### MQTT Connection Failed
|
||||||
|
- Check network connectivity
|
||||||
|
- Verify MQTT credentials
|
||||||
|
- Check if broker URL is correct
|
||||||
|
|
||||||
|
### WebRTC Connection Failed
|
||||||
|
- Check ICE server configuration
|
||||||
|
- Verify TURN credentials
|
||||||
|
- Check firewall settings
|
||||||
|
|
||||||
|
### No Video Stream
|
||||||
|
- Verify camera is online
|
||||||
|
- Check data channel Stream request
|
||||||
|
- Verify channel mask and resolution settings
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
419
client/app.js
Normal file
419
client/app.js
Normal file
|
|
@ -0,0 +1,419 @@
|
||||||
|
/**
|
||||||
|
* Main Application Controller
|
||||||
|
* Coordinates MQTT signaling and WebRTC connection
|
||||||
|
*/
|
||||||
|
class FPTCameraClient {
|
||||||
|
constructor() {
|
||||||
|
this.mqttClient = null;
|
||||||
|
this.webrtcClient = null;
|
||||||
|
this.cameras = [];
|
||||||
|
this.currentCamera = null;
|
||||||
|
this.connectionState = 'disconnected';
|
||||||
|
|
||||||
|
// Event callbacks
|
||||||
|
this.onCameraListUpdate = null;
|
||||||
|
this.onConnectionStateChange = null;
|
||||||
|
this.onStreamReady = null;
|
||||||
|
this.onError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the client
|
||||||
|
*/
|
||||||
|
async initialize() {
|
||||||
|
this.log('Initializing FPT Camera Client...');
|
||||||
|
|
||||||
|
// Create MQTT client
|
||||||
|
this.mqttClient = new MQTTSignalingClient(CONFIG);
|
||||||
|
this.setupMQTTCallbacks();
|
||||||
|
|
||||||
|
// Create WebRTC client
|
||||||
|
this.webrtcClient = new WebRTCClient(CONFIG);
|
||||||
|
this.setupWebRTCCallbacks();
|
||||||
|
|
||||||
|
// Connect to MQTT
|
||||||
|
await this.connectMQTT();
|
||||||
|
|
||||||
|
this.log('Client initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup MQTT event callbacks
|
||||||
|
*/
|
||||||
|
setupMQTTCallbacks() {
|
||||||
|
this.mqttClient.onConnected = () => {
|
||||||
|
this.updateConnectionState('mqtt_connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.mqttClient.onDisconnected = () => {
|
||||||
|
this.updateConnectionState('mqtt_disconnected');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.mqttClient.onError = (error) => {
|
||||||
|
this.logError('MQTT Error:', error);
|
||||||
|
if (this.onError) this.onError(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.mqttClient.onReconnecting = () => {
|
||||||
|
this.updateConnectionState('mqtt_reconnecting');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup WebRTC event callbacks
|
||||||
|
*/
|
||||||
|
setupWebRTCCallbacks() {
|
||||||
|
this.webrtcClient.onIceCandidate = (candidate) => {
|
||||||
|
// Send ICE candidate via MQTT
|
||||||
|
if (this.currentCamera) {
|
||||||
|
this.sendIceCandidate(candidate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.webrtcClient.onTrack = (track, streams) => {
|
||||||
|
this.log('Remote track received:', track.kind);
|
||||||
|
if (this.onStreamReady) {
|
||||||
|
this.onStreamReady(this.webrtcClient.getRemoteStream());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.webrtcClient.onDataChannelOpen = () => {
|
||||||
|
this.log('Data channel opened');
|
||||||
|
this.updateConnectionState('data_channel_open');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.webrtcClient.onDataChannelMessage = (message) => {
|
||||||
|
this.handleDataChannelMessage(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.webrtcClient.onConnectionStateChange = (state) => {
|
||||||
|
this.log('WebRTC connection state:', state);
|
||||||
|
this.updateConnectionState('webrtc_' + state);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.webrtcClient.onError = (error) => {
|
||||||
|
this.logError('WebRTC Error:', error);
|
||||||
|
if (this.onError) this.onError(error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to MQTT broker
|
||||||
|
*/
|
||||||
|
async connectMQTT() {
|
||||||
|
try {
|
||||||
|
await this.mqttClient.connect();
|
||||||
|
this.log('MQTT connected');
|
||||||
|
|
||||||
|
// Subscribe to discovery topic
|
||||||
|
await this.mqttClient.subscribe(CONFIG.topics.discovery, (message) => {
|
||||||
|
this.handleDiscoveryMessage(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.logError('Failed to connect MQTT:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle discovery message (camera list)
|
||||||
|
* @param {object} message - Discovery message
|
||||||
|
*/
|
||||||
|
handleDiscoveryMessage(message) {
|
||||||
|
this.log('Discovery message received:', message);
|
||||||
|
|
||||||
|
if (Array.isArray(message)) {
|
||||||
|
this.cameras = message;
|
||||||
|
} else if (message.cameras) {
|
||||||
|
this.cameras = message.cameras;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.onCameraListUpdate) {
|
||||||
|
this.onCameraListUpdate(this.cameras);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send camera credentials for authentication
|
||||||
|
* @param {string} serial - Camera serial
|
||||||
|
* @param {string} username - Camera username
|
||||||
|
* @param {string} password - Camera password
|
||||||
|
* @param {string} ip - Camera IP address (optional)
|
||||||
|
*/
|
||||||
|
async sendCredentials(serial, username, password, ip = '') {
|
||||||
|
this.log(`Sending credentials for ${serial}` + (ip ? ` (IP: ${ip})` : ''));
|
||||||
|
await this.mqttClient.publishCredentials(serial, username, password, ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to a camera via WebRTC
|
||||||
|
* @param {string} serial - Camera serial
|
||||||
|
*/
|
||||||
|
async connectToCamera(serial) {
|
||||||
|
this.log(`Connecting to camera: ${serial}`);
|
||||||
|
|
||||||
|
this.currentCamera = { serial };
|
||||||
|
this.updateConnectionState('connecting');
|
||||||
|
|
||||||
|
// Initialize WebRTC
|
||||||
|
this.webrtcClient.initialize(this.mqttClient.getClientId());
|
||||||
|
|
||||||
|
// Subscribe to signaling response topic
|
||||||
|
await this.mqttClient.subscribeSignaling(serial, (message) => {
|
||||||
|
this.handleSignalingResponse(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send signaling request
|
||||||
|
const request = createSignalingRequest(serial, this.mqttClient.getClientId());
|
||||||
|
await this.mqttClient.publishSignaling(serial, request);
|
||||||
|
|
||||||
|
this.log('Signaling request sent');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle signaling response from camera/NVR
|
||||||
|
* @param {object} message - Signaling response
|
||||||
|
*/
|
||||||
|
async handleSignalingResponse(message) {
|
||||||
|
this.log('Signaling response:', message);
|
||||||
|
|
||||||
|
const response = parseSignalingResponse(message);
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
this.logError('Signaling failed:', response.error);
|
||||||
|
if (this.onError) this.onError(new Error(response.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (response.type) {
|
||||||
|
case SignalingType.DENY:
|
||||||
|
this.handleDenyResponse(response.data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SignalingType.OFFER:
|
||||||
|
await this.handleOfferResponse(response.data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SignalingType.CCU:
|
||||||
|
this.handleCCUResponse(response.data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SignalingType.ICE_CANDIDATE:
|
||||||
|
await this.handleRemoteIceCandidate(response.data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
this.log('Unknown signaling type:', response.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle deny response (max clients reached)
|
||||||
|
* @param {object} data - Deny response data
|
||||||
|
*/
|
||||||
|
handleDenyResponse(data) {
|
||||||
|
this.logError('Connection denied:', data);
|
||||||
|
this.updateConnectionState('denied');
|
||||||
|
|
||||||
|
const error = new Error(`Connection denied. Max: ${data.ClientMax}, Current: ${data.CurrentClientsTotal}`);
|
||||||
|
if (this.onError) this.onError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle offer response from camera
|
||||||
|
* @param {object} data - Offer data with SDP
|
||||||
|
*/
|
||||||
|
async handleOfferResponse(data) {
|
||||||
|
this.log('Received offer from camera');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set remote description (offer)
|
||||||
|
await this.webrtcClient.setRemoteDescription(data.Sdp, 'offer');
|
||||||
|
|
||||||
|
// Create answer
|
||||||
|
const answer = await this.webrtcClient.createAnswer();
|
||||||
|
|
||||||
|
// Wait for ICE gathering
|
||||||
|
await this.webrtcClient.waitForIceGathering();
|
||||||
|
|
||||||
|
// Get final SDP with candidates
|
||||||
|
const finalSdp = this.webrtcClient.getLocalDescriptionWithCandidates();
|
||||||
|
|
||||||
|
// Send answer via MQTT
|
||||||
|
const answerMessage = createAnswerMessage(
|
||||||
|
this.currentCamera.serial,
|
||||||
|
this.mqttClient.getClientId(),
|
||||||
|
finalSdp
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.mqttClient.publishSignaling(
|
||||||
|
this.currentCamera.serial,
|
||||||
|
answerMessage
|
||||||
|
);
|
||||||
|
|
||||||
|
this.log('Answer sent');
|
||||||
|
this.updateConnectionState('answer_sent');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.logError('Failed to handle offer:', error);
|
||||||
|
if (this.onError) this.onError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle CCU (concurrent user count) response
|
||||||
|
* @param {object} data - CCU data
|
||||||
|
*/
|
||||||
|
handleCCUResponse(data) {
|
||||||
|
this.log('CCU update:', data.CurrentClientsTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle remote ICE candidate
|
||||||
|
* @param {object} data - ICE candidate data
|
||||||
|
*/
|
||||||
|
async handleRemoteIceCandidate(data) {
|
||||||
|
if (data.Candidate) {
|
||||||
|
await this.webrtcClient.addIceCandidate(data.Candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send ICE candidate via MQTT
|
||||||
|
* @param {RTCIceCandidate} candidate - ICE candidate
|
||||||
|
*/
|
||||||
|
async sendIceCandidate(candidate) {
|
||||||
|
if (!this.currentCamera) return;
|
||||||
|
|
||||||
|
const message = createIceCandidateMessage(
|
||||||
|
this.currentCamera.serial,
|
||||||
|
this.mqttClient.getClientId(),
|
||||||
|
candidate
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.mqttClient.publishSignaling(
|
||||||
|
this.currentCamera.serial,
|
||||||
|
message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle data channel message
|
||||||
|
* @param {object} message - Data channel message
|
||||||
|
*/
|
||||||
|
handleDataChannelMessage(message) {
|
||||||
|
this.log('Data channel message:', message);
|
||||||
|
|
||||||
|
if (message.Command === DataChannelCommand.STREAM) {
|
||||||
|
this.log('Stream response:', message.Result);
|
||||||
|
} else if (message.Command === DataChannelCommand.ONVIF_STATUS) {
|
||||||
|
this.log('ONVIF status:', message.Content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request stream from NVR
|
||||||
|
* @param {number} channelMask - Channel bitmask
|
||||||
|
* @param {number} resolutionMask - Resolution bitmask
|
||||||
|
*/
|
||||||
|
requestStream(channelMask, resolutionMask) {
|
||||||
|
if (!this.currentCamera) {
|
||||||
|
this.logError('No camera connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.webrtcClient.requestStream(
|
||||||
|
this.currentCamera.serial,
|
||||||
|
channelMask,
|
||||||
|
resolutionMask
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request ONVIF status from NVR
|
||||||
|
*/
|
||||||
|
requestOnvifStatus() {
|
||||||
|
if (!this.currentCamera) {
|
||||||
|
this.logError('No camera connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.webrtcClient.requestOnvifStatus(this.currentCamera.serial);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from current camera
|
||||||
|
*/
|
||||||
|
disconnect() {
|
||||||
|
if (this.currentCamera) {
|
||||||
|
// Unsubscribe from signaling topic
|
||||||
|
const topic = CONFIG.topics.responseSignaling(this.currentCamera.serial);
|
||||||
|
this.mqttClient.unsubscribe(topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.webrtcClient.close();
|
||||||
|
this.currentCamera = null;
|
||||||
|
this.updateConnectionState('disconnected');
|
||||||
|
|
||||||
|
this.log('Disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update connection state
|
||||||
|
* @param {string} state - New state
|
||||||
|
*/
|
||||||
|
updateConnectionState(state) {
|
||||||
|
this.connectionState = state;
|
||||||
|
this.log('Connection state:', state);
|
||||||
|
|
||||||
|
if (this.onConnectionStateChange) {
|
||||||
|
this.onConnectionStateChange(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current connection state
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getConnectionState() {
|
||||||
|
return this.connectionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remote stream
|
||||||
|
* @returns {MediaStream}
|
||||||
|
*/
|
||||||
|
getRemoteStream() {
|
||||||
|
return this.webrtcClient.getRemoteStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get camera list
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
getCameras() {
|
||||||
|
return this.cameras;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log helper
|
||||||
|
*/
|
||||||
|
log(...args) {
|
||||||
|
console.log('[App]', ...args);
|
||||||
|
if (window.addLog) window.addLog('App', args.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error log helper
|
||||||
|
*/
|
||||||
|
logError(...args) {
|
||||||
|
console.error('[App]', ...args);
|
||||||
|
if (window.addLog) window.addLog('App ERROR', args.join(' '));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for module usage
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { FPTCameraClient };
|
||||||
|
}
|
||||||
89
client/config/config.js
Normal file
89
client/config/config.js
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
// Client Configuration
|
||||||
|
const CONFIG = {
|
||||||
|
// Adapter API Settings (for ONVIF discovery)
|
||||||
|
api: {
|
||||||
|
baseUrl: 'http://localhost:8890',
|
||||||
|
endpoints: {
|
||||||
|
discover: '/api/discover',
|
||||||
|
auth: '/api/auth',
|
||||||
|
devices: '/api/devices',
|
||||||
|
health: '/api/health'
|
||||||
|
},
|
||||||
|
timeout: 30000
|
||||||
|
},
|
||||||
|
|
||||||
|
// MediaMTX Settings
|
||||||
|
mediamtx: {
|
||||||
|
// WebRTC/WHEP endpoint
|
||||||
|
webrtcUrl: 'http://localhost:8889',
|
||||||
|
// API endpoint
|
||||||
|
apiUrl: 'http://localhost:9997'
|
||||||
|
},
|
||||||
|
|
||||||
|
// MQTT Settings
|
||||||
|
mqtt: {
|
||||||
|
brokerUrl: 'wss://beta-broker-mqtt.fcam.vn:8084/mqtt',
|
||||||
|
username: 'hoangbd7',
|
||||||
|
password: 'Hoangbd7',
|
||||||
|
clientIdPrefix: 'fpt-client-',
|
||||||
|
reconnectPeriod: 5000,
|
||||||
|
connectTimeout: 30000,
|
||||||
|
keepalive: 60,
|
||||||
|
clean: true,
|
||||||
|
qos: 1
|
||||||
|
},
|
||||||
|
|
||||||
|
// Topic Templates (prefix: ipc/fss)
|
||||||
|
topics: {
|
||||||
|
// Topic prefix
|
||||||
|
prefix: 'ipc/fss',
|
||||||
|
|
||||||
|
// Discovery topic để lấy danh sách camera
|
||||||
|
discovery: 'ipc/fss/discovery',
|
||||||
|
|
||||||
|
// Request/Response signaling topics (template)
|
||||||
|
// Actual topic: ipc/fss/<serialno>/request/signaling
|
||||||
|
requestSignaling: (serial) => `ipc/fss/${serial}/request/signaling`,
|
||||||
|
responseSignaling: (serial) => `ipc/fss/${serial}/response/signaling`,
|
||||||
|
|
||||||
|
// Credential topic
|
||||||
|
credential: (serial) => `ipc/fss/${serial}/credential`
|
||||||
|
},
|
||||||
|
|
||||||
|
// WebRTC Settings
|
||||||
|
webrtc: {
|
||||||
|
iceServers: [
|
||||||
|
{ urls: 'stun:stun-connect.fcam.vn:3478' },
|
||||||
|
{ urls: 'stun:stunp-connect.fcam.vn:3478' },
|
||||||
|
{ urls: 'stun:stun.l.google.com:19302' },
|
||||||
|
{
|
||||||
|
urls: 'turn:turn-connect.fcam.vn:3478',
|
||||||
|
username: 'turnuser',
|
||||||
|
credential: 'camfptvnturn133099'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// ICE gathering timeout (ms)
|
||||||
|
iceGatheringTimeout: 5000,
|
||||||
|
// Data channel config
|
||||||
|
dataChannel: {
|
||||||
|
label: 'control',
|
||||||
|
ordered: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// UI Settings
|
||||||
|
ui: {
|
||||||
|
logMaxLines: 100,
|
||||||
|
autoScroll: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate unique client ID
|
||||||
|
function generateClientId() {
|
||||||
|
return CONFIG.mqtt.clientIdPrefix + Math.random().toString(36).substring(2, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for module usage
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { CONFIG, generateClientId };
|
||||||
|
}
|
||||||
1478
client/index.html
Normal file
1478
client/index.html
Normal file
File diff suppressed because it is too large
Load diff
207
client/messages/message-types.js
Normal file
207
client/messages/message-types.js
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
// Message Types for MQTT Communication
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signaling Message Types
|
||||||
|
*/
|
||||||
|
const SignalingType = {
|
||||||
|
REQUEST: 'request', // Initial request from client
|
||||||
|
OFFER: 'offer', // SDP Offer from camera/NVR
|
||||||
|
ANSWER: 'answer', // SDP Answer from client
|
||||||
|
DENY: 'deny', // Connection denied (max clients reached)
|
||||||
|
CCU: 'ccu', // Concurrent user count update
|
||||||
|
ICE_CANDIDATE: 'ice-candidate' // ICE candidate exchange
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data Channel Commands
|
||||||
|
*/
|
||||||
|
const DataChannelCommand = {
|
||||||
|
STREAM: 'Stream',
|
||||||
|
ONVIF_STATUS: 'OnvifStatus'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result Codes
|
||||||
|
*/
|
||||||
|
const ResultCode = {
|
||||||
|
SUCCESS: 100,
|
||||||
|
FAIL: 103,
|
||||||
|
STREAM_SUCCESS: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Signaling Request Message
|
||||||
|
* @param {string} serial - Camera/NVR serial number
|
||||||
|
* @param {string} clientId - Unique client identifier
|
||||||
|
* @returns {object} - Signaling request message
|
||||||
|
*/
|
||||||
|
function createSignalingRequest(serial, clientId) {
|
||||||
|
return {
|
||||||
|
Method: 'ACT',
|
||||||
|
MessageType: 'Signaling',
|
||||||
|
Serial: serial,
|
||||||
|
Data: {
|
||||||
|
Type: SignalingType.REQUEST,
|
||||||
|
ClientId: clientId
|
||||||
|
},
|
||||||
|
Timestamp: Math.floor(Date.now() / 1000)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create SDP Answer Message
|
||||||
|
* @param {string} serial - Camera/NVR serial number
|
||||||
|
* @param {string} clientId - Unique client identifier
|
||||||
|
* @param {string} sdp - SDP Answer string
|
||||||
|
* @returns {object} - SDP Answer message
|
||||||
|
*/
|
||||||
|
function createAnswerMessage(serial, clientId, sdp) {
|
||||||
|
return {
|
||||||
|
Method: 'ACT',
|
||||||
|
MessageType: 'Signaling',
|
||||||
|
Serial: serial,
|
||||||
|
Data: {
|
||||||
|
Type: SignalingType.ANSWER,
|
||||||
|
Sdp: sdp,
|
||||||
|
ClientId: clientId
|
||||||
|
},
|
||||||
|
Timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
Result: {
|
||||||
|
Ret: ResultCode.SUCCESS,
|
||||||
|
Message: 'Success'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create ICE Candidate Message
|
||||||
|
* @param {string} serial - Camera/NVR serial number
|
||||||
|
* @param {string} clientId - Unique client identifier
|
||||||
|
* @param {RTCIceCandidate} candidate - ICE candidate
|
||||||
|
* @returns {object} - ICE candidate message
|
||||||
|
*/
|
||||||
|
function createIceCandidateMessage(serial, clientId, candidate) {
|
||||||
|
return {
|
||||||
|
Method: 'ACT',
|
||||||
|
MessageType: 'Signaling',
|
||||||
|
Serial: serial,
|
||||||
|
Data: {
|
||||||
|
Type: SignalingType.ICE_CANDIDATE,
|
||||||
|
ClientId: clientId,
|
||||||
|
Candidate: {
|
||||||
|
candidate: candidate.candidate,
|
||||||
|
sdpMid: candidate.sdpMid,
|
||||||
|
sdpMLineIndex: candidate.sdpMLineIndex
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Timestamp: Math.floor(Date.now() / 1000)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Credential Message
|
||||||
|
* @param {string} serial - Camera/NVR serial number
|
||||||
|
* @param {string} username - Camera username
|
||||||
|
* @param {string} password - Camera password
|
||||||
|
* @param {string} ip - Camera IP address (optional, will be discovered via ONVIF if not provided)
|
||||||
|
* @returns {object} - Credential message
|
||||||
|
*/
|
||||||
|
function createCredentialMessage(serial, username, password, ip = '') {
|
||||||
|
const data = {
|
||||||
|
Username: username,
|
||||||
|
Password: password
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include IP if provided
|
||||||
|
if (ip) {
|
||||||
|
data.IP = ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Method: 'ACT',
|
||||||
|
MessageType: 'Credential',
|
||||||
|
Serial: serial,
|
||||||
|
Data: data,
|
||||||
|
Timestamp: Math.floor(Date.now() / 1000)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Stream Request for Data Channel
|
||||||
|
* @param {string} nvrSerial - NVR serial number
|
||||||
|
* @param {number} channelMask - 64-bit bitmask for channel enable (bit0=CH1...bit15=CH16)
|
||||||
|
* @param {number} resolutionMask - 64-bit bitmask for stream type (0=sub, 1=main)
|
||||||
|
* @returns {object} - Stream request message
|
||||||
|
*/
|
||||||
|
function createStreamRequest(nvrSerial, channelMask, resolutionMask) {
|
||||||
|
return {
|
||||||
|
Id: nvrSerial,
|
||||||
|
Command: DataChannelCommand.STREAM,
|
||||||
|
Type: 'Request',
|
||||||
|
Content: {
|
||||||
|
ChannelMask: channelMask,
|
||||||
|
ResolutionMask: resolutionMask
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create ONVIF Status Request for Data Channel
|
||||||
|
* @param {string} nvrSerial - NVR serial number
|
||||||
|
* @returns {object} - ONVIF status request message
|
||||||
|
*/
|
||||||
|
function createOnvifStatusRequest(nvrSerial) {
|
||||||
|
return {
|
||||||
|
Id: nvrSerial,
|
||||||
|
Command: DataChannelCommand.ONVIF_STATUS,
|
||||||
|
Type: 'Request',
|
||||||
|
Content: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Signaling Response
|
||||||
|
* @param {object} message - Received message
|
||||||
|
* @returns {object} - Parsed response with type and data
|
||||||
|
*/
|
||||||
|
function parseSignalingResponse(message) {
|
||||||
|
const result = {
|
||||||
|
success: false,
|
||||||
|
type: null,
|
||||||
|
data: null,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (message.Result && message.Result.Ret !== ResultCode.SUCCESS) {
|
||||||
|
result.error = message.Result.Message || 'Unknown error';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.Data) {
|
||||||
|
result.type = message.Data.Type;
|
||||||
|
result.data = message.Data;
|
||||||
|
result.success = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
result.error = e.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for module usage
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = {
|
||||||
|
SignalingType,
|
||||||
|
DataChannelCommand,
|
||||||
|
ResultCode,
|
||||||
|
createSignalingRequest,
|
||||||
|
createAnswerMessage,
|
||||||
|
createIceCandidateMessage,
|
||||||
|
createCredentialMessage,
|
||||||
|
createStreamRequest,
|
||||||
|
createOnvifStatusRequest,
|
||||||
|
parseSignalingResponse
|
||||||
|
};
|
||||||
|
}
|
||||||
294
client/mqtt/mqtt-client.js
Normal file
294
client/mqtt/mqtt-client.js
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
/**
|
||||||
|
* MQTT Client Wrapper for WebRTC Signaling
|
||||||
|
*/
|
||||||
|
class MQTTSignalingClient {
|
||||||
|
constructor(config) {
|
||||||
|
this.config = config;
|
||||||
|
this.client = null;
|
||||||
|
this.clientId = generateClientId();
|
||||||
|
this.subscriptions = new Map();
|
||||||
|
this.messageHandlers = new Map();
|
||||||
|
this.connected = false;
|
||||||
|
this.reconnecting = false;
|
||||||
|
|
||||||
|
// Event callbacks
|
||||||
|
this.onConnected = null;
|
||||||
|
this.onDisconnected = null;
|
||||||
|
this.onError = null;
|
||||||
|
this.onMessage = null;
|
||||||
|
this.onReconnecting = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to MQTT broker
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const options = {
|
||||||
|
clientId: this.clientId,
|
||||||
|
username: this.config.mqtt.username,
|
||||||
|
password: this.config.mqtt.password,
|
||||||
|
reconnectPeriod: this.config.mqtt.reconnectPeriod,
|
||||||
|
connectTimeout: this.config.mqtt.connectTimeout,
|
||||||
|
keepalive: this.config.mqtt.keepalive,
|
||||||
|
clean: this.config.mqtt.clean
|
||||||
|
};
|
||||||
|
|
||||||
|
this.log('Connecting to MQTT broker...', this.config.mqtt.brokerUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.client = mqtt.connect(this.config.mqtt.brokerUrl, options);
|
||||||
|
|
||||||
|
this.client.on('connect', () => {
|
||||||
|
this.connected = true;
|
||||||
|
this.reconnecting = false;
|
||||||
|
this.log('Connected to MQTT broker');
|
||||||
|
|
||||||
|
// Resubscribe to all topics after reconnect
|
||||||
|
this.resubscribeAll();
|
||||||
|
|
||||||
|
if (this.onConnected) this.onConnected();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('reconnect', () => {
|
||||||
|
this.reconnecting = true;
|
||||||
|
this.log('Reconnecting to MQTT broker...');
|
||||||
|
if (this.onReconnecting) this.onReconnecting();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('close', () => {
|
||||||
|
this.connected = false;
|
||||||
|
this.log('Disconnected from MQTT broker');
|
||||||
|
if (this.onDisconnected) this.onDisconnected();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('error', (error) => {
|
||||||
|
this.logError('MQTT error:', error);
|
||||||
|
if (this.onError) this.onError(error);
|
||||||
|
if (!this.connected) reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('message', (topic, message) => {
|
||||||
|
this.handleMessage(topic, message);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.logError('Failed to connect:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from MQTT broker
|
||||||
|
*/
|
||||||
|
disconnect() {
|
||||||
|
if (this.client) {
|
||||||
|
this.client.end();
|
||||||
|
this.connected = false;
|
||||||
|
this.log('Disconnected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a topic
|
||||||
|
* @param {string} topic - Topic to subscribe
|
||||||
|
* @param {function} handler - Message handler callback
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async subscribe(topic, handler) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.connected) {
|
||||||
|
reject(new Error('Not connected to MQTT broker'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client.subscribe(topic, { qos: this.config.mqtt.qos }, (error) => {
|
||||||
|
if (error) {
|
||||||
|
this.logError(`Failed to subscribe to ${topic}:`, error);
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
this.subscriptions.set(topic, handler);
|
||||||
|
this.log(`Subscribed to: ${topic}`);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from a topic
|
||||||
|
* @param {string} topic - Topic to unsubscribe
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async unsubscribe(topic) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.connected) {
|
||||||
|
reject(new Error('Not connected to MQTT broker'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client.unsubscribe(topic, (error) => {
|
||||||
|
if (error) {
|
||||||
|
this.logError(`Failed to unsubscribe from ${topic}:`, error);
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
this.subscriptions.delete(topic);
|
||||||
|
this.log(`Unsubscribed from: ${topic}`);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a message to a topic
|
||||||
|
* @param {string} topic - Topic to publish to
|
||||||
|
* @param {object|string} message - Message to publish
|
||||||
|
* @param {object} options - Publish options
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async publish(topic, message, options = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.connected) {
|
||||||
|
reject(new Error('Not connected to MQTT broker'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = typeof message === 'string' ? message : JSON.stringify(message);
|
||||||
|
const pubOptions = {
|
||||||
|
qos: options.qos || this.config.mqtt.qos,
|
||||||
|
retain: options.retain || false
|
||||||
|
};
|
||||||
|
|
||||||
|
this.client.publish(topic, payload, pubOptions, (error) => {
|
||||||
|
if (error) {
|
||||||
|
this.logError(`Failed to publish to ${topic}:`, error);
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
this.log(`Published to: ${topic}`);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming messages
|
||||||
|
* @param {string} topic - Topic the message was received on
|
||||||
|
* @param {Buffer} message - Raw message buffer
|
||||||
|
*/
|
||||||
|
handleMessage(topic, message) {
|
||||||
|
try {
|
||||||
|
const payload = message.toString();
|
||||||
|
let parsed;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(payload);
|
||||||
|
} catch {
|
||||||
|
parsed = payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log(`Message on ${topic}:`, parsed);
|
||||||
|
|
||||||
|
// Call topic-specific handler
|
||||||
|
const handler = this.subscriptions.get(topic);
|
||||||
|
if (handler) {
|
||||||
|
handler(parsed, topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call global message handler
|
||||||
|
if (this.onMessage) {
|
||||||
|
this.onMessage(parsed, topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.logError('Error handling message:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resubscribe to all topics (after reconnect)
|
||||||
|
*/
|
||||||
|
resubscribeAll() {
|
||||||
|
for (const [topic, handler] of this.subscriptions) {
|
||||||
|
this.client.subscribe(topic, { qos: this.config.mqtt.qos }, (error) => {
|
||||||
|
if (error) {
|
||||||
|
this.logError(`Failed to resubscribe to ${topic}:`, error);
|
||||||
|
} else {
|
||||||
|
this.log(`Resubscribed to: ${topic}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to signaling response topic for a camera
|
||||||
|
* @param {string} serial - Camera serial number
|
||||||
|
* @param {function} handler - Response handler
|
||||||
|
*/
|
||||||
|
async subscribeSignaling(serial, handler) {
|
||||||
|
const topic = this.config.topics.responseSignaling(serial);
|
||||||
|
await this.subscribe(topic, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish signaling request
|
||||||
|
* @param {string} serial - Camera serial number
|
||||||
|
* @param {object} message - Signaling message
|
||||||
|
*/
|
||||||
|
async publishSignaling(serial, message) {
|
||||||
|
const topic = this.config.topics.requestSignaling(serial);
|
||||||
|
await this.publish(topic, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish camera credentials
|
||||||
|
* @param {string} serial - Camera serial number
|
||||||
|
* @param {string} username - Camera username
|
||||||
|
* @param {string} password - Camera password
|
||||||
|
* @param {string} ip - Camera IP address (optional)
|
||||||
|
*/
|
||||||
|
async publishCredentials(serial, username, password, ip = '') {
|
||||||
|
const topic = this.config.topics.credential(serial);
|
||||||
|
const message = createCredentialMessage(serial, username, password, ip);
|
||||||
|
await this.publish(topic, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log helper
|
||||||
|
*/
|
||||||
|
log(...args) {
|
||||||
|
console.log('[MQTT]', ...args);
|
||||||
|
if (window.addLog) window.addLog('MQTT', args.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error log helper
|
||||||
|
*/
|
||||||
|
logError(...args) {
|
||||||
|
console.error('[MQTT]', ...args);
|
||||||
|
if (window.addLog) window.addLog('MQTT ERROR', args.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client ID
|
||||||
|
*/
|
||||||
|
getClientId() {
|
||||||
|
return this.clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if connected
|
||||||
|
*/
|
||||||
|
isConnected() {
|
||||||
|
return this.connected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for module usage
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { MQTTSignalingClient };
|
||||||
|
}
|
||||||
22
client/package.json
Normal file
22
client/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"name": "fpt-camera-webrtc-client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "WebRTC client for FPT cameras with MQTT signaling",
|
||||||
|
"main": "index.html",
|
||||||
|
"scripts": {
|
||||||
|
"start": "npx serve . -p 8080",
|
||||||
|
"dev": "npx serve . -p 8080 --cors"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"webrtc",
|
||||||
|
"mqtt",
|
||||||
|
"camera",
|
||||||
|
"fpt",
|
||||||
|
"streaming"
|
||||||
|
],
|
||||||
|
"author": "FPT Telecom",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"serve": "^14.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
67
client/scripts/inspect-mqtt.js
Normal file
67
client/scripts/inspect-mqtt.js
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Simple MQTT inspector: subscribe to a topic and print raw payloads (pretty JSON when possible)
|
||||||
|
// Usage: node inspect-mqtt.js <brokerUrl> <username> <password> <topic>
|
||||||
|
// Example: node inspect-mqtt.js wss://beta-broker-mqtt.fcam.vn:8084/mqtt myuser mypass "ipc/fss/#"
|
||||||
|
|
||||||
|
const mqtt = require('mqtt');
|
||||||
|
|
||||||
|
function tryParseJSON(s) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(s);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.length < 4) {
|
||||||
|
console.error('Usage: node inspect-mqtt.js <brokerUrl> <username> <password> <topic>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [brokerUrl, username, password, topic] = args;
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
reconnectPeriod: 5000,
|
||||||
|
connectTimeout: 30000,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`Connecting to ${brokerUrl} ...`);
|
||||||
|
const client = mqtt.connect(brokerUrl, options);
|
||||||
|
|
||||||
|
client.on('connect', () => {
|
||||||
|
console.log('Connected. Subscribing to', topic);
|
||||||
|
client.subscribe(topic, { qos: 1 }, (err) => {
|
||||||
|
if (err) console.error('Subscribe error', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('message', (t, payloadBuffer) => {
|
||||||
|
const payload = payloadBuffer.toString();
|
||||||
|
const parsed = tryParseJSON(payload);
|
||||||
|
console.log('---');
|
||||||
|
console.log('Topic:', t);
|
||||||
|
if (parsed !== null) {
|
||||||
|
try {
|
||||||
|
console.log('JSON payload:');
|
||||||
|
console.log(JSON.stringify(parsed, null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Could not stringify JSON:', e.message);
|
||||||
|
console.log('Raw payload:', payload);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Raw payload (non-JSON):');
|
||||||
|
console.log(payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('error', (err) => {
|
||||||
|
console.error('MQTT error:', err.message || err);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('\nDisconnecting...');
|
||||||
|
client.end(() => process.exit(0));
|
||||||
|
});
|
||||||
418
client/webrtc/webrtc-client.js
Normal file
418
client/webrtc/webrtc-client.js
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
/**
|
||||||
|
* WebRTC Client for Camera Streaming
|
||||||
|
*/
|
||||||
|
class WebRTCClient {
|
||||||
|
constructor(config) {
|
||||||
|
this.config = config;
|
||||||
|
this.peerConnection = null;
|
||||||
|
this.dataChannel = null;
|
||||||
|
this.localStream = null;
|
||||||
|
this.remoteStream = null;
|
||||||
|
this.iceCandidates = [];
|
||||||
|
this.iceGatheringComplete = false;
|
||||||
|
|
||||||
|
// State
|
||||||
|
this.clientId = null;
|
||||||
|
this.currentSerial = null;
|
||||||
|
this.currentBrand = null;
|
||||||
|
|
||||||
|
// Event callbacks
|
||||||
|
this.onIceCandidate = null;
|
||||||
|
this.onIceGatheringComplete = null;
|
||||||
|
this.onTrack = null;
|
||||||
|
this.onDataChannelOpen = null;
|
||||||
|
this.onDataChannelMessage = null;
|
||||||
|
this.onDataChannelClose = null;
|
||||||
|
this.onConnectionStateChange = null;
|
||||||
|
this.onError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize WebRTC peer connection
|
||||||
|
* @param {string} clientId - Unique client identifier
|
||||||
|
*/
|
||||||
|
initialize(clientId) {
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.createPeerConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create RTCPeerConnection with configured ICE servers
|
||||||
|
*/
|
||||||
|
createPeerConnection() {
|
||||||
|
const rtcConfig = {
|
||||||
|
iceServers: this.config.webrtc.iceServers,
|
||||||
|
iceCandidatePoolSize: 10
|
||||||
|
};
|
||||||
|
|
||||||
|
this.log('Creating PeerConnection with config:', rtcConfig);
|
||||||
|
this.peerConnection = new RTCPeerConnection(rtcConfig);
|
||||||
|
this.setupPeerConnectionEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup event handlers for peer connection
|
||||||
|
*/
|
||||||
|
setupPeerConnectionEvents() {
|
||||||
|
// ICE candidate event
|
||||||
|
this.peerConnection.onicecandidate = (event) => {
|
||||||
|
if (event.candidate) {
|
||||||
|
this.log('ICE candidate:', event.candidate.candidate);
|
||||||
|
this.iceCandidates.push(event.candidate);
|
||||||
|
|
||||||
|
if (this.onIceCandidate) {
|
||||||
|
this.onIceCandidate(event.candidate);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.log('ICE gathering complete');
|
||||||
|
this.iceGatheringComplete = true;
|
||||||
|
|
||||||
|
if (this.onIceGatheringComplete) {
|
||||||
|
this.onIceGatheringComplete(this.iceCandidates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ICE gathering state change
|
||||||
|
this.peerConnection.onicegatheringstatechange = () => {
|
||||||
|
this.log('ICE gathering state:', this.peerConnection.iceGatheringState);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ICE connection state change
|
||||||
|
this.peerConnection.oniceconnectionstatechange = () => {
|
||||||
|
this.log('ICE connection state:', this.peerConnection.iceConnectionState);
|
||||||
|
|
||||||
|
if (this.onConnectionStateChange) {
|
||||||
|
this.onConnectionStateChange(this.peerConnection.iceConnectionState);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.peerConnection.iceConnectionState === 'failed') {
|
||||||
|
this.logError('ICE connection failed');
|
||||||
|
if (this.onError) this.onError(new Error('ICE connection failed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connection state change
|
||||||
|
this.peerConnection.onconnectionstatechange = () => {
|
||||||
|
this.log('Connection state:', this.peerConnection.connectionState);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track event (remote media)
|
||||||
|
this.peerConnection.ontrack = (event) => {
|
||||||
|
this.log('Received remote track:', event.track.kind);
|
||||||
|
|
||||||
|
if (!this.remoteStream) {
|
||||||
|
this.remoteStream = new MediaStream();
|
||||||
|
}
|
||||||
|
this.remoteStream.addTrack(event.track);
|
||||||
|
|
||||||
|
if (this.onTrack) {
|
||||||
|
this.onTrack(event.track, event.streams);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Data channel event (when remote creates data channel)
|
||||||
|
this.peerConnection.ondatachannel = (event) => {
|
||||||
|
this.log('Received data channel:', event.channel.label);
|
||||||
|
this.setupDataChannel(event.channel);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and setup data channel
|
||||||
|
* @param {string} label - Data channel label
|
||||||
|
* @returns {RTCDataChannel}
|
||||||
|
*/
|
||||||
|
createDataChannel(label = null) {
|
||||||
|
const channelLabel = label || this.config.webrtc.dataChannel.label;
|
||||||
|
const options = {
|
||||||
|
ordered: this.config.webrtc.dataChannel.ordered
|
||||||
|
};
|
||||||
|
|
||||||
|
this.log('Creating data channel:', channelLabel);
|
||||||
|
this.dataChannel = this.peerConnection.createDataChannel(channelLabel, options);
|
||||||
|
this.setupDataChannel(this.dataChannel);
|
||||||
|
|
||||||
|
return this.dataChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup data channel event handlers
|
||||||
|
* @param {RTCDataChannel} channel - Data channel to setup
|
||||||
|
*/
|
||||||
|
setupDataChannel(channel) {
|
||||||
|
this.dataChannel = channel;
|
||||||
|
|
||||||
|
channel.onopen = () => {
|
||||||
|
this.log('Data channel opened');
|
||||||
|
if (this.onDataChannelOpen) this.onDataChannelOpen();
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.onclose = () => {
|
||||||
|
this.log('Data channel closed');
|
||||||
|
if (this.onDataChannelClose) this.onDataChannelClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.onmessage = (event) => {
|
||||||
|
this.log('Data channel message:', event.data);
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(event.data);
|
||||||
|
} catch {
|
||||||
|
data = event.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.onDataChannelMessage) {
|
||||||
|
this.onDataChannelMessage(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.onerror = (error) => {
|
||||||
|
this.logError('Data channel error:', error);
|
||||||
|
if (this.onError) this.onError(error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create SDP Offer
|
||||||
|
* @param {object} options - Offer options
|
||||||
|
* @returns {Promise<RTCSessionDescriptionInit>}
|
||||||
|
*/
|
||||||
|
async createOffer(options = {}) {
|
||||||
|
try {
|
||||||
|
// Add transceivers for receiving video and audio
|
||||||
|
if (!options.dataChannelOnly) {
|
||||||
|
this.peerConnection.addTransceiver('video', { direction: 'recvonly' });
|
||||||
|
this.peerConnection.addTransceiver('audio', { direction: 'recvonly' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create data channel before offer
|
||||||
|
if (options.createDataChannel !== false) {
|
||||||
|
this.createDataChannel();
|
||||||
|
}
|
||||||
|
|
||||||
|
const offer = await this.peerConnection.createOffer({
|
||||||
|
offerToReceiveVideo: !options.dataChannelOnly,
|
||||||
|
offerToReceiveAudio: !options.dataChannelOnly
|
||||||
|
});
|
||||||
|
|
||||||
|
this.log('Created offer:', offer.sdp);
|
||||||
|
|
||||||
|
await this.peerConnection.setLocalDescription(offer);
|
||||||
|
this.log('Set local description (offer)');
|
||||||
|
|
||||||
|
return offer;
|
||||||
|
} catch (error) {
|
||||||
|
this.logError('Failed to create offer:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create SDP Answer (when receiving offer from camera)
|
||||||
|
* @returns {Promise<RTCSessionDescriptionInit>}
|
||||||
|
*/
|
||||||
|
async createAnswer() {
|
||||||
|
try {
|
||||||
|
const answer = await this.peerConnection.createAnswer();
|
||||||
|
this.log('Created answer:', answer.sdp);
|
||||||
|
|
||||||
|
await this.peerConnection.setLocalDescription(answer);
|
||||||
|
this.log('Set local description (answer)');
|
||||||
|
|
||||||
|
return answer;
|
||||||
|
} catch (error) {
|
||||||
|
this.logError('Failed to create answer:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set remote description (Offer from camera or Answer from camera)
|
||||||
|
* @param {string} sdp - SDP string
|
||||||
|
* @param {string} type - 'offer' or 'answer'
|
||||||
|
*/
|
||||||
|
async setRemoteDescription(sdp, type) {
|
||||||
|
try {
|
||||||
|
const description = new RTCSessionDescription({
|
||||||
|
type: type,
|
||||||
|
sdp: sdp
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.peerConnection.setRemoteDescription(description);
|
||||||
|
this.log(`Set remote description (${type})`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logError('Failed to set remote description:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add ICE candidate
|
||||||
|
* @param {object} candidate - ICE candidate object
|
||||||
|
*/
|
||||||
|
async addIceCandidate(candidate) {
|
||||||
|
try {
|
||||||
|
if (candidate && candidate.candidate) {
|
||||||
|
const iceCandidate = new RTCIceCandidate(candidate);
|
||||||
|
await this.peerConnection.addIceCandidate(iceCandidate);
|
||||||
|
this.log('Added ICE candidate');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logError('Failed to add ICE candidate:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for ICE gathering to complete
|
||||||
|
* @param {number} timeout - Timeout in milliseconds
|
||||||
|
* @returns {Promise<RTCIceCandidate[]>}
|
||||||
|
*/
|
||||||
|
async waitForIceGathering(timeout = null) {
|
||||||
|
const gatherTimeout = timeout || this.config.webrtc.iceGatheringTimeout;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (this.iceGatheringComplete) {
|
||||||
|
resolve(this.iceCandidates);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
this.log('ICE gathering timeout, proceeding with collected candidates');
|
||||||
|
resolve(this.iceCandidates);
|
||||||
|
}, gatherTimeout);
|
||||||
|
|
||||||
|
const originalCallback = this.onIceGatheringComplete;
|
||||||
|
this.onIceGatheringComplete = (candidates) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (originalCallback) originalCallback(candidates);
|
||||||
|
resolve(candidates);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get local description with gathered ICE candidates
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getLocalDescriptionWithCandidates() {
|
||||||
|
if (this.peerConnection.localDescription) {
|
||||||
|
return this.peerConnection.localDescription.sdp;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send message via data channel
|
||||||
|
* @param {object|string} message - Message to send
|
||||||
|
*/
|
||||||
|
sendDataChannelMessage(message) {
|
||||||
|
if (this.dataChannel && this.dataChannel.readyState === 'open') {
|
||||||
|
const payload = typeof message === 'string' ? message : JSON.stringify(message);
|
||||||
|
this.dataChannel.send(payload);
|
||||||
|
this.log('Sent data channel message:', payload);
|
||||||
|
} else {
|
||||||
|
this.logError('Data channel not open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request stream via data channel
|
||||||
|
* @param {string} nvrSerial - NVR serial number
|
||||||
|
* @param {number} channelMask - Channel bitmask
|
||||||
|
* @param {number} resolutionMask - Resolution bitmask
|
||||||
|
*/
|
||||||
|
requestStream(nvrSerial, channelMask, resolutionMask) {
|
||||||
|
const message = createStreamRequest(nvrSerial, channelMask, resolutionMask);
|
||||||
|
this.sendDataChannelMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request ONVIF status via data channel
|
||||||
|
* @param {string} nvrSerial - NVR serial number
|
||||||
|
*/
|
||||||
|
requestOnvifStatus(nvrSerial) {
|
||||||
|
const message = createOnvifStatusRequest(nvrSerial);
|
||||||
|
this.sendDataChannelMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remote stream
|
||||||
|
* @returns {MediaStream}
|
||||||
|
*/
|
||||||
|
getRemoteStream() {
|
||||||
|
return this.remoteStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get connection state
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getConnectionState() {
|
||||||
|
return this.peerConnection ? this.peerConnection.connectionState : 'closed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ICE connection state
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getIceConnectionState() {
|
||||||
|
return this.peerConnection ? this.peerConnection.iceConnectionState : 'closed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the connection
|
||||||
|
*/
|
||||||
|
close() {
|
||||||
|
if (this.dataChannel) {
|
||||||
|
this.dataChannel.close();
|
||||||
|
this.dataChannel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.peerConnection) {
|
||||||
|
this.peerConnection.close();
|
||||||
|
this.peerConnection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.remoteStream) {
|
||||||
|
this.remoteStream.getTracks().forEach(track => track.stop());
|
||||||
|
this.remoteStream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.iceCandidates = [];
|
||||||
|
this.iceGatheringComplete = false;
|
||||||
|
|
||||||
|
this.log('Connection closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset for new connection
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
this.close();
|
||||||
|
this.createPeerConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log helper
|
||||||
|
*/
|
||||||
|
log(...args) {
|
||||||
|
console.log('[WebRTC]', ...args);
|
||||||
|
if (window.addLog) window.addLog('WebRTC', args.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error log helper
|
||||||
|
*/
|
||||||
|
logError(...args) {
|
||||||
|
console.error('[WebRTC]', ...args);
|
||||||
|
if (window.addLog) window.addLog('WebRTC ERROR', args.join(' '));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for module usage
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { WebRTCClient };
|
||||||
|
}
|
||||||
89
cmd/adapter/main.go
Normal file
89
cmd/adapter/main.go
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
// Package main provides the entry point for the FPT Camera WebRTC Adapter
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/bluenviron/mediamtx/internal/adapter"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
version = "1.0.0"
|
||||||
|
buildTime = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Command line flags
|
||||||
|
showVersion := flag.Bool("version", false, "Show version information")
|
||||||
|
showEnvExample := flag.Bool("env-example", false, "Show example environment variables")
|
||||||
|
configFile := flag.String("config", "", "Path to configuration file (optional)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *showVersion {
|
||||||
|
fmt.Printf("FPT Camera WebRTC Adapter v%s (built: %s)\n", version, buildTime)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *showEnvExample {
|
||||||
|
fmt.Println(adapter.EnvExample())
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
config := adapter.LoadConfigFromEnv()
|
||||||
|
|
||||||
|
// Override with config file if provided
|
||||||
|
if *configFile != "" {
|
||||||
|
// TODO: Implement config file loading
|
||||||
|
fmt.Printf("Config file loading not yet implemented: %s\n", *configFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("===========================================")
|
||||||
|
fmt.Println(" FPT Camera WebRTC Adapter")
|
||||||
|
fmt.Printf(" Version: %s\n", version)
|
||||||
|
fmt.Println("===========================================")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Create and start adapter
|
||||||
|
adapterInstance := adapter.NewAdapter(config)
|
||||||
|
|
||||||
|
// Set callbacks
|
||||||
|
adapterInstance.OnClientConnected = func(clientID, serial string) {
|
||||||
|
fmt.Printf("[INFO] Client connected: %s (camera: %s)\n", clientID, serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
adapterInstance.OnClientDisconnected = func(clientID, serial string) {
|
||||||
|
fmt.Printf("[INFO] Client disconnected: %s (camera: %s)\n", clientID, serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
adapterInstance.OnError = func(err error) {
|
||||||
|
fmt.Printf("[ERROR] %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the adapter
|
||||||
|
if err := adapterInstance.Start(); err != nil {
|
||||||
|
fmt.Printf("[FATAL] Failed to start adapter: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("[INFO] Adapter started successfully")
|
||||||
|
fmt.Printf("[INFO] MQTT Broker: %s\n", config.MQTT.BrokerURL)
|
||||||
|
fmt.Printf("[INFO] MediaMTX WHEP: %s\n", config.WHEP.BaseURL)
|
||||||
|
fmt.Printf("[INFO] Max Sessions: %d\n", config.MaxSessions)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Press Ctrl+C to stop...")
|
||||||
|
|
||||||
|
// Wait for shutdown signal
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sigChan
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("[INFO] Shutting down...")
|
||||||
|
adapterInstance.Stop()
|
||||||
|
fmt.Println("[INFO] Adapter stopped")
|
||||||
|
}
|
||||||
1
go.mod
1
go.mod
|
|
@ -15,6 +15,7 @@ require (
|
||||||
github.com/bluenviron/gortsplib/v5 v5.2.0
|
github.com/bluenviron/gortsplib/v5 v5.2.0
|
||||||
github.com/bluenviron/mediacommon/v2 v2.5.2-0.20251201152746-8d059e8616fb
|
github.com/bluenviron/mediacommon/v2 v2.5.2-0.20251201152746-8d059e8616fb
|
||||||
github.com/datarhei/gosrt v0.9.0
|
github.com/datarhei/gosrt v0.9.0
|
||||||
|
github.com/eclipse/paho.mqtt.golang v1.4.3
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
github.com/gin-contrib/pprof v1.5.3
|
github.com/gin-contrib/pprof v1.5.3
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -57,6 +57,8 @@ github.com/datarhei/gosrt v0.9.0/go.mod h1:rqTRK8sDZdN2YBgp1EEICSV4297mQk0oglwvp
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
|
||||||
|
github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
|
||||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||||
|
|
|
||||||
196
internal/adapter/README.md
Normal file
196
internal/adapter/README.md
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
# FPT Camera WebRTC Adapter
|
||||||
|
|
||||||
|
This adapter bridges between FPT Camera's custom WebRTC signaling protocol (via MQTT) and MediaMTX's WHEP/WHIP protocol.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ MQTT ┌─────────────────┐ WHEP/WHIP ┌──────────┐
|
||||||
|
│ Client │◄─────────────────►│ Adapter │◄────────────────►│ MediaMTX │
|
||||||
|
│ (Mobile) │ │ (Bridge) │ │ (Server) │
|
||||||
|
└─────────────┘ └─────────────────┘ └──────────┘
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
FPT Payload Translation WHEP/WHIP
|
||||||
|
- Request - Session Mgmt Standard
|
||||||
|
- Offer - SDP Relay Protocol
|
||||||
|
- Answer - ICE Handling
|
||||||
|
- ICE Candidate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
|
||||||
|
### Signaling Flow (Client → Camera via Adapter)
|
||||||
|
|
||||||
|
1. **Client sends Request** → MQTT `ipc/<brand>/<serial>/request/signaling`
|
||||||
|
2. **Adapter receives Request** → Creates session, requests offer from MediaMTX
|
||||||
|
3. **MediaMTX returns Offer** → Via WHEP endpoint
|
||||||
|
4. **Adapter sends Offer** → MQTT `ipc/<brand>/<serial>/response/signaling`
|
||||||
|
5. **Client sends Answer** → MQTT `ipc/<brand>/<serial>/request/signaling`
|
||||||
|
6. **Adapter relays Answer** → To MediaMTX via WHEP
|
||||||
|
7. **Connection established** → Media flows via WebRTC
|
||||||
|
|
||||||
|
### FPT Camera Payload Format
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Method": "ACT",
|
||||||
|
"MessageType": "Signaling",
|
||||||
|
"Serial": "c05o24110000021",
|
||||||
|
"Data": {
|
||||||
|
"Type": "request",
|
||||||
|
"ClientId": "client-123"
|
||||||
|
},
|
||||||
|
"Timestamp": 1589861099
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Offer Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Method": "ACT",
|
||||||
|
"MessageType": "Signaling",
|
||||||
|
"Serial": "c05o24110000021",
|
||||||
|
"Data": {
|
||||||
|
"Type": "offer",
|
||||||
|
"ClientId": "client-123",
|
||||||
|
"Sdp": "v=0\r\n...",
|
||||||
|
"IceServers": ["stun-connect.fcam.vn", "turn-connect.fcam.vn"]
|
||||||
|
},
|
||||||
|
"Result": { "Ret": 100, "Message": "Success" },
|
||||||
|
"Timestamp": 1589861099
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Answer:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Method": "ACT",
|
||||||
|
"MessageType": "Signaling",
|
||||||
|
"Serial": "c05o24110000021",
|
||||||
|
"Data": {
|
||||||
|
"Type": "answer",
|
||||||
|
"Sdp": "v=0\r\n...",
|
||||||
|
"ClientId": "client-123"
|
||||||
|
},
|
||||||
|
"Timestamp": 1589861099
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/adapter/
|
||||||
|
├── doc.go # Package documentation
|
||||||
|
├── types.go # Message types and structures
|
||||||
|
├── mqtt.go # MQTT client wrapper
|
||||||
|
├── whep.go # WHEP client for MediaMTX
|
||||||
|
├── adapter.go # Main adapter logic
|
||||||
|
└── config.go # Configuration from environment
|
||||||
|
|
||||||
|
cmd/adapter/
|
||||||
|
└── main.go # Entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download dependencies
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
# Build the adapter
|
||||||
|
go build -o bin/fpt-adapter ./cmd/adapter
|
||||||
|
|
||||||
|
# Or using make
|
||||||
|
make -f adapter.mk build-adapter
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
### Using environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy example config
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Edit .env with your settings
|
||||||
|
vim .env
|
||||||
|
|
||||||
|
# Run with env file
|
||||||
|
export $(cat .env | grep -v '^#' | xargs) && ./bin/fpt-adapter
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using command line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show help
|
||||||
|
./bin/fpt-adapter -help
|
||||||
|
|
||||||
|
# Show version
|
||||||
|
./bin/fpt-adapter -version
|
||||||
|
|
||||||
|
# Show example env
|
||||||
|
./bin/fpt-adapter -env-example
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Environment Variable | Description | Default |
|
||||||
|
|---------------------|-------------|---------|
|
||||||
|
| FPT_MQTT_BROKER | MQTT broker URL | wss://beta-broker-mqtt.fcam.vn:8084/mqtt |
|
||||||
|
| FPT_MQTT_USER | MQTT username | - |
|
||||||
|
| FPT_MQTT_PASS | MQTT password | - |
|
||||||
|
| MEDIAMTX_WHEP_URL | MediaMTX WHEP endpoint | http://localhost:8889 |
|
||||||
|
| WEBRTC_STUN_SERVERS | STUN servers (comma-separated) | stun:stun-connect.fcam.vn:3478 |
|
||||||
|
| TURN_SERVER_URL | TURN server URL | turn:turn-connect.fcam.vn:3478 |
|
||||||
|
| TURN_USERNAME | TURN username | turnuser |
|
||||||
|
| TURN_PASSWORD | TURN password | - |
|
||||||
|
| ADAPTER_MAX_SESSIONS | Maximum concurrent sessions | 100 |
|
||||||
|
| ADAPTER_SESSION_TIMEOUT | Session timeout in minutes | 30 |
|
||||||
|
|
||||||
|
## MQTT Topics
|
||||||
|
|
||||||
|
| Topic | Direction | Description |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `ipc/discovery` | Subscribe | Camera discovery list |
|
||||||
|
| `ipc/<brand>/<serial>/request/signaling` | Subscribe | Client signaling requests |
|
||||||
|
| `ipc/<brand>/<serial>/response/signaling` | Publish | Signaling responses to client |
|
||||||
|
| `ipc/<brand>/<serial>/credential` | Subscribe | Camera credentials from client |
|
||||||
|
|
||||||
|
## Integration with MediaMTX
|
||||||
|
|
||||||
|
The adapter communicates with MediaMTX using the WHEP (WebRTC-HTTP Egress Protocol) endpoint:
|
||||||
|
|
||||||
|
1. **Stream Path**: Uses `<brand>/<serial>` as the stream path
|
||||||
|
2. **WHEP Endpoint**: `http://localhost:8889/<brand>/<serial>/whep`
|
||||||
|
3. **SDP Exchange**: Translates between FPT format and standard SDP
|
||||||
|
|
||||||
|
### MediaMTX Configuration
|
||||||
|
|
||||||
|
Ensure MediaMTX is configured with:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# mediamtx.yml
|
||||||
|
webrtc: yes
|
||||||
|
webrtcAddress: :8889
|
||||||
|
|
||||||
|
paths:
|
||||||
|
all:
|
||||||
|
# Allow all paths for dynamic camera streams
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run adapter tests
|
||||||
|
go test -v ./internal/adapter/...
|
||||||
|
|
||||||
|
# Or using make
|
||||||
|
make -f adapter.mk test-adapter
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
787
internal/adapter/adapter.go
Normal file
787
internal/adapter/adapter.go
Normal file
|
|
@ -0,0 +1,787 @@
|
||||||
|
package adapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionState represents the state of a client session
|
||||||
|
type SessionState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SessionStateNew SessionState = "new"
|
||||||
|
SessionStateWaitingOffer SessionState = "waiting_offer"
|
||||||
|
SessionStateOfferSent SessionState = "offer_sent"
|
||||||
|
SessionStateWaitingAnswer SessionState = "waiting_answer"
|
||||||
|
SessionStateAnswerReceived SessionState = "answer_received"
|
||||||
|
SessionStateConnected SessionState = "connected"
|
||||||
|
SessionStateFailed SessionState = "failed"
|
||||||
|
SessionStateClosed SessionState = "closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientSession represents an active client session
|
||||||
|
type ClientSession struct {
|
||||||
|
ClientID string
|
||||||
|
Brand string
|
||||||
|
Serial string
|
||||||
|
StreamPath string
|
||||||
|
State SessionState
|
||||||
|
WHEPSession *WHEPSession
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
OfferSDP string
|
||||||
|
AnswerSDP string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionManager manages client sessions
|
||||||
|
type SessionManager struct {
|
||||||
|
sessions map[string]*ClientSession // key: clientID
|
||||||
|
sessionsMu sync.RWMutex
|
||||||
|
maxSessions int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSessionManager creates a new session manager
|
||||||
|
func NewSessionManager(maxSessions int) *SessionManager {
|
||||||
|
if maxSessions <= 0 {
|
||||||
|
maxSessions = 100
|
||||||
|
}
|
||||||
|
return &SessionManager{
|
||||||
|
sessions: make(map[string]*ClientSession),
|
||||||
|
maxSessions: maxSessions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSession creates a new client session
|
||||||
|
func (sm *SessionManager) CreateSession(clientID, brand, serial string) (*ClientSession, error) {
|
||||||
|
sm.sessionsMu.Lock()
|
||||||
|
defer sm.sessionsMu.Unlock()
|
||||||
|
|
||||||
|
if len(sm.sessions) >= sm.maxSessions {
|
||||||
|
return nil, fmt.Errorf("max sessions reached: %d", sm.maxSessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build StreamPath without leading slash. If brand is empty (serial-only topics),
|
||||||
|
// use a default prefix 'fpt' to match MediaMTX expected paths like 'fpt/<serial>'.
|
||||||
|
streamPath := ""
|
||||||
|
if brand == "" {
|
||||||
|
streamPath = fmt.Sprintf("fpt/%s", serial)
|
||||||
|
} else {
|
||||||
|
streamPath = fmt.Sprintf("%s/%s", brand, serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
session := &ClientSession{
|
||||||
|
ClientID: clientID,
|
||||||
|
Brand: brand,
|
||||||
|
Serial: serial,
|
||||||
|
StreamPath: streamPath,
|
||||||
|
State: SessionStateNew,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.sessions[clientID] = session
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSession gets a session by client ID
|
||||||
|
func (sm *SessionManager) GetSession(clientID string) *ClientSession {
|
||||||
|
sm.sessionsMu.RLock()
|
||||||
|
defer sm.sessionsMu.RUnlock()
|
||||||
|
return sm.sessions[clientID]
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSessionState updates the state of a session
|
||||||
|
func (sm *SessionManager) UpdateSessionState(clientID string, state SessionState) error {
|
||||||
|
sm.sessionsMu.Lock()
|
||||||
|
defer sm.sessionsMu.Unlock()
|
||||||
|
|
||||||
|
session, ok := sm.sessions[clientID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session not found: %s", clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
session.State = state
|
||||||
|
session.UpdatedAt = time.Now()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSessionWHEP updates the WHEP session
|
||||||
|
func (sm *SessionManager) UpdateSessionWHEP(clientID string, whepSession *WHEPSession) error {
|
||||||
|
sm.sessionsMu.Lock()
|
||||||
|
defer sm.sessionsMu.Unlock()
|
||||||
|
|
||||||
|
session, ok := sm.sessions[clientID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session not found: %s", clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
session.WHEPSession = whepSession
|
||||||
|
session.UpdatedAt = time.Now()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSession deletes a session
|
||||||
|
func (sm *SessionManager) DeleteSession(clientID string) {
|
||||||
|
sm.sessionsMu.Lock()
|
||||||
|
defer sm.sessionsMu.Unlock()
|
||||||
|
delete(sm.sessions, clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessionCount returns the number of active sessions
|
||||||
|
func (sm *SessionManager) GetSessionCount() int {
|
||||||
|
sm.sessionsMu.RLock()
|
||||||
|
defer sm.sessionsMu.RUnlock()
|
||||||
|
return len(sm.sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessionsBySerial returns all sessions for a specific serial
|
||||||
|
func (sm *SessionManager) GetSessionsBySerial(serial string) []*ClientSession {
|
||||||
|
sm.sessionsMu.RLock()
|
||||||
|
defer sm.sessionsMu.RUnlock()
|
||||||
|
|
||||||
|
var sessions []*ClientSession
|
||||||
|
for _, session := range sm.sessions {
|
||||||
|
if session.Serial == serial {
|
||||||
|
sessions = append(sessions, session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupStaleSessions removes sessions older than the specified duration
|
||||||
|
func (sm *SessionManager) CleanupStaleSessions(maxAge time.Duration) int {
|
||||||
|
sm.sessionsMu.Lock()
|
||||||
|
defer sm.sessionsMu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
for clientID, session := range sm.sessions {
|
||||||
|
if now.Sub(session.UpdatedAt) > maxAge {
|
||||||
|
delete(sm.sessions, clientID)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdapterConfig holds the adapter configuration
|
||||||
|
type AdapterConfig struct {
|
||||||
|
MQTT MQTTConfig
|
||||||
|
WHEP WHEPConfig
|
||||||
|
MediaMTXAPIURL string // MediaMTX REST API URL (default: http://localhost:9997)
|
||||||
|
MaxSessions int
|
||||||
|
SessionTimeout time.Duration
|
||||||
|
LogLevel string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultAdapterConfig returns default adapter configuration
|
||||||
|
func DefaultAdapterConfig() AdapterConfig {
|
||||||
|
return AdapterConfig{
|
||||||
|
MQTT: DefaultMQTTConfig(),
|
||||||
|
WHEP: DefaultWHEPConfig(),
|
||||||
|
MediaMTXAPIURL: "http://localhost:9997",
|
||||||
|
MaxSessions: 100,
|
||||||
|
SessionTimeout: 30 * time.Minute,
|
||||||
|
LogLevel: "info",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CameraCredentials stores credentials for a camera
|
||||||
|
type CameraCredentials struct {
|
||||||
|
Serial string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
RTSPURL string // constructed RTSP URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapter is the main adapter that bridges FPT Camera signaling with MediaMTX
|
||||||
|
type Adapter struct {
|
||||||
|
config AdapterConfig
|
||||||
|
mqtt *MQTTClient
|
||||||
|
whep *WHEPClient
|
||||||
|
sessions *SessionManager
|
||||||
|
credentials map[string]*CameraCredentials // key: serial
|
||||||
|
credentialsMu sync.RWMutex
|
||||||
|
running bool
|
||||||
|
runningMu sync.RWMutex
|
||||||
|
stopChan chan struct{}
|
||||||
|
apiServer *APIServer
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
OnClientConnected func(clientID, serial string)
|
||||||
|
OnClientDisconnected func(clientID, serial string)
|
||||||
|
OnError func(err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdapter creates a new adapter
|
||||||
|
func NewAdapter(config AdapterConfig) *Adapter {
|
||||||
|
return &Adapter{
|
||||||
|
config: config,
|
||||||
|
mqtt: NewMQTTClient(config.MQTT),
|
||||||
|
whep: NewWHEPClient(config.WHEP),
|
||||||
|
sessions: NewSessionManager(config.MaxSessions),
|
||||||
|
credentials: make(map[string]*CameraCredentials),
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the adapter
|
||||||
|
func (a *Adapter) Start() error {
|
||||||
|
a.runningMu.Lock()
|
||||||
|
if a.running {
|
||||||
|
a.runningMu.Unlock()
|
||||||
|
return fmt.Errorf("adapter already running")
|
||||||
|
}
|
||||||
|
a.running = true
|
||||||
|
a.runningMu.Unlock()
|
||||||
|
|
||||||
|
// Setup MQTT callbacks
|
||||||
|
a.mqtt.OnConnected = func() {
|
||||||
|
a.log("Connected to MQTT broker")
|
||||||
|
a.subscribeToTopics()
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mqtt.OnDisconnected = func(err error) {
|
||||||
|
a.log("Disconnected from MQTT broker: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to MQTT
|
||||||
|
if err := a.mqtt.Connect(); err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to MQTT: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start API server for ONVIF discovery
|
||||||
|
a.apiServer = NewAPIServer(a, 8890)
|
||||||
|
if err := a.apiServer.Start(); err != nil {
|
||||||
|
a.logError("Failed to start API server: %v", err)
|
||||||
|
// Non-fatal, continue without API
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start cleanup goroutine
|
||||||
|
go a.cleanupLoop()
|
||||||
|
|
||||||
|
a.log("Adapter started")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the adapter
|
||||||
|
func (a *Adapter) Stop() {
|
||||||
|
a.runningMu.Lock()
|
||||||
|
if !a.running {
|
||||||
|
a.runningMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.running = false
|
||||||
|
a.runningMu.Unlock()
|
||||||
|
|
||||||
|
close(a.stopChan)
|
||||||
|
|
||||||
|
// Stop API server
|
||||||
|
if a.apiServer != nil {
|
||||||
|
a.apiServer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mqtt.Disconnect()
|
||||||
|
a.log("Adapter stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribeToTopics subscribes to all required MQTT topics
|
||||||
|
func (a *Adapter) subscribeToTopics() {
|
||||||
|
// Subscribe to all signaling requests (both brand/serial and serial-only formats)
|
||||||
|
topic1 := a.mqtt.Topics().RequestSignalingWildcard()
|
||||||
|
if err := a.mqtt.Subscribe(topic1, a.handleSignalingRequest); err != nil {
|
||||||
|
a.logError("Failed to subscribe to signaling (brand/serial): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
topic2 := a.mqtt.Topics().RequestSignalingWildcardSingle()
|
||||||
|
if err := a.mqtt.Subscribe(topic2, a.handleSignalingRequest); err != nil {
|
||||||
|
a.logError("Failed to subscribe to signaling (serial-only): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to all credentials (both formats)
|
||||||
|
cred1 := a.mqtt.Topics().CredentialWildcard()
|
||||||
|
if err := a.mqtt.Subscribe(cred1, a.handleCredential); err != nil {
|
||||||
|
a.logError("Failed to subscribe to credentials (brand/serial): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cred2 := a.mqtt.Topics().CredentialWildcardSingle()
|
||||||
|
if err := a.mqtt.Subscribe(cred2, a.handleCredential); err != nil {
|
||||||
|
a.logError("Failed to subscribe to credentials (serial-only): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to adapter control topic for add_source commands
|
||||||
|
controlTopic := "fpt/adapter/control"
|
||||||
|
if err := a.mqtt.Subscribe(controlTopic, a.handleControlMessage); err != nil {
|
||||||
|
a.logError("Failed to subscribe to control topic: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.log("Subscribed to topics: %s, %s, %s, %s, %s", topic1, topic2, cred1, cred2, controlTopic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSignalingRequest handles incoming signaling requests from clients
|
||||||
|
func (a *Adapter) handleSignalingRequest(topic string, payload []byte) {
|
||||||
|
a.log("Received signaling request on topic: %s", topic)
|
||||||
|
|
||||||
|
// Parse topic to extract brand and serial
|
||||||
|
brand, serial, err := a.parseSignalingTopic(topic)
|
||||||
|
if err != nil {
|
||||||
|
a.logError("Failed to parse topic: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the message
|
||||||
|
var msg SignalingMessage
|
||||||
|
if err := json.Unmarshal(payload, &msg); err != nil {
|
||||||
|
a.logError("Failed to parse message: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get message type
|
||||||
|
msgType, ok := msg.Data["Type"].(string)
|
||||||
|
if !ok {
|
||||||
|
a.logError("Missing Type in message data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID, _ := msg.Data["ClientId"].(string)
|
||||||
|
|
||||||
|
switch SignalingType(msgType) {
|
||||||
|
case SignalingTypeRequest:
|
||||||
|
a.handleInitialRequest(brand, serial, clientID)
|
||||||
|
|
||||||
|
case SignalingTypeAnswer:
|
||||||
|
sdp, _ := msg.Data["Sdp"].(string)
|
||||||
|
a.handleAnswer(brand, serial, clientID, sdp)
|
||||||
|
|
||||||
|
case SignalingTypeIceCandidate:
|
||||||
|
a.handleIceCandidate(brand, serial, clientID, msg.Data)
|
||||||
|
|
||||||
|
default:
|
||||||
|
a.log("Unknown signaling type: %s", msgType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSignalingTopic parses the signaling topic to extract brand and serial
|
||||||
|
func (a *Adapter) parseSignalingTopic(topic string) (brand, serial string, err error) {
|
||||||
|
// Use TopicBuilder to parse topic based on configured prefix
|
||||||
|
brand, serial, msgType, parseErr := a.mqtt.Topics().ParseTopic(topic)
|
||||||
|
if parseErr != nil {
|
||||||
|
return "", "", fmt.Errorf("invalid topic format: %s", topic)
|
||||||
|
}
|
||||||
|
if msgType != "request/signaling" {
|
||||||
|
return "", "", fmt.Errorf("unexpected message type: %s", msgType)
|
||||||
|
}
|
||||||
|
return brand, serial, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleInitialRequest handles the initial signaling request from a client
|
||||||
|
func (a *Adapter) handleInitialRequest(brand, serial, clientID string) {
|
||||||
|
a.log("Handling initial request from client %s for %s", clientID, serial)
|
||||||
|
|
||||||
|
// Check if max sessions reached
|
||||||
|
if a.sessions.GetSessionCount() >= a.config.MaxSessions {
|
||||||
|
// Send deny response
|
||||||
|
denyResp := NewDenyResponse(serial, clientID, a.config.MaxSessions, a.sessions.GetSessionCount())
|
||||||
|
if err := a.mqtt.PublishSignalingResponse(brand, serial, denyResp); err != nil {
|
||||||
|
a.logError("Failed to send deny response: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
session, err := a.sessions.CreateSession(clientID, brand, serial)
|
||||||
|
if err != nil {
|
||||||
|
a.logError("Failed to create session: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.log("Created session for client %s -> stream %s", clientID, session.StreamPath)
|
||||||
|
|
||||||
|
// Get offer from MediaMTX via WHEP
|
||||||
|
// For standard WHEP, client sends offer and server responds with answer
|
||||||
|
// But FPT protocol expects server to send offer first
|
||||||
|
// So we need to generate an offer ourselves or use MediaMTX's special handling
|
||||||
|
|
||||||
|
// For now, we'll try to get stream info and create an offer
|
||||||
|
streamPath := session.StreamPath
|
||||||
|
|
||||||
|
// Check if stream is available
|
||||||
|
if !a.whep.CheckStreamReady(streamPath) {
|
||||||
|
a.log("Stream not ready: %s", streamPath)
|
||||||
|
// Still send offer, MediaMTX might handle it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a basic SDP offer
|
||||||
|
// In real implementation, this would come from WebRTC peer connection
|
||||||
|
// For now, we'll request MediaMTX to generate one or use a template
|
||||||
|
|
||||||
|
whepSession, err := a.whep.CreateOffer(streamPath)
|
||||||
|
if err != nil {
|
||||||
|
a.logError("Failed to get offer from MediaMTX: %v", err)
|
||||||
|
// Try alternative approach - send a placeholder and wait for client offer
|
||||||
|
a.sendOfferRequest(brand, serial, clientID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update session
|
||||||
|
session.WHEPSession = whepSession
|
||||||
|
session.OfferSDP = whepSession.AnswerSDP // MediaMTX sends SDP in response
|
||||||
|
a.sessions.UpdateSessionState(clientID, SessionStateOfferSent)
|
||||||
|
|
||||||
|
// Send offer to client via MQTT
|
||||||
|
iceServers := a.whep.GetICEServers()
|
||||||
|
offerResp := NewOfferResponse(serial, clientID, whepSession.AnswerSDP, iceServers)
|
||||||
|
|
||||||
|
if err := a.mqtt.PublishSignalingResponse(brand, serial, offerResp); err != nil {
|
||||||
|
a.logError("Failed to send offer response: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.log("Sent offer to client %s", clientID)
|
||||||
|
|
||||||
|
if a.OnClientConnected != nil {
|
||||||
|
a.OnClientConnected(clientID, serial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendOfferRequest sends a request for client to send offer (alternative flow)
|
||||||
|
func (a *Adapter) sendOfferRequest(brand, serial, clientID string) {
|
||||||
|
|
||||||
|
iceServers := a.whep.GetICEServers()
|
||||||
|
|
||||||
|
// Send a special response indicating client should send offer
|
||||||
|
resp := &SignalingResponse{
|
||||||
|
Method: "ACT",
|
||||||
|
MessageType: "Signaling",
|
||||||
|
Serial: serial,
|
||||||
|
Data: ResponseData{
|
||||||
|
Type: "request_offer",
|
||||||
|
ClientID: clientID,
|
||||||
|
IceServers: iceServers,
|
||||||
|
},
|
||||||
|
Timestamp: time.Now().Unix(),
|
||||||
|
Result: Result{
|
||||||
|
Ret: ResultSuccess,
|
||||||
|
Message: "Please send offer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mqtt.PublishSignalingResponse(brand, serial, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAnswer handles the SDP answer from client
|
||||||
|
func (a *Adapter) handleAnswer(brand, serial, clientID, sdp string) {
|
||||||
|
a.log("Handling answer from client %s", clientID)
|
||||||
|
|
||||||
|
session := a.sessions.GetSession(clientID)
|
||||||
|
if session == nil {
|
||||||
|
a.logError("Session not found for client: %s", clientID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.AnswerSDP = sdp
|
||||||
|
|
||||||
|
// If we have a WHEP session, send the answer to MediaMTX
|
||||||
|
if session.WHEPSession != nil && session.WHEPSession.SessionURL != "" {
|
||||||
|
if err := a.whep.SendAnswer(session.WHEPSession.SessionURL, sdp); err != nil {
|
||||||
|
a.logError("Failed to send answer to MediaMTX: %v", err)
|
||||||
|
a.sessions.UpdateSessionState(clientID, SessionStateFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Client sent offer, now we send to MediaMTX and get answer
|
||||||
|
whepSession, err := a.whep.SendOffer(session.StreamPath, sdp)
|
||||||
|
if err != nil {
|
||||||
|
a.logError("Failed to send offer to MediaMTX: %v", err)
|
||||||
|
a.sessions.UpdateSessionState(clientID, SessionStateFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.WHEPSession = whepSession
|
||||||
|
a.sessions.UpdateSessionWHEP(clientID, whepSession)
|
||||||
|
|
||||||
|
// Send answer back to client
|
||||||
|
iceServers := a.whep.GetICEServers()
|
||||||
|
offerResp := NewOfferResponse(serial, clientID, whepSession.AnswerSDP, iceServers)
|
||||||
|
offerResp.Data.Type = SignalingTypeAnswer
|
||||||
|
|
||||||
|
if err := a.mqtt.PublishSignalingResponse(brand, serial, offerResp); err != nil {
|
||||||
|
a.logError("Failed to send answer response: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.sessions.UpdateSessionState(clientID, SessionStateConnected)
|
||||||
|
|
||||||
|
// Send CCU update
|
||||||
|
ccuResp := NewCCUResponse(serial, a.sessions.GetSessionCount())
|
||||||
|
a.mqtt.PublishSignalingResponse(brand, serial, ccuResp)
|
||||||
|
|
||||||
|
a.log("Client %s connected successfully", clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleIceCandidate handles ICE candidates from client
|
||||||
|
func (a *Adapter) handleIceCandidate(brand, serial, clientID string, data map[string]interface{}) {
|
||||||
|
// brand and serial may not be needed here; mark as used to avoid compiler warnings
|
||||||
|
_ = brand
|
||||||
|
_ = serial
|
||||||
|
a.log("Handling ICE candidate from client %s", clientID)
|
||||||
|
|
||||||
|
session := a.sessions.GetSession(clientID)
|
||||||
|
if session == nil || session.WHEPSession == nil {
|
||||||
|
a.logError("Session not found for ICE candidate: %s", clientID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse candidate
|
||||||
|
candidateData, ok := data["Candidate"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
a.logError("Invalid candidate data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate := IceCandidate{
|
||||||
|
Candidate: candidateData["candidate"].(string),
|
||||||
|
SDPMid: candidateData["sdpMid"].(string),
|
||||||
|
}
|
||||||
|
if idx, ok := candidateData["sdpMLineIndex"].(float64); ok {
|
||||||
|
candidate.SDPMLineIndex = int(idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to MediaMTX
|
||||||
|
if err := a.whep.AddICECandidate(session.WHEPSession.SessionURL, candidate, session.WHEPSession.ETag); err != nil {
|
||||||
|
a.logError("Failed to add ICE candidate: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCredential handles camera credentials from client
|
||||||
|
func (a *Adapter) handleCredential(topic string, payload []byte) {
|
||||||
|
a.log("Received credential on topic: %s", topic)
|
||||||
|
a.log("Credential payload: %s", string(payload))
|
||||||
|
|
||||||
|
var msg CredentialMessage
|
||||||
|
if err := json.Unmarshal(payload, &msg); err != nil {
|
||||||
|
a.logError("Failed to parse credential message: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.log("Received credentials for camera %s: user=%s, ip=%s", msg.Serial, msg.Data.Username, msg.Data.IP)
|
||||||
|
|
||||||
|
// Get camera IP - either from message or use serial as hostname
|
||||||
|
cameraIP := msg.Data.IP
|
||||||
|
if cameraIP == "" {
|
||||||
|
// If no IP provided, try using serial as hostname (might work in some networks)
|
||||||
|
// Or you could implement ONVIF WS-Discovery here
|
||||||
|
a.log("No IP provided, using serial as hostname: %s", msg.Serial)
|
||||||
|
cameraIP = msg.Serial
|
||||||
|
}
|
||||||
|
|
||||||
|
var rtspURL string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Try ONVIF to get RTSP URL (this will log all SOAP XML)
|
||||||
|
// Use client with auth for HTTP Digest fallback
|
||||||
|
a.log("Attempting ONVIF connection to %s...", cameraIP)
|
||||||
|
onvif := NewONVIFClientWithAuth(a.log, msg.Data.Username, msg.Data.Password)
|
||||||
|
rtspURL, err = onvif.GetRTSPURL(cameraIP, 80, msg.Data.Username, msg.Data.Password)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
a.logError("ONVIF failed: %v", err)
|
||||||
|
// Fallback: construct default RTSP URL (Hikvision/FPT camera format)
|
||||||
|
a.log("Falling back to default RTSP URL format")
|
||||||
|
rtspURL = fmt.Sprintf("rtsp://%s:%s@%s:554/Streaming/Channels/101",
|
||||||
|
msg.Data.Username, msg.Data.Password, cameraIP)
|
||||||
|
} else {
|
||||||
|
a.log("ONVIF success! Got RTSP URL: %s", rtspURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store credentials
|
||||||
|
creds := &CameraCredentials{
|
||||||
|
Serial: msg.Serial,
|
||||||
|
Username: msg.Data.Username,
|
||||||
|
Password: msg.Data.Password,
|
||||||
|
RTSPURL: rtspURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
a.credentialsMu.Lock()
|
||||||
|
a.credentials[msg.Serial] = creds
|
||||||
|
a.credentialsMu.Unlock()
|
||||||
|
|
||||||
|
// Create path in MediaMTX with RTSP source
|
||||||
|
streamPath := fmt.Sprintf("fpt/%s", msg.Serial)
|
||||||
|
if err := a.whep.AddPath(streamPath, rtspURL); err != nil {
|
||||||
|
a.logError("Failed to add path to MediaMTX: %v", err)
|
||||||
|
// Don't return - path might already exist, continue anyway
|
||||||
|
} else {
|
||||||
|
a.log("Created MediaMTX path %s -> %s", streamPath, rtspURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupLoop periodically cleans up stale sessions
|
||||||
|
func (a *Adapter) cleanupLoop() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
count := a.sessions.CleanupStaleSessions(a.config.SessionTimeout)
|
||||||
|
if count > 0 {
|
||||||
|
a.log("Cleaned up %d stale sessions", count)
|
||||||
|
}
|
||||||
|
case <-a.stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessionCount returns the number of active sessions
|
||||||
|
func (a *Adapter) GetSessionCount() int {
|
||||||
|
return a.sessions.GetSessionCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisconnectClient disconnects a specific client
|
||||||
|
func (a *Adapter) DisconnectClient(clientID string) error {
|
||||||
|
session := a.sessions.GetSession(clientID)
|
||||||
|
if session == nil {
|
||||||
|
return fmt.Errorf("session not found: %s", clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete WHEP session if exists
|
||||||
|
if session.WHEPSession != nil && session.WHEPSession.SessionURL != "" {
|
||||||
|
a.whep.DeleteSession(session.WHEPSession.SessionURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.sessions.DeleteSession(clientID)
|
||||||
|
|
||||||
|
if a.OnClientDisconnected != nil {
|
||||||
|
a.OnClientDisconnected(clientID, session.Serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ControlMessage represents a control message from the client
|
||||||
|
type ControlMessage struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
RTSPUrl string `json:"rtsp_url"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleControlMessage handles control messages for adapter operations
|
||||||
|
func (a *Adapter) handleControlMessage(topic string, payload []byte) {
|
||||||
|
a.log("Received control message on topic: %s", topic)
|
||||||
|
|
||||||
|
var msg ControlMessage
|
||||||
|
if err := json.Unmarshal(payload, &msg); err != nil {
|
||||||
|
a.logError("Failed to parse control message: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.log("Control message: action=%s, path=%s, rtsp_url=%s", msg.Action, msg.Path, msg.RTSPUrl)
|
||||||
|
|
||||||
|
switch msg.Action {
|
||||||
|
case "add_source":
|
||||||
|
a.handleAddSource(msg.Path, msg.RTSPUrl)
|
||||||
|
case "remove_source":
|
||||||
|
a.handleRemoveSource(msg.Path)
|
||||||
|
default:
|
||||||
|
a.logError("Unknown control action: %s", msg.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAddSource adds an RTSP source to MediaMTX
|
||||||
|
func (a *Adapter) handleAddSource(path, rtspUrl string) {
|
||||||
|
if path == "" || rtspUrl == "" {
|
||||||
|
a.logError("add_source requires path and rtsp_url")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.log("Adding RTSP source: path=%s, url=%s", path, rtspUrl)
|
||||||
|
|
||||||
|
// Use MediaMTX API to add the path with RTSP source
|
||||||
|
err := a.addPathToMediaMTX(path, rtspUrl)
|
||||||
|
if err != nil {
|
||||||
|
a.logError("Failed to add source to MediaMTX: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.log("Successfully added source: %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRemoveSource removes a source from MediaMTX
|
||||||
|
func (a *Adapter) handleRemoveSource(path string) {
|
||||||
|
if path == "" {
|
||||||
|
a.logError("remove_source requires path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.log("Removing source: path=%s", path)
|
||||||
|
// TODO: Implement removal via MediaMTX API
|
||||||
|
}
|
||||||
|
|
||||||
|
// addPathToMediaMTX adds a path configuration to MediaMTX via its API
|
||||||
|
func (a *Adapter) addPathToMediaMTX(path, rtspUrl string) error {
|
||||||
|
// MediaMTX API endpoint for adding/editing path config
|
||||||
|
// POST /v3/config/paths/add/{name}
|
||||||
|
// or PATCH /v3/config/paths/edit/{name}
|
||||||
|
|
||||||
|
apiUrl := fmt.Sprintf("%s/v3/config/paths/add/%s", a.config.MediaMTXAPIURL, path)
|
||||||
|
|
||||||
|
// Path configuration
|
||||||
|
pathConfig := map[string]interface{}{
|
||||||
|
"source": rtspUrl,
|
||||||
|
}
|
||||||
|
|
||||||
|
configJSON, err := json.Marshal(pathConfig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.log("Calling MediaMTX API: POST %s", apiUrl)
|
||||||
|
a.log("Config: %s", string(configJSON))
|
||||||
|
|
||||||
|
// Make HTTP request to MediaMTX API
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
req, err := http.NewRequest("POST", apiUrl, bytes.NewBuffer(configJSON))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
a.log("MediaMTX API response: %d - %s", resp.StatusCode, string(body))
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
return fmt.Errorf("MediaMTX API returned %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// log logs a message
|
||||||
|
func (a *Adapter) log(format string, args ...interface{}) {
|
||||||
|
fmt.Printf("[Adapter] "+format+"\n", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// logError logs an error
|
||||||
|
func (a *Adapter) logError(format string, args ...interface{}) {
|
||||||
|
fmt.Printf("[Adapter ERROR] "+format+"\n", args...)
|
||||||
|
if a.OnError != nil {
|
||||||
|
a.OnError(fmt.Errorf(format, args...))
|
||||||
|
}
|
||||||
|
}
|
||||||
532
internal/adapter/api.go
Normal file
532
internal/adapter/api.go
Normal file
|
|
@ -0,0 +1,532 @@
|
||||||
|
package adapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// APIServer provides REST API for the adapter
|
||||||
|
type APIServer struct {
|
||||||
|
adapter *Adapter
|
||||||
|
port int
|
||||||
|
server *http.Server
|
||||||
|
|
||||||
|
// Discovery cache
|
||||||
|
discoveredDevices []DiscoveredDevice
|
||||||
|
discoveryMu sync.RWMutex
|
||||||
|
lastDiscovery time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoveredDevice represents an ONVIF device found on network
|
||||||
|
type DiscoveredDevice struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
XAddr string `json:"xaddr"`
|
||||||
|
Manufacturer string `json:"manufacturer,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Serial string `json:"serial,omitempty"`
|
||||||
|
Firmware string `json:"firmware,omitempty"`
|
||||||
|
Profiles []ProfileInfo `json:"profiles,omitempty"`
|
||||||
|
Services []string `json:"services,omitempty"`
|
||||||
|
HasMedia bool `json:"hasMedia"`
|
||||||
|
DiscoveredAt string `json:"discoveredAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileInfo contains stream profile information
|
||||||
|
type ProfileInfo struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
RTSPPath string `json:"rtspPath,omitempty"` // Path without credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoverResponse is the API response for /api/discover
|
||||||
|
type DiscoverResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Devices []DiscoveredDevice `json:"devices"`
|
||||||
|
ScanTime string `json:"scanTime"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthRequest is the request body for /api/auth
|
||||||
|
type AuthRequest struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthResponse is the API response for /api/auth
|
||||||
|
type AuthResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
RTSPURL string `json:"rtspUrl,omitempty"`
|
||||||
|
RTSPURLs []string `json:"rtspUrls,omitempty"` // Multiple profiles
|
||||||
|
Profiles []ProfileInfo `json:"profiles,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAPIServer creates a new API server
|
||||||
|
func NewAPIServer(adapter *Adapter, port int) *APIServer {
|
||||||
|
return &APIServer{
|
||||||
|
adapter: adapter,
|
||||||
|
port: port,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the API server
|
||||||
|
func (s *APIServer) Start() error {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// CORS middleware wrapper
|
||||||
|
corsHandler := func(h http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
mux.HandleFunc("/api/discover", corsHandler(s.handleDiscover))
|
||||||
|
mux.HandleFunc("/api/auth", corsHandler(s.handleAuth))
|
||||||
|
mux.HandleFunc("/api/devices", corsHandler(s.handleGetDevices))
|
||||||
|
mux.HandleFunc("/api/health", corsHandler(s.handleHealth))
|
||||||
|
|
||||||
|
s.server = &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", s.port),
|
||||||
|
Handler: mux,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.adapter.log("API server starting on port %d", s.port)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := s.server.ListenAndServe(); err != http.ErrServerClosed {
|
||||||
|
s.adapter.logError("API server error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the API server
|
||||||
|
func (s *APIServer) Stop() {
|
||||||
|
if s.server != nil {
|
||||||
|
s.server.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHealth returns server health status
|
||||||
|
func (s *APIServer) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"time": time.Now().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDiscover scans network for ONVIF devices
|
||||||
|
func (s *APIServer) handleDiscover(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
s.adapter.log("API: Starting network discovery...")
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Use ONVIF WS-Discovery
|
||||||
|
onvif := NewONVIFClient(s.adapter.log)
|
||||||
|
cameras, err := onvif.Discover(5 * time.Second)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.adapter.logError("Discovery failed: %v", err)
|
||||||
|
json.NewEncoder(w).Encode(DiscoverResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: err.Error(),
|
||||||
|
Devices: []DiscoveredDevice{},
|
||||||
|
ScanTime: startTime.Format(time.RFC3339),
|
||||||
|
Duration: time.Since(startTime).String(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to DiscoveredDevice
|
||||||
|
var devices []DiscoveredDevice
|
||||||
|
for _, cam := range cameras {
|
||||||
|
device := DiscoveredDevice{
|
||||||
|
IP: cam.IP,
|
||||||
|
Port: cam.Port,
|
||||||
|
XAddr: cam.XAddr,
|
||||||
|
Serial: cam.Serial,
|
||||||
|
HasMedia: false,
|
||||||
|
DiscoveredAt: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get device info (without auth - some cameras allow this)
|
||||||
|
if cam.XAddr != "" {
|
||||||
|
info, err := onvif.GetDeviceInfo(cam.XAddr, "", "")
|
||||||
|
if err == nil {
|
||||||
|
device.Manufacturer = info["Manufacturer"]
|
||||||
|
device.Model = info["Model"]
|
||||||
|
device.Serial = info["SerialNumber"]
|
||||||
|
device.Firmware = info["FirmwareVersion"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get services to check for media
|
||||||
|
services, err := onvif.GetServices(cam.XAddr, "", "")
|
||||||
|
if err == nil {
|
||||||
|
for ns := range services {
|
||||||
|
device.Services = append(device.Services, ns)
|
||||||
|
if containsMedia(ns) {
|
||||||
|
device.HasMedia = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
devices = append(devices, device)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache results
|
||||||
|
s.discoveryMu.Lock()
|
||||||
|
s.discoveredDevices = devices
|
||||||
|
s.lastDiscovery = time.Now()
|
||||||
|
s.discoveryMu.Unlock()
|
||||||
|
|
||||||
|
s.adapter.log("API: Discovery completed, found %d device(s)", len(devices))
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(DiscoverResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: fmt.Sprintf("Found %d device(s)", len(devices)),
|
||||||
|
Devices: devices,
|
||||||
|
ScanTime: startTime.Format(time.RFC3339),
|
||||||
|
Duration: time.Since(startTime).String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetDevices returns cached discovered devices
|
||||||
|
func (s *APIServer) handleGetDevices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
s.discoveryMu.RLock()
|
||||||
|
devices := s.discoveredDevices
|
||||||
|
lastScan := s.lastDiscovery
|
||||||
|
s.discoveryMu.RUnlock()
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"devices": devices,
|
||||||
|
"lastScan": lastScan.Format(time.RFC3339),
|
||||||
|
"cached": !lastScan.IsZero(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAuth authenticates with a device and gets RTSP URLs
|
||||||
|
func (s *APIServer) handleAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if r.Method != "POST" {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req AuthRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
json.NewEncoder(w).Encode(AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Invalid request body",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.IP == "" {
|
||||||
|
json.NewEncoder(w).Encode(AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "IP is required",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Port == 0 {
|
||||||
|
req.Port = 80
|
||||||
|
}
|
||||||
|
|
||||||
|
s.adapter.log("API: Authenticating with %s:%d (user: %s)", req.IP, req.Port, req.Username)
|
||||||
|
|
||||||
|
// Create ONVIF client with credentials
|
||||||
|
onvif := NewONVIFClientWithAuth(s.adapter.log, req.Username, req.Password)
|
||||||
|
|
||||||
|
// Build device URL
|
||||||
|
deviceURL := fmt.Sprintf("http://%s:%d/onvif/device_service", req.IP, req.Port)
|
||||||
|
|
||||||
|
// Get services to find media URL
|
||||||
|
services, err := onvif.GetServices(deviceURL, req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
s.adapter.logError("Failed to get services: %v", err)
|
||||||
|
|
||||||
|
// If the request port is 554 (RTSP) or we timed out contacting ONVIF,
|
||||||
|
// try RTSP probe as a fallback (device might expose RTSP only)
|
||||||
|
if req.Port == 554 {
|
||||||
|
s.adapter.log("API: ONVIF failed on port 554; attempting RTSP probe fallback")
|
||||||
|
rtspUrls := s.probeRTSP(req.IP, req.Port, req.Username, req.Password)
|
||||||
|
if len(rtspUrls) > 0 {
|
||||||
|
// store credential and return success
|
||||||
|
serial := fmt.Sprintf("%s_%d", req.IP, req.Port)
|
||||||
|
s.adapter.credentialsMu.Lock()
|
||||||
|
s.adapter.credentials[serial] = &CameraCredentials{
|
||||||
|
Serial: serial,
|
||||||
|
Username: req.Username,
|
||||||
|
Password: req.Password,
|
||||||
|
RTSPURL: rtspUrls[0],
|
||||||
|
}
|
||||||
|
s.adapter.credentialsMu.Unlock()
|
||||||
|
|
||||||
|
s.adapter.log("API: RTSP probe successful, found %d URL(s)", len(rtspUrls))
|
||||||
|
json.NewEncoder(w).Encode(AuthResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: fmt.Sprintf("RTSP probe found %d URL(s)", len(rtspUrls)),
|
||||||
|
RTSPURL: rtspUrls[0],
|
||||||
|
RTSPURLs: rtspUrls,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: fmt.Sprintf("Failed to connect: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find media service URL
|
||||||
|
var mediaURL string
|
||||||
|
for ns, url := range services {
|
||||||
|
if containsMedia(ns) {
|
||||||
|
mediaURL = url
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mediaURL == "" {
|
||||||
|
mediaURL = fmt.Sprintf("http://%s:%d/onvif/media_service", req.IP, req.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.adapter.log("API: Media service URL: %s", mediaURL)
|
||||||
|
|
||||||
|
// Get profiles
|
||||||
|
profiles, err := onvif.GetProfiles(mediaURL, req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
s.adapter.logError("Failed to get profiles: %v", err)
|
||||||
|
json.NewEncoder(w).Encode(AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: fmt.Sprintf("Authentication failed: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(profiles) == 0 {
|
||||||
|
json.NewEncoder(w).Encode(AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "No stream profiles found",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get stream URIs for each profile
|
||||||
|
var rtspURLs []string
|
||||||
|
var profileInfos []ProfileInfo
|
||||||
|
|
||||||
|
for _, profile := range profiles {
|
||||||
|
streamURI, err := onvif.GetStreamURI(mediaURL, req.Username, req.Password, profile.Token)
|
||||||
|
if err != nil {
|
||||||
|
s.adapter.log("Failed to get stream URI for profile %s: %v", profile.Token, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rtspURLs = append(rtspURLs, streamURI)
|
||||||
|
profileInfos = append(profileInfos, ProfileInfo{
|
||||||
|
Token: profile.Token,
|
||||||
|
Name: profile.Name,
|
||||||
|
RTSPPath: streamURI,
|
||||||
|
})
|
||||||
|
|
||||||
|
s.adapter.log("API: Profile %s (%s) -> %s", profile.Token, profile.Name, streamURI)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rtspURLs) == 0 {
|
||||||
|
json.NewEncoder(w).Encode(AuthResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "Failed to get stream URLs",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store credentials in adapter
|
||||||
|
serial := fmt.Sprintf("%s_%d", req.IP, req.Port)
|
||||||
|
s.adapter.credentialsMu.Lock()
|
||||||
|
s.adapter.credentials[serial] = &CameraCredentials{
|
||||||
|
Serial: serial,
|
||||||
|
Username: req.Username,
|
||||||
|
Password: req.Password,
|
||||||
|
RTSPURL: rtspURLs[0],
|
||||||
|
}
|
||||||
|
s.adapter.credentialsMu.Unlock()
|
||||||
|
|
||||||
|
s.adapter.log("API: Authentication successful, found %d stream(s)", len(rtspURLs))
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(AuthResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: fmt.Sprintf("Found %d stream profile(s)", len(rtspURLs)),
|
||||||
|
RTSPURL: rtspURLs[0],
|
||||||
|
RTSPURLs: rtspURLs,
|
||||||
|
Profiles: profileInfos,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeRTSP tries common RTSP paths on the device and returns successful URLs
|
||||||
|
func (s *APIServer) probeRTSP(ip string, port int, username, password string) []string {
|
||||||
|
commonPaths := []string{
|
||||||
|
"/",
|
||||||
|
"/cam/realmonitor?channel=1",
|
||||||
|
"/cam/realmonitor?channel=1&subtype=0",
|
||||||
|
"/Streaming/Channels/101",
|
||||||
|
"/live.sdp",
|
||||||
|
}
|
||||||
|
|
||||||
|
var found []string
|
||||||
|
for _, p := range commonPaths {
|
||||||
|
ok, err := sendRTSPDescribe(ip, port, p, username, password)
|
||||||
|
if err != nil {
|
||||||
|
s.adapter.log("RTSP probe %s%s failed: %v", ip, p, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
// build URL with credentials if provided
|
||||||
|
url := fmt.Sprintf("rtsp://%s:%d%s", ip, port, p)
|
||||||
|
if username != "" {
|
||||||
|
// encode naive - assume no special chars
|
||||||
|
url = fmt.Sprintf("rtsp://%s:%s@%s:%d%s", username, password, ip, port, p)
|
||||||
|
}
|
||||||
|
found = append(found, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendRTSPDescribe sends an RTSP DESCRIBE and handles Digest auth if required
|
||||||
|
func sendRTSPDescribe(ip string, port int, path, username, password string) (bool, error) {
|
||||||
|
addr := fmt.Sprintf("%s:%d", ip, port)
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
path = "/" + path
|
||||||
|
}
|
||||||
|
uri := fmt.Sprintf("rtsp://%s:%d%s", ip, port, path)
|
||||||
|
|
||||||
|
// send initial DESCRIBE
|
||||||
|
req := fmt.Sprintf("DESCRIBE %s RTSP/1.0\r\nCSeq: 1\r\nAccept: application/sdp\r\n\r\n", uri)
|
||||||
|
if _, err := conn.Write([]byte(req)); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
// read status line
|
||||||
|
statusLine, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
statusLine = strings.TrimSpace(statusLine)
|
||||||
|
// Example: RTSP/1.0 401 Unauthorized
|
||||||
|
if strings.Contains(statusLine, "200") {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// read headers
|
||||||
|
headers := map[string]string{}
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth, ok := headers["WWW-Authenticate"]; ok && strings.Contains(auth, "Digest") && username != "" {
|
||||||
|
// parse realm/nonce
|
||||||
|
realm := extractDigestParam(auth, "realm")
|
||||||
|
nonce := extractDigestParam(auth, "nonce")
|
||||||
|
qop := extractDigestParam(auth, "qop")
|
||||||
|
|
||||||
|
ha1 := md5Hash(username + ":" + realm + ":" + password)
|
||||||
|
ha2 := md5Hash("DESCRIBE:" + getURIPath(uri))
|
||||||
|
nc := "00000001"
|
||||||
|
cnonce := fmt.Sprintf("%08x", time.Now().UnixNano())
|
||||||
|
|
||||||
|
var response string
|
||||||
|
if qop != "" {
|
||||||
|
response = md5Hash(ha1 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + ha2)
|
||||||
|
} else {
|
||||||
|
response = md5Hash(ha1 + ":" + nonce + ":" + ha2)
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader := fmt.Sprintf(`Digest username="%s", realm="%s", nonce="%s", uri="%s", response="%s"`, username, realm, nonce, getURIPath(uri), response)
|
||||||
|
if qop != "" {
|
||||||
|
authHeader += fmt.Sprintf(", qop=%s, nc=%s, cnonce=\"%s\"", qop, nc, cnonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
// open new connection for authenticated DESCRIBE
|
||||||
|
conn2, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer conn2.Close()
|
||||||
|
|
||||||
|
req2 := fmt.Sprintf("DESCRIBE %s RTSP/1.0\r\nCSeq: 2\r\nAccept: application/sdp\r\nAuthorization: %s\r\n\r\n", uri, authHeader)
|
||||||
|
if _, err := conn2.Write([]byte(req2)); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r2 := bufio.NewReader(conn2)
|
||||||
|
status2, err := r2.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
status2 = strings.TrimSpace(status2)
|
||||||
|
if strings.Contains(status2, "200") {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to check if namespace contains media service
|
||||||
|
func containsMedia(ns string) bool {
|
||||||
|
return len(ns) > 0 && (
|
||||||
|
ns == "http://www.onvif.org/ver10/media/wsdl" ||
|
||||||
|
ns == "http://www.onvif.org/ver20/media/wsdl" ||
|
||||||
|
// Check if contains "media" substring
|
||||||
|
(len(ns) > 5 && (ns[len(ns)-5:] == "media" ||
|
||||||
|
(len(ns) > 10 && ns[len(ns)-10:] == "media/wsdl"))))
|
||||||
|
}
|
||||||
108
internal/adapter/config.go
Normal file
108
internal/adapter/config.go
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
package adapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadConfigFromEnv loads adapter configuration from environment variables
|
||||||
|
func LoadConfigFromEnv() AdapterConfig {
|
||||||
|
config := DefaultAdapterConfig()
|
||||||
|
|
||||||
|
// MQTT Configuration
|
||||||
|
if v := os.Getenv("FPT_MQTT_BROKER"); v != "" {
|
||||||
|
config.MQTT.BrokerURL = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("FPT_MQTT_USER"); v != "" {
|
||||||
|
config.MQTT.Username = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("FPT_MQTT_PASS"); v != "" {
|
||||||
|
config.MQTT.Password = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("FPT_MQTT_TOPIC_PREFIX"); v != "" {
|
||||||
|
config.MQTT.TopicPrefix = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("FPT_MQTT_CLIENT_PREFIX"); v != "" {
|
||||||
|
config.MQTT.ClientIDPrefix = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("FPT_MQTT_TLS_ENABLED"); v != "" {
|
||||||
|
config.MQTT.TLSEnabled = v == "1" || strings.ToLower(v) == "true"
|
||||||
|
}
|
||||||
|
if v := os.Getenv("FPT_MQTT_TLS_INSECURE_SKIP_VERIFY"); v != "" {
|
||||||
|
config.MQTT.TLSInsecureSkipVerify = v == "1" || strings.ToLower(v) == "true"
|
||||||
|
}
|
||||||
|
if v := os.Getenv("FPT_MQTT_QOS"); v != "" {
|
||||||
|
if qos, err := strconv.Atoi(v); err == nil && qos >= 0 && qos <= 2 {
|
||||||
|
config.MQTT.QoS = byte(qos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := os.Getenv("FPT_MQTT_KEEPALIVE"); v != "" {
|
||||||
|
if secs, err := strconv.Atoi(v); err == nil {
|
||||||
|
config.MQTT.KeepAlive = time.Duration(secs) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WHEP Configuration
|
||||||
|
if v := os.Getenv("MEDIAMTX_WHEP_URL"); v != "" {
|
||||||
|
config.WHEP.BaseURL = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("WEBRTC_STUN_SERVERS"); v != "" {
|
||||||
|
config.WHEP.STUNServers = strings.Split(v, ",")
|
||||||
|
}
|
||||||
|
if v := os.Getenv("TURN_SERVER_URL"); v != "" {
|
||||||
|
config.WHEP.TURNServer = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("TURN_USERNAME"); v != "" {
|
||||||
|
config.WHEP.TURNUsername = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("TURN_PASSWORD"); v != "" {
|
||||||
|
config.WHEP.TURNPassword = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session Configuration
|
||||||
|
if v := os.Getenv("ADAPTER_MAX_SESSIONS"); v != "" {
|
||||||
|
if max, err := strconv.Atoi(v); err == nil {
|
||||||
|
config.MaxSessions = max
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := os.Getenv("ADAPTER_SESSION_TIMEOUT"); v != "" {
|
||||||
|
if mins, err := strconv.Atoi(v); err == nil {
|
||||||
|
config.SessionTimeout = time.Duration(mins) * time.Minute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := os.Getenv("ADAPTER_LOG_LEVEL"); v != "" {
|
||||||
|
config.LogLevel = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvExample returns example environment variables
|
||||||
|
func EnvExample() string {
|
||||||
|
return `# FPT Camera MQTT Configuration
|
||||||
|
FPT_MQTT_BROKER=wss://beta-broker-mqtt.fcam.vn:8084/mqtt
|
||||||
|
FPT_MQTT_USER=hoangbd7
|
||||||
|
FPT_MQTT_PASS=Hoangbd7
|
||||||
|
FPT_MQTT_CLIENT_PREFIX=mediamtx-adapter-
|
||||||
|
FPT_MQTT_TLS_ENABLED=1
|
||||||
|
FPT_MQTT_TLS_INSECURE_SKIP_VERIFY=1
|
||||||
|
FPT_MQTT_QOS=1
|
||||||
|
FPT_MQTT_KEEPALIVE=60
|
||||||
|
|
||||||
|
# MediaMTX WHEP Configuration
|
||||||
|
MEDIAMTX_WHEP_URL=http://localhost:8889
|
||||||
|
|
||||||
|
# WebRTC ICE Servers
|
||||||
|
WEBRTC_STUN_SERVERS=stun:stun-connect.fcam.vn:3478,stun:stunp-connect.fcam.vn:3478
|
||||||
|
TURN_SERVER_URL=turn:turn-connect.fcam.vn:3478
|
||||||
|
TURN_USERNAME=turnuser
|
||||||
|
TURN_PASSWORD=camfptvnturn133099
|
||||||
|
|
||||||
|
# Adapter Configuration
|
||||||
|
ADAPTER_MAX_SESSIONS=100
|
||||||
|
ADAPTER_SESSION_TIMEOUT=30
|
||||||
|
ADAPTER_LOG_LEVEL=info
|
||||||
|
`
|
||||||
|
}
|
||||||
399
internal/adapter/mqtt.go
Normal file
399
internal/adapter/mqtt.go
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
package adapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MQTTConfig holds MQTT connection configuration
|
||||||
|
type MQTTConfig struct {
|
||||||
|
BrokerURL string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
ClientIDPrefix string
|
||||||
|
TopicPrefix string
|
||||||
|
KeepAlive time.Duration
|
||||||
|
ConnectTimeout time.Duration
|
||||||
|
ReconnectInterval time.Duration
|
||||||
|
TLSEnabled bool
|
||||||
|
TLSInsecureSkipVerify bool
|
||||||
|
QoS byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultMQTTConfig returns default MQTT configuration
|
||||||
|
func DefaultMQTTConfig() MQTTConfig {
|
||||||
|
return MQTTConfig{
|
||||||
|
BrokerURL: "wss://beta-broker-mqtt.fcam.vn:8084/mqtt",
|
||||||
|
Username: "",
|
||||||
|
Password: "",
|
||||||
|
ClientIDPrefix: "mediamtx-adapter-",
|
||||||
|
TopicPrefix: "ipc/fss",
|
||||||
|
KeepAlive: 60 * time.Second,
|
||||||
|
ConnectTimeout: 30 * time.Second,
|
||||||
|
ReconnectInterval: 5 * time.Second,
|
||||||
|
TLSEnabled: true,
|
||||||
|
TLSInsecureSkipVerify: true,
|
||||||
|
QoS: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TopicBuilder builds MQTT topics for FPT Camera protocol
|
||||||
|
type TopicBuilder struct {
|
||||||
|
prefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTopicBuilder creates a new topic builder
|
||||||
|
func NewTopicBuilder(prefix string) *TopicBuilder {
|
||||||
|
if prefix == "" {
|
||||||
|
prefix = "ipc"
|
||||||
|
}
|
||||||
|
return &TopicBuilder{prefix: prefix}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discovery returns the discovery topic
|
||||||
|
func (tb *TopicBuilder) Discovery() string {
|
||||||
|
return fmt.Sprintf("%s/discovery", tb.prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestSignaling returns the request signaling topic for a camera
|
||||||
|
func (tb *TopicBuilder) RequestSignaling(brand, serial string) string {
|
||||||
|
return fmt.Sprintf("%s/%s/%s/request/signaling", tb.prefix, brand, serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponseSignaling returns the response signaling topic for a camera
|
||||||
|
func (tb *TopicBuilder) ResponseSignaling(brand, serial string) string {
|
||||||
|
return fmt.Sprintf("%s/%s/%s/response/signaling", tb.prefix, brand, serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credential returns the credential topic for a camera
|
||||||
|
func (tb *TopicBuilder) Credential(brand, serial string) string {
|
||||||
|
return fmt.Sprintf("%s/%s/%s/credential", tb.prefix, brand, serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestSignalingWildcard returns wildcard topic for all request signaling
|
||||||
|
func (tb *TopicBuilder) RequestSignalingWildcard() string {
|
||||||
|
return fmt.Sprintf("%s/+/+/request/signaling", tb.prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CredentialWildcard returns wildcard topic for all credentials
|
||||||
|
func (tb *TopicBuilder) CredentialWildcard() string {
|
||||||
|
return fmt.Sprintf("%s/+/+/credential", tb.prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestSignalingWildcardSingle returns wildcard topic assuming client uses only serial segment
|
||||||
|
func (tb *TopicBuilder) RequestSignalingWildcardSingle() string {
|
||||||
|
return fmt.Sprintf("%s/+/request/signaling", tb.prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CredentialWildcardSingle returns wildcard topic assuming client uses only serial segment
|
||||||
|
func (tb *TopicBuilder) CredentialWildcardSingle() string {
|
||||||
|
return fmt.Sprintf("%s/+/credential", tb.prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTopic parses a topic and extracts brand and serial
|
||||||
|
func (tb *TopicBuilder) ParseTopic(topic string) (brand, serial, messageType string, err error) {
|
||||||
|
// Split topic and prefix into parts
|
||||||
|
parts := splitTopic(topic)
|
||||||
|
prefixParts := splitTopic(tb.prefix)
|
||||||
|
|
||||||
|
if len(parts) < len(prefixParts)+1 {
|
||||||
|
return "", "", "", fmt.Errorf("invalid topic format: %s", topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify prefix matches the beginning of the topic; if so, remove those segments
|
||||||
|
matches := true
|
||||||
|
for i := 0; i < len(prefixParts); i++ {
|
||||||
|
if parts[i] != prefixParts[i] {
|
||||||
|
matches = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matches {
|
||||||
|
return "", "", "", fmt.Errorf("topic does not start with expected prefix: %s", topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts = parts[len(prefixParts):]
|
||||||
|
|
||||||
|
if len(parts) == 3 {
|
||||||
|
// serial-only format: <serial>/<type>/<subtype>
|
||||||
|
serial = parts[0]
|
||||||
|
brand = ""
|
||||||
|
messageType = parts[1] + "/" + parts[2]
|
||||||
|
return brand, serial, messageType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) == 4 {
|
||||||
|
// brand/serial format: <brand>/<serial>/<type>/<subtype>
|
||||||
|
brand = parts[0]
|
||||||
|
serial = parts[1]
|
||||||
|
messageType = parts[2] + "/" + parts[3]
|
||||||
|
return brand, serial, messageType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", "", "", fmt.Errorf("invalid topic format: %s", topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MQTTClient wraps the MQTT client for FPT Camera signaling
|
||||||
|
type MQTTClient struct {
|
||||||
|
config MQTTConfig
|
||||||
|
client mqtt.Client
|
||||||
|
topics *TopicBuilder
|
||||||
|
handlers map[string]MessageHandler
|
||||||
|
handlersMu sync.RWMutex
|
||||||
|
connected bool
|
||||||
|
connectedMu sync.RWMutex
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
OnConnected func()
|
||||||
|
OnDisconnected func(error)
|
||||||
|
OnError func(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageHandler is a callback for handling MQTT messages
|
||||||
|
type MessageHandler func(topic string, payload []byte)
|
||||||
|
|
||||||
|
// NewMQTTClient creates a new MQTT client
|
||||||
|
func NewMQTTClient(config MQTTConfig) *MQTTClient {
|
||||||
|
return &MQTTClient{
|
||||||
|
config: config,
|
||||||
|
topics: NewTopicBuilder(config.TopicPrefix),
|
||||||
|
handlers: make(map[string]MessageHandler),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Topics returns the topic builder
|
||||||
|
func (mc *MQTTClient) Topics() *TopicBuilder {
|
||||||
|
return mc.topics
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect connects to the MQTT broker
|
||||||
|
func (mc *MQTTClient) Connect() error {
|
||||||
|
opts := mqtt.NewClientOptions()
|
||||||
|
opts.AddBroker(mc.config.BrokerURL)
|
||||||
|
opts.SetClientID(mc.config.ClientIDPrefix + generateClientID())
|
||||||
|
opts.SetUsername(mc.config.Username)
|
||||||
|
opts.SetPassword(mc.config.Password)
|
||||||
|
opts.SetKeepAlive(mc.config.KeepAlive)
|
||||||
|
opts.SetConnectTimeout(mc.config.ConnectTimeout)
|
||||||
|
opts.SetAutoReconnect(true)
|
||||||
|
opts.SetConnectRetryInterval(mc.config.ReconnectInterval)
|
||||||
|
opts.SetCleanSession(true)
|
||||||
|
|
||||||
|
// TLS configuration
|
||||||
|
if mc.config.TLSEnabled {
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
InsecureSkipVerify: mc.config.TLSInsecureSkipVerify,
|
||||||
|
}
|
||||||
|
opts.SetTLSConfig(tlsConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection handlers
|
||||||
|
opts.SetOnConnectHandler(func(client mqtt.Client) {
|
||||||
|
mc.connectedMu.Lock()
|
||||||
|
mc.connected = true
|
||||||
|
mc.connectedMu.Unlock()
|
||||||
|
|
||||||
|
// Resubscribe to all topics
|
||||||
|
mc.resubscribeAll()
|
||||||
|
|
||||||
|
if mc.OnConnected != nil {
|
||||||
|
mc.OnConnected()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opts.SetConnectionLostHandler(func(client mqtt.Client, err error) {
|
||||||
|
mc.connectedMu.Lock()
|
||||||
|
mc.connected = false
|
||||||
|
mc.connectedMu.Unlock()
|
||||||
|
|
||||||
|
if mc.OnDisconnected != nil {
|
||||||
|
mc.OnDisconnected(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opts.SetDefaultPublishHandler(func(client mqtt.Client, msg mqtt.Message) {
|
||||||
|
mc.handleMessage(msg.Topic(), msg.Payload())
|
||||||
|
})
|
||||||
|
|
||||||
|
mc.client = mqtt.NewClient(opts)
|
||||||
|
|
||||||
|
token := mc.client.Connect()
|
||||||
|
if token.Wait() && token.Error() != nil {
|
||||||
|
return fmt.Errorf("failed to connect to MQTT broker: %w", token.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect disconnects from the MQTT broker
|
||||||
|
func (mc *MQTTClient) Disconnect() {
|
||||||
|
if mc.client != nil && mc.client.IsConnected() {
|
||||||
|
mc.client.Disconnect(250)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConnected returns true if connected to the broker
|
||||||
|
func (mc *MQTTClient) IsConnected() bool {
|
||||||
|
mc.connectedMu.RLock()
|
||||||
|
defer mc.connectedMu.RUnlock()
|
||||||
|
return mc.connected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe subscribes to a topic with a message handler
|
||||||
|
func (mc *MQTTClient) Subscribe(topic string, handler MessageHandler) error {
|
||||||
|
mc.handlersMu.Lock()
|
||||||
|
mc.handlers[topic] = handler
|
||||||
|
mc.handlersMu.Unlock()
|
||||||
|
|
||||||
|
token := mc.client.Subscribe(topic, mc.config.QoS, func(client mqtt.Client, msg mqtt.Message) {
|
||||||
|
mc.handleMessage(msg.Topic(), msg.Payload())
|
||||||
|
})
|
||||||
|
|
||||||
|
if token.Wait() && token.Error() != nil {
|
||||||
|
return fmt.Errorf("failed to subscribe to %s: %w", topic, token.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe unsubscribes from a topic
|
||||||
|
func (mc *MQTTClient) Unsubscribe(topic string) error {
|
||||||
|
mc.handlersMu.Lock()
|
||||||
|
delete(mc.handlers, topic)
|
||||||
|
mc.handlersMu.Unlock()
|
||||||
|
|
||||||
|
token := mc.client.Unsubscribe(topic)
|
||||||
|
if token.Wait() && token.Error() != nil {
|
||||||
|
return fmt.Errorf("failed to unsubscribe from %s: %w", topic, token.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish publishes a message to a topic
|
||||||
|
func (mc *MQTTClient) Publish(topic string, payload interface{}, retain bool) error {
|
||||||
|
var data []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch v := payload.(type) {
|
||||||
|
case []byte:
|
||||||
|
data = v
|
||||||
|
case string:
|
||||||
|
data = []byte(v)
|
||||||
|
default:
|
||||||
|
data, err = json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
token := mc.client.Publish(topic, mc.config.QoS, retain, data)
|
||||||
|
if token.Wait() && token.Error() != nil {
|
||||||
|
return fmt.Errorf("failed to publish to %s: %w", topic, token.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishSignalingResponse publishes a signaling response
|
||||||
|
func (mc *MQTTClient) PublishSignalingResponse(brand, serial string, response interface{}) error {
|
||||||
|
topic := mc.topics.ResponseSignaling(brand, serial)
|
||||||
|
return mc.Publish(topic, response, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMessage routes messages to registered handlers
|
||||||
|
func (mc *MQTTClient) handleMessage(topic string, payload []byte) {
|
||||||
|
mc.handlersMu.RLock()
|
||||||
|
|
||||||
|
// Check for exact match first
|
||||||
|
if handler, ok := mc.handlers[topic]; ok {
|
||||||
|
mc.handlersMu.RUnlock()
|
||||||
|
handler(topic, payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for wildcard matches
|
||||||
|
for pattern, handler := range mc.handlers {
|
||||||
|
if matchTopic(pattern, topic) {
|
||||||
|
mc.handlersMu.RUnlock()
|
||||||
|
handler(topic, payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mc.handlersMu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resubscribeAll resubscribes to all topics after reconnection
|
||||||
|
func (mc *MQTTClient) resubscribeAll() {
|
||||||
|
mc.handlersMu.RLock()
|
||||||
|
topics := make([]string, 0, len(mc.handlers))
|
||||||
|
for topic := range mc.handlers {
|
||||||
|
topics = append(topics, topic)
|
||||||
|
}
|
||||||
|
mc.handlersMu.RUnlock()
|
||||||
|
|
||||||
|
for _, topic := range topics {
|
||||||
|
mc.client.Subscribe(topic, mc.config.QoS, func(client mqtt.Client, msg mqtt.Message) {
|
||||||
|
mc.handleMessage(msg.Topic(), msg.Payload())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchTopic checks if a topic matches a pattern with wildcards
|
||||||
|
func matchTopic(pattern, topic string) bool {
|
||||||
|
// Simple wildcard matching for + and #
|
||||||
|
// This is a simplified implementation
|
||||||
|
patternParts := splitTopic(pattern)
|
||||||
|
topicParts := splitTopic(topic)
|
||||||
|
|
||||||
|
if len(patternParts) != len(topicParts) {
|
||||||
|
// Check for # wildcard at the end
|
||||||
|
if len(patternParts) > 0 && patternParts[len(patternParts)-1] == "#" {
|
||||||
|
return len(topicParts) >= len(patternParts)-1
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, part := range patternParts {
|
||||||
|
if part == "+" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if part == "#" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if part != topicParts[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitTopic splits a topic into parts
|
||||||
|
func splitTopic(topic string) []string {
|
||||||
|
var parts []string
|
||||||
|
current := ""
|
||||||
|
for _, c := range topic {
|
||||||
|
if c == '/' {
|
||||||
|
parts = append(parts, current)
|
||||||
|
current = ""
|
||||||
|
} else {
|
||||||
|
current += string(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current != "" {
|
||||||
|
parts = append(parts, current)
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateClientID generates a unique client ID
|
||||||
|
func generateClientID() string {
|
||||||
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
717
internal/adapter/onvif.go
Normal file
717
internal/adapter/onvif.go
Normal file
|
|
@ -0,0 +1,717 @@
|
||||||
|
package adapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ONVIFClient handles ONVIF protocol communication
|
||||||
|
type ONVIFClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
logger func(format string, args ...interface{})
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewONVIFClient creates a new ONVIF client
|
||||||
|
func NewONVIFClient(logger func(format string, args ...interface{})) *ONVIFClient {
|
||||||
|
return &ONVIFClient{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewONVIFClientWithAuth creates a new ONVIF client with stored credentials for HTTP Digest
|
||||||
|
func NewONVIFClientWithAuth(logger func(format string, args ...interface{}), username, password string) *ONVIFClient {
|
||||||
|
return &ONVIFClient{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
logger: logger,
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoveredCamera holds info about a discovered camera
|
||||||
|
type DiscoveredCamera struct {
|
||||||
|
Serial string
|
||||||
|
IP string
|
||||||
|
Port int
|
||||||
|
XAddr string // ONVIF service address
|
||||||
|
Model string
|
||||||
|
Endpoints map[string]string // service -> endpoint URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamProfile holds camera stream profile info
|
||||||
|
type StreamProfile struct {
|
||||||
|
Token string
|
||||||
|
Name string
|
||||||
|
VideoWidth int
|
||||||
|
VideoHeight int
|
||||||
|
VideoCodec string
|
||||||
|
StreamURI string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// WS-Discovery - Find cameras on network
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const wsDiscoveryProbe = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<e:Envelope xmlns:e="http://www.w3.org/2003/05/soap-envelope"
|
||||||
|
xmlns:w="http://schemas.xmlsoap.org/ws/2004/08/addressing"
|
||||||
|
xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery"
|
||||||
|
xmlns:dn="http://www.onvif.org/ver10/network/wsdl">
|
||||||
|
<e:Header>
|
||||||
|
<w:MessageID>uuid:%s</w:MessageID>
|
||||||
|
<w:To e:mustUnderstand="true">urn:schemas-xmlsoap-org:ws:2005:04:discovery</w:To>
|
||||||
|
<w:Action e:mustUnderstand="true">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</w:Action>
|
||||||
|
</e:Header>
|
||||||
|
<e:Body>
|
||||||
|
<d:Probe>
|
||||||
|
<d:Types>dn:NetworkVideoTransmitter</d:Types>
|
||||||
|
</d:Probe>
|
||||||
|
</e:Body>
|
||||||
|
</e:Envelope>`
|
||||||
|
|
||||||
|
// Alternative probe without Types filter (finds more devices)
|
||||||
|
const wsDiscoveryProbeAll = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<e:Envelope xmlns:e="http://www.w3.org/2003/05/soap-envelope"
|
||||||
|
xmlns:w="http://schemas.xmlsoap.org/ws/2004/08/addressing"
|
||||||
|
xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery">
|
||||||
|
<e:Header>
|
||||||
|
<w:MessageID>uuid:%s</w:MessageID>
|
||||||
|
<w:To e:mustUnderstand="true">urn:schemas-xmlsoap-org:ws:2005:04:discovery</w:To>
|
||||||
|
<w:Action e:mustUnderstand="true">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</w:Action>
|
||||||
|
</e:Header>
|
||||||
|
<e:Body>
|
||||||
|
<d:Probe/>
|
||||||
|
</e:Body>
|
||||||
|
</e:Envelope>`
|
||||||
|
|
||||||
|
// generateUUID creates a simple UUID v4-like string
|
||||||
|
func generateUUID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = byte(time.Now().UnixNano() >> (i * 4))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
||||||
|
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover finds ONVIF cameras on the network
|
||||||
|
func (c *ONVIFClient) Discover(timeout time.Duration) ([]DiscoveredCamera, error) {
|
||||||
|
c.log("Starting WS-Discovery on all interfaces...")
|
||||||
|
|
||||||
|
// Get all local interfaces
|
||||||
|
interfaces, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get interfaces: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multicast address for WS-Discovery
|
||||||
|
multicastAddr, err := net.ResolveUDPAddr("udp4", "239.255.255.250:3702")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve multicast address: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track unique cameras by IP
|
||||||
|
cameraMap := make(map[string]*DiscoveredCamera)
|
||||||
|
|
||||||
|
// Generate UUID for message ID
|
||||||
|
msgID := generateUUID()
|
||||||
|
|
||||||
|
// Try both probe types
|
||||||
|
probes := []string{
|
||||||
|
fmt.Sprintf(wsDiscoveryProbe, msgID),
|
||||||
|
fmt.Sprintf(wsDiscoveryProbeAll, generateUUID()),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send probes from each interface
|
||||||
|
for _, iface := range interfaces {
|
||||||
|
// Skip loopback, down, or non-multicast interfaces
|
||||||
|
if iface.Flags&net.FlagLoopback != 0 ||
|
||||||
|
iface.Flags&net.FlagUp == 0 ||
|
||||||
|
iface.Flags&net.FlagMulticast == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
addrs, err := iface.Addrs()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, addr := range addrs {
|
||||||
|
ipNet, ok := addr.(*net.IPNet)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := ipNet.IP.To4()
|
||||||
|
if ip == nil {
|
||||||
|
continue // Skip IPv6
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind to this specific interface IP
|
||||||
|
localAddr := &net.UDPAddr{IP: ip, Port: 0}
|
||||||
|
conn, err := net.ListenUDP("udp4", localAddr)
|
||||||
|
if err != nil {
|
||||||
|
c.log("Failed to bind to %s: %v", ip, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log("Sending WS-Discovery from interface %s (%s)", iface.Name, ip)
|
||||||
|
|
||||||
|
// Send both probe types
|
||||||
|
for _, probe := range probes {
|
||||||
|
_, err = conn.WriteToUDP([]byte(probe), multicastAddr)
|
||||||
|
if err != nil {
|
||||||
|
c.log("Failed to send probe from %s: %v", ip, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for responses with timeout
|
||||||
|
conn.SetReadDeadline(time.Now().Add(timeout))
|
||||||
|
buf := make([]byte, 16384) // Larger buffer
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, remoteAddr, err := conn.ReadFromUDP(buf)
|
||||||
|
if err != nil {
|
||||||
|
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||||
|
break // Normal timeout
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
response := string(buf[:n])
|
||||||
|
c.log("WS-Discovery response from %s (len=%d)", remoteAddr.String(), n)
|
||||||
|
|
||||||
|
// Parse response
|
||||||
|
camera := c.parseDiscoveryResponse(response, remoteAddr.IP.String())
|
||||||
|
if camera != nil {
|
||||||
|
// Deduplicate by IP
|
||||||
|
if _, exists := cameraMap[camera.IP]; !exists {
|
||||||
|
cameraMap[camera.IP] = camera
|
||||||
|
c.log("Found device: IP=%s, XAddr=%s", camera.IP, camera.XAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert map to slice
|
||||||
|
var cameras []DiscoveredCamera
|
||||||
|
for _, cam := range cameraMap {
|
||||||
|
cameras = append(cameras, *cam)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log("Discovered %d unique device(s)", len(cameras))
|
||||||
|
return cameras, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDiscoveryResponse extracts camera info from WS-Discovery response
|
||||||
|
func (c *ONVIFClient) parseDiscoveryResponse(response, ip string) *DiscoveredCamera {
|
||||||
|
// Extract XAddrs (ONVIF service URLs)
|
||||||
|
xaddrRe := regexp.MustCompile(`<[^:]*:XAddrs>([^<]+)</[^:]*:XAddrs>`)
|
||||||
|
matches := xaddrRe.FindStringSubmatch(response)
|
||||||
|
if len(matches) < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
xaddrs := strings.Fields(matches[1])
|
||||||
|
if len(xaddrs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use first HTTP address
|
||||||
|
var xaddr string
|
||||||
|
for _, addr := range xaddrs {
|
||||||
|
if strings.HasPrefix(addr, "http://") {
|
||||||
|
xaddr = addr
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if xaddr == "" {
|
||||||
|
xaddr = xaddrs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract serial/endpoint reference
|
||||||
|
serialRe := regexp.MustCompile(`<[^:]*:Address>urn:uuid:([^<]+)</[^:]*:Address>`)
|
||||||
|
serialMatches := serialRe.FindStringSubmatch(response)
|
||||||
|
serial := ""
|
||||||
|
if len(serialMatches) >= 2 {
|
||||||
|
serial = serialMatches[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DiscoveredCamera{
|
||||||
|
Serial: serial,
|
||||||
|
IP: ip,
|
||||||
|
Port: 80,
|
||||||
|
XAddr: xaddr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ONVIF Device Service
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// createSecurityHeader creates WS-Security header for authentication
|
||||||
|
func (c *ONVIFClient) createSecurityHeader(username, password string) string {
|
||||||
|
// Generate nonce and timestamp
|
||||||
|
nonce := make([]byte, 16)
|
||||||
|
for i := range nonce {
|
||||||
|
nonce[i] = byte(time.Now().UnixNano() >> (i * 8))
|
||||||
|
}
|
||||||
|
nonceBase64 := base64.StdEncoding.EncodeToString(nonce)
|
||||||
|
|
||||||
|
created := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||||
|
|
||||||
|
// Calculate password digest: Base64(SHA1(nonce + created + password))
|
||||||
|
h := sha1.New()
|
||||||
|
h.Write(nonce)
|
||||||
|
h.Write([]byte(created))
|
||||||
|
h.Write([]byte(password))
|
||||||
|
digest := base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||||
|
|
||||||
|
return fmt.Sprintf(`
|
||||||
|
<Security s:mustUnderstand="1" xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
|
||||||
|
<UsernameToken>
|
||||||
|
<Username>%s</Username>
|
||||||
|
<Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">%s</Password>
|
||||||
|
<Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">%s</Nonce>
|
||||||
|
<Created xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">%s</Created>
|
||||||
|
</UsernameToken>
|
||||||
|
</Security>`, username, digest, nonceBase64, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeviceInfo gets device information via ONVIF
|
||||||
|
func (c *ONVIFClient) GetDeviceInfo(deviceURL, username, password string) (map[string]string, error) {
|
||||||
|
soap := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
|
||||||
|
<s:Header>%s</s:Header>
|
||||||
|
<s:Body xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
||||||
|
<GetDeviceInformation xmlns="http://www.onvif.org/ver10/device/wsdl"/>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>`, c.createSecurityHeader(username, password))
|
||||||
|
|
||||||
|
c.log("GetDeviceInformation SOAP Request:\n%s", soap)
|
||||||
|
|
||||||
|
resp, err := c.sendSOAP(deviceURL, soap)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log("GetDeviceInformation SOAP Response:\n%s", resp)
|
||||||
|
|
||||||
|
// Parse response
|
||||||
|
info := make(map[string]string)
|
||||||
|
|
||||||
|
// Extract fields using regex (simple parsing)
|
||||||
|
fields := []string{"Manufacturer", "Model", "FirmwareVersion", "SerialNumber", "HardwareId"}
|
||||||
|
for _, field := range fields {
|
||||||
|
re := regexp.MustCompile(fmt.Sprintf(`<%s>([^<]*)</%s>`, field, field))
|
||||||
|
matches := re.FindStringSubmatch(resp)
|
||||||
|
if len(matches) >= 2 {
|
||||||
|
info[field] = matches[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServices gets available ONVIF services
|
||||||
|
func (c *ONVIFClient) GetServices(deviceURL, username, password string) (map[string]string, error) {
|
||||||
|
soap := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
|
||||||
|
<s:Header>%s</s:Header>
|
||||||
|
<s:Body>
|
||||||
|
<GetServices xmlns="http://www.onvif.org/ver10/device/wsdl">
|
||||||
|
<IncludeCapability>false</IncludeCapability>
|
||||||
|
</GetServices>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>`, c.createSecurityHeader(username, password))
|
||||||
|
|
||||||
|
c.log("GetServices SOAP Request:\n%s", soap)
|
||||||
|
|
||||||
|
resp, err := c.sendSOAP(deviceURL, soap)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log("GetServices SOAP Response:\n%s", resp)
|
||||||
|
|
||||||
|
// Parse services
|
||||||
|
services := make(map[string]string)
|
||||||
|
|
||||||
|
// Extract namespace and XAddr pairs
|
||||||
|
serviceRe := regexp.MustCompile(`<tds:Service>.*?<tds:Namespace>([^<]+)</tds:Namespace>.*?<tds:XAddr>([^<]+)</tds:XAddr>.*?</tds:Service>`)
|
||||||
|
matches := serviceRe.FindAllStringSubmatch(resp, -1)
|
||||||
|
for _, match := range matches {
|
||||||
|
if len(match) >= 3 {
|
||||||
|
services[match[1]] = match[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return services, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ONVIF Media Service - GetProfiles & GetStreamUri
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// GetProfiles gets media profiles from camera
|
||||||
|
func (c *ONVIFClient) GetProfiles(mediaURL, username, password string) ([]StreamProfile, error) {
|
||||||
|
soap := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
|
||||||
|
<s:Header>%s</s:Header>
|
||||||
|
<s:Body>
|
||||||
|
<GetProfiles xmlns="http://www.onvif.org/ver10/media/wsdl"/>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>`, c.createSecurityHeader(username, password))
|
||||||
|
|
||||||
|
c.log("GetProfiles SOAP Request:\n%s", soap)
|
||||||
|
|
||||||
|
resp, err := c.sendSOAP(mediaURL, soap)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log("GetProfiles SOAP Response:\n%s", resp)
|
||||||
|
|
||||||
|
// Parse profiles
|
||||||
|
var profiles []StreamProfile
|
||||||
|
|
||||||
|
// Extract profile tokens and names
|
||||||
|
profileRe := regexp.MustCompile(`<trt:Profiles[^>]*token="([^"]+)"[^>]*>.*?<tt:Name>([^<]*)</tt:Name>`)
|
||||||
|
matches := profileRe.FindAllStringSubmatch(resp, -1)
|
||||||
|
|
||||||
|
for _, match := range matches {
|
||||||
|
if len(match) >= 3 {
|
||||||
|
profiles = append(profiles, StreamProfile{
|
||||||
|
Token: match[1],
|
||||||
|
Name: match[2],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If regex didn't work, try alternative pattern
|
||||||
|
if len(profiles) == 0 {
|
||||||
|
altRe := regexp.MustCompile(`token="([^"]+)"`)
|
||||||
|
matches := altRe.FindAllStringSubmatch(resp, -1)
|
||||||
|
for i, match := range matches {
|
||||||
|
if len(match) >= 2 && i < 5 { // Limit to first 5
|
||||||
|
profiles = append(profiles, StreamProfile{
|
||||||
|
Token: match[1],
|
||||||
|
Name: fmt.Sprintf("Profile_%d", i+1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log("Found %d profiles", len(profiles))
|
||||||
|
return profiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStreamURI gets RTSP stream URI for a profile
|
||||||
|
func (c *ONVIFClient) GetStreamURI(mediaURL, username, password, profileToken string) (string, error) {
|
||||||
|
soap := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
|
||||||
|
<s:Header>%s</s:Header>
|
||||||
|
<s:Body>
|
||||||
|
<GetStreamUri xmlns="http://www.onvif.org/ver10/media/wsdl">
|
||||||
|
<StreamSetup>
|
||||||
|
<Stream xmlns="http://www.onvif.org/ver10/schema">RTP-Unicast</Stream>
|
||||||
|
<Transport xmlns="http://www.onvif.org/ver10/schema">
|
||||||
|
<Protocol>RTSP</Protocol>
|
||||||
|
</Transport>
|
||||||
|
</StreamSetup>
|
||||||
|
<ProfileToken>%s</ProfileToken>
|
||||||
|
</GetStreamUri>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>`, c.createSecurityHeader(username, password), profileToken)
|
||||||
|
|
||||||
|
c.log("GetStreamUri SOAP Request:\n%s", soap)
|
||||||
|
|
||||||
|
resp, err := c.sendSOAP(mediaURL, soap)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log("GetStreamUri SOAP Response:\n%s", resp)
|
||||||
|
|
||||||
|
// Extract URI
|
||||||
|
uriRe := regexp.MustCompile(`<tt:Uri>([^<]+)</tt:Uri>`)
|
||||||
|
matches := uriRe.FindStringSubmatch(resp)
|
||||||
|
if len(matches) < 2 {
|
||||||
|
// Try alternative pattern
|
||||||
|
uriRe = regexp.MustCompile(`<[^:]*Uri>([^<]+)</[^:]*Uri>`)
|
||||||
|
matches = uriRe.FindStringSubmatch(resp)
|
||||||
|
if len(matches) < 2 {
|
||||||
|
return "", fmt.Errorf("URI not found in response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uri := matches[1]
|
||||||
|
|
||||||
|
// Decode XML/HTML entities
|
||||||
|
uri = strings.ReplaceAll(uri, "&", "&")
|
||||||
|
uri = strings.ReplaceAll(uri, "<", "<")
|
||||||
|
uri = strings.ReplaceAll(uri, ">", ">")
|
||||||
|
uri = strings.ReplaceAll(uri, """, "\"")
|
||||||
|
uri = strings.ReplaceAll(uri, "'", "'")
|
||||||
|
|
||||||
|
// Add credentials to URI if not present
|
||||||
|
if !strings.Contains(uri, "@") && username != "" {
|
||||||
|
uri = strings.Replace(uri, "rtsp://", fmt.Sprintf("rtsp://%s:%s@", username, password), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log("Stream URI: %s", uri)
|
||||||
|
return uri, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// High-level helper: Get RTSP URL from camera
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// GetRTSPURL discovers camera and gets RTSP URL using credentials
|
||||||
|
func (c *ONVIFClient) GetRTSPURL(cameraIP string, port int, username, password string) (string, error) {
|
||||||
|
// Build device service URL
|
||||||
|
deviceURL := fmt.Sprintf("http://%s:%d/onvif/device_service", cameraIP, port)
|
||||||
|
|
||||||
|
c.log("Connecting to ONVIF device at %s", deviceURL)
|
||||||
|
|
||||||
|
// 1. Get device info (optional, for logging)
|
||||||
|
info, err := c.GetDeviceInfo(deviceURL, username, password)
|
||||||
|
if err != nil {
|
||||||
|
c.log("Warning: Could not get device info: %v", err)
|
||||||
|
} else {
|
||||||
|
c.log("Device: %s %s (Serial: %s)", info["Manufacturer"], info["Model"], info["SerialNumber"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try to get services to find media service URL
|
||||||
|
mediaURL := fmt.Sprintf("http://%s:%d/onvif/media", cameraIP, port)
|
||||||
|
services, err := c.GetServices(deviceURL, username, password)
|
||||||
|
if err == nil {
|
||||||
|
// Look for media service
|
||||||
|
for ns, url := range services {
|
||||||
|
if strings.Contains(ns, "media") {
|
||||||
|
mediaURL = url
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log("Media service URL: %s", mediaURL)
|
||||||
|
|
||||||
|
// 3. Get profiles
|
||||||
|
profiles, err := c.GetProfiles(mediaURL, username, password)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get profiles: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(profiles) == 0 {
|
||||||
|
return "", fmt.Errorf("no profiles found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use first profile (usually main stream)
|
||||||
|
profile := profiles[0]
|
||||||
|
c.log("Using profile: %s (%s)", profile.Token, profile.Name)
|
||||||
|
|
||||||
|
// 4. Get stream URI
|
||||||
|
streamURI, err := c.GetStreamURI(mediaURL, username, password, profile.Token)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get stream URI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamURI, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Helper methods
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// sendSOAP sends a SOAP request and returns the response
|
||||||
|
func (c *ONVIFClient) sendSOAP(url, body string) (string, error) {
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewBufferString(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/soap+xml; charset=utf-8")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we got 401 Unauthorized - need HTTP Digest Auth
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
// Try HTTP Digest authentication
|
||||||
|
authHeader := resp.Header.Get("WWW-Authenticate")
|
||||||
|
if strings.Contains(authHeader, "Digest") && c.username != "" {
|
||||||
|
c.log("Trying HTTP Digest authentication...")
|
||||||
|
return c.sendSOAPWithDigest(url, body, authHeader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return string(respBody), fmt.Errorf("SOAP request failed with status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(respBody), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendSOAPWithDigest sends a SOAP request with HTTP Digest authentication
|
||||||
|
func (c *ONVIFClient) sendSOAPWithDigest(url, body, authHeader string) (string, error) {
|
||||||
|
// Parse WWW-Authenticate header
|
||||||
|
realm := extractDigestParam(authHeader, "realm")
|
||||||
|
nonce := extractDigestParam(authHeader, "nonce")
|
||||||
|
qop := extractDigestParam(authHeader, "qop")
|
||||||
|
|
||||||
|
// Calculate digest response
|
||||||
|
ha1 := md5Hash(c.username + ":" + realm + ":" + c.password)
|
||||||
|
ha2 := md5Hash("POST:" + getURIPath(url))
|
||||||
|
|
||||||
|
nc := "00000001"
|
||||||
|
cnonce := fmt.Sprintf("%08x", time.Now().UnixNano())
|
||||||
|
|
||||||
|
var response string
|
||||||
|
if qop != "" {
|
||||||
|
response = md5Hash(ha1 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + ha2)
|
||||||
|
} else {
|
||||||
|
response = md5Hash(ha1 + ":" + nonce + ":" + ha2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build Authorization header
|
||||||
|
auth := fmt.Sprintf(`Digest username="%s", realm="%s", nonce="%s", uri="%s", response="%s"`,
|
||||||
|
c.username, realm, nonce, getURIPath(url), response)
|
||||||
|
if qop != "" {
|
||||||
|
auth += fmt.Sprintf(`, qop=%s, nc=%s, cnonce="%s"`, qop, nc, cnonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewBufferString(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/soap+xml; charset=utf-8")
|
||||||
|
req.Header.Set("Authorization", auth)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return string(respBody), fmt.Errorf("SOAP request failed with status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(respBody), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to extract param from Digest auth header
|
||||||
|
func extractDigestParam(header, param string) string {
|
||||||
|
re := regexp.MustCompile(param + `="?([^",]+)"?`)
|
||||||
|
matches := re.FindStringSubmatch(header)
|
||||||
|
if len(matches) >= 2 {
|
||||||
|
return matches[1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get URI path from full URL
|
||||||
|
func getURIPath(fullURL string) string {
|
||||||
|
re := regexp.MustCompile(`https?://[^/]+(.*)`)
|
||||||
|
matches := re.FindStringSubmatch(fullURL)
|
||||||
|
if len(matches) >= 2 {
|
||||||
|
return matches[1]
|
||||||
|
}
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MD5 hash helper
|
||||||
|
func md5Hash(data string) string {
|
||||||
|
h := md5.New()
|
||||||
|
h.Write([]byte(data))
|
||||||
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ONVIFClient) log(format string, args ...interface{}) {
|
||||||
|
if c.logger != nil {
|
||||||
|
c.logger("[ONVIF] "+format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SOAP Response Structures (for XML parsing if needed)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Envelope represents SOAP envelope
|
||||||
|
type Envelope struct {
|
||||||
|
XMLName xml.Name `xml:"Envelope"`
|
||||||
|
Body Body `xml:"Body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body represents SOAP body
|
||||||
|
type Body struct {
|
||||||
|
GetProfilesResponse *GetProfilesResponse `xml:"GetProfilesResponse"`
|
||||||
|
GetStreamUriResponse *GetStreamUriResponse `xml:"GetStreamUriResponse"`
|
||||||
|
GetDeviceInfoResponse *GetDeviceInfoResponse `xml:"GetDeviceInformationResponse"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProfilesResponse holds GetProfiles response
|
||||||
|
type GetProfilesResponse struct {
|
||||||
|
Profiles []Profile `xml:"Profiles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile represents a media profile
|
||||||
|
type Profile struct {
|
||||||
|
Token string `xml:"token,attr"`
|
||||||
|
Name string `xml:"Name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStreamUriResponse holds GetStreamUri response
|
||||||
|
type GetStreamUriResponse struct {
|
||||||
|
MediaUri MediaUri `xml:"MediaUri"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaUri holds stream URI info
|
||||||
|
type MediaUri struct {
|
||||||
|
Uri string `xml:"Uri"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeviceInfoResponse holds device info response
|
||||||
|
type GetDeviceInfoResponse struct {
|
||||||
|
Manufacturer string `xml:"Manufacturer"`
|
||||||
|
Model string `xml:"Model"`
|
||||||
|
FirmwareVersion string `xml:"FirmwareVersion"`
|
||||||
|
SerialNumber string `xml:"SerialNumber"`
|
||||||
|
HardwareId string `xml:"HardwareId"`
|
||||||
|
}
|
||||||
322
internal/adapter/types.go
Normal file
322
internal/adapter/types.go
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
package adapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SignalingType represents the type of signaling message
|
||||||
|
type SignalingType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SignalingTypeRequest SignalingType = "request"
|
||||||
|
SignalingTypeOffer SignalingType = "offer"
|
||||||
|
SignalingTypeAnswer SignalingType = "answer"
|
||||||
|
SignalingTypeDeny SignalingType = "deny"
|
||||||
|
SignalingTypeCCU SignalingType = "ccu"
|
||||||
|
SignalingTypeIceCandidate SignalingType = "ice-candidate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResultCode represents the result code in responses
|
||||||
|
type ResultCode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ResultSuccess ResultCode = 100
|
||||||
|
ResultFail ResultCode = 103
|
||||||
|
ResultStreamSuccess ResultCode = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result represents the result object in messages
|
||||||
|
type Result struct {
|
||||||
|
Ret ResultCode `json:"Ret"`
|
||||||
|
Message string `json:"Message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignalingMessage represents the base signaling message structure
|
||||||
|
type SignalingMessage struct {
|
||||||
|
Method string `json:"Method"`
|
||||||
|
MessageType string `json:"MessageType"`
|
||||||
|
Serial string `json:"Serial"`
|
||||||
|
Data map[string]interface{} `json:"Data"`
|
||||||
|
Timestamp int64 `json:"Timestamp"`
|
||||||
|
Result *Result `json:"Result,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignalingRequest represents a signaling request from client
|
||||||
|
type SignalingRequest struct {
|
||||||
|
Method string `json:"Method"`
|
||||||
|
MessageType string `json:"MessageType"`
|
||||||
|
Serial string `json:"Serial"`
|
||||||
|
Data RequestData `json:"Data"`
|
||||||
|
Timestamp int64 `json:"Timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestData represents the data in a signaling request
|
||||||
|
type RequestData struct {
|
||||||
|
Type SignalingType `json:"Type"`
|
||||||
|
ClientID string `json:"ClientId"`
|
||||||
|
SDP string `json:"Sdp,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignalingResponse represents a signaling response to client
|
||||||
|
type SignalingResponse struct {
|
||||||
|
Method string `json:"Method"`
|
||||||
|
MessageType string `json:"MessageType"`
|
||||||
|
Serial string `json:"Serial"`
|
||||||
|
Data ResponseData `json:"Data"`
|
||||||
|
Timestamp int64 `json:"Timestamp"`
|
||||||
|
Result Result `json:"Result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponseData represents the data in a signaling response
|
||||||
|
type ResponseData struct {
|
||||||
|
Type SignalingType `json:"Type"`
|
||||||
|
ClientID string `json:"ClientId,omitempty"`
|
||||||
|
SDP string `json:"Sdp,omitempty"`
|
||||||
|
IceServers []string `json:"IceServers,omitempty"`
|
||||||
|
ClientMax int `json:"ClientMax,omitempty"`
|
||||||
|
CurrentClientsTotal int `json:"CurrentClientsTotal,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DenyResponse represents a deny response when max clients reached
|
||||||
|
type DenyResponse struct {
|
||||||
|
Method string `json:"Method"`
|
||||||
|
MessageType string `json:"MessageType"`
|
||||||
|
Serial string `json:"Serial"`
|
||||||
|
Data DenyData `json:"Data"`
|
||||||
|
Timestamp int64 `json:"Timestamp"`
|
||||||
|
Result Result `json:"Result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DenyData represents data in a deny response
|
||||||
|
type DenyData struct {
|
||||||
|
Type SignalingType `json:"Type"`
|
||||||
|
ClientID string `json:"ClientId"`
|
||||||
|
ClientMax int `json:"ClientMax"`
|
||||||
|
CurrentClientsTotal int `json:"CurrentClientsTotal"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfferResponse represents an offer response from camera/adapter
|
||||||
|
type OfferResponse struct {
|
||||||
|
Method string `json:"Method"`
|
||||||
|
MessageType string `json:"MessageType"`
|
||||||
|
Serial string `json:"Serial"`
|
||||||
|
Data OfferData `json:"Data"`
|
||||||
|
Timestamp int64 `json:"Timestamp"`
|
||||||
|
Result Result `json:"Result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfferData represents data in an offer response
|
||||||
|
type OfferData struct {
|
||||||
|
Type SignalingType `json:"Type"`
|
||||||
|
ClientID string `json:"ClientId"`
|
||||||
|
SDP string `json:"Sdp"`
|
||||||
|
IceServers []string `json:"IceServers,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnswerRequest represents an answer from client
|
||||||
|
type AnswerRequest struct {
|
||||||
|
Method string `json:"Method"`
|
||||||
|
MessageType string `json:"MessageType"`
|
||||||
|
Serial string `json:"Serial"`
|
||||||
|
Data AnswerData `json:"Data"`
|
||||||
|
Timestamp int64 `json:"Timestamp"`
|
||||||
|
Result *Result `json:"Result,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnswerData represents data in an answer request
|
||||||
|
type AnswerData struct {
|
||||||
|
Type SignalingType `json:"Type"`
|
||||||
|
ClientID string `json:"ClientId"`
|
||||||
|
SDP string `json:"Sdp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CCUResponse represents a CCU (Concurrent Client Update) response
|
||||||
|
type CCUResponse struct {
|
||||||
|
Method string `json:"Method"`
|
||||||
|
MessageType string `json:"MessageType"`
|
||||||
|
Serial string `json:"Serial"`
|
||||||
|
Data CCUData `json:"Data"`
|
||||||
|
Timestamp int64 `json:"Timestamp"`
|
||||||
|
Result Result `json:"Result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CCUData represents data in a CCU response
|
||||||
|
type CCUData struct {
|
||||||
|
Type SignalingType `json:"Type"`
|
||||||
|
CurrentClientsTotal int `json:"CurrentClientsTotal"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IceCandidateMessage represents an ICE candidate exchange message
|
||||||
|
type IceCandidateMessage struct {
|
||||||
|
Method string `json:"Method"`
|
||||||
|
MessageType string `json:"MessageType"`
|
||||||
|
Serial string `json:"Serial"`
|
||||||
|
Data IceCandidateData `json:"Data"`
|
||||||
|
Timestamp int64 `json:"Timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IceCandidateData represents data in an ICE candidate message
|
||||||
|
type IceCandidateData struct {
|
||||||
|
Type SignalingType `json:"Type"`
|
||||||
|
ClientID string `json:"ClientId"`
|
||||||
|
Candidate IceCandidate `json:"Candidate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IceCandidate represents a single ICE candidate
|
||||||
|
type IceCandidate struct {
|
||||||
|
Candidate string `json:"candidate"`
|
||||||
|
SDPMid string `json:"sdpMid"`
|
||||||
|
SDPMLineIndex int `json:"sdpMLineIndex"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CredentialMessage represents camera credentials from client
|
||||||
|
type CredentialMessage struct {
|
||||||
|
Method string `json:"Method"`
|
||||||
|
MessageType string `json:"MessageType"`
|
||||||
|
Serial string `json:"Serial"`
|
||||||
|
Data CredentialData `json:"Data"`
|
||||||
|
Timestamp int64 `json:"Timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CredentialData represents camera credentials
|
||||||
|
type CredentialData struct {
|
||||||
|
Username string `json:"Username"`
|
||||||
|
Password string `json:"Password"`
|
||||||
|
IP string `json:"IP,omitempty"` // Camera IP address (optional, can be discovered via ONVIF)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataChannelCommand represents commands sent via data channel
|
||||||
|
type DataChannelCommand string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DataChannelCommandStream DataChannelCommand = "Stream"
|
||||||
|
DataChannelCommandOnvifStatus DataChannelCommand = "OnvifStatus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DataChannelRequest represents a request via data channel
|
||||||
|
type DataChannelRequest struct {
|
||||||
|
ID string `json:"Id"`
|
||||||
|
Command DataChannelCommand `json:"Command"`
|
||||||
|
Type string `json:"Type"`
|
||||||
|
Content interface{} `json:"Content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamContent represents content in a stream request
|
||||||
|
type StreamContent struct {
|
||||||
|
ChannelMask int64 `json:"ChannelMask"`
|
||||||
|
ResolutionMask int64 `json:"ResolutionMask"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataChannelResponse represents a response via data channel
|
||||||
|
type DataChannelResponse struct {
|
||||||
|
ID string `json:"Id"`
|
||||||
|
Command DataChannelCommand `json:"Command"`
|
||||||
|
Type string `json:"Type"`
|
||||||
|
Content interface{} `json:"Content"`
|
||||||
|
Result Result `json:"Result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnvifStatus represents the status of an ONVIF camera channel
|
||||||
|
type OnvifStatus struct {
|
||||||
|
StreamID int `json:"Stream id"`
|
||||||
|
IP string `json:"IP"`
|
||||||
|
User string `json:"User"`
|
||||||
|
Pass string `json:"Pass"`
|
||||||
|
FullHD string `json:"FullHD"`
|
||||||
|
HD string `json:"HD"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnvifStatusContent represents the content of an ONVIF status response
|
||||||
|
type OnvifStatusContent struct {
|
||||||
|
OnvifStatus []OnvifStatus `json:"OnvifStatus"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSignalingRequest creates a new signaling request
|
||||||
|
func NewSignalingRequest(serial, clientID string, sigType SignalingType) *SignalingRequest {
|
||||||
|
return &SignalingRequest{
|
||||||
|
Method: "ACT",
|
||||||
|
MessageType: "Signaling",
|
||||||
|
Serial: serial,
|
||||||
|
Data: RequestData{
|
||||||
|
Type: sigType,
|
||||||
|
ClientID: clientID,
|
||||||
|
},
|
||||||
|
Timestamp: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOfferResponse creates a new offer response
|
||||||
|
func NewOfferResponse(serial, clientID, sdp string, iceServers []string) *OfferResponse {
|
||||||
|
return &OfferResponse{
|
||||||
|
Method: "ACT",
|
||||||
|
MessageType: "Signaling",
|
||||||
|
Serial: serial,
|
||||||
|
Data: OfferData{
|
||||||
|
Type: SignalingTypeOffer,
|
||||||
|
ClientID: clientID,
|
||||||
|
SDP: sdp,
|
||||||
|
IceServers: iceServers,
|
||||||
|
},
|
||||||
|
Timestamp: time.Now().Unix(),
|
||||||
|
Result: Result{
|
||||||
|
Ret: ResultSuccess,
|
||||||
|
Message: "Success",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAnswerResponse creates a new answer acknowledgment response
|
||||||
|
func NewAnswerResponse(serial, clientID string) *SignalingResponse {
|
||||||
|
return &SignalingResponse{
|
||||||
|
Method: "ACT",
|
||||||
|
MessageType: "Signaling",
|
||||||
|
Serial: serial,
|
||||||
|
Data: ResponseData{
|
||||||
|
Type: SignalingTypeAnswer,
|
||||||
|
ClientID: clientID,
|
||||||
|
},
|
||||||
|
Timestamp: time.Now().Unix(),
|
||||||
|
Result: Result{
|
||||||
|
Ret: ResultSuccess,
|
||||||
|
Message: "Success",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDenyResponse creates a new deny response
|
||||||
|
func NewDenyResponse(serial, clientID string, maxClients, currentClients int) *DenyResponse {
|
||||||
|
return &DenyResponse{
|
||||||
|
Method: "ACT",
|
||||||
|
MessageType: "Signaling",
|
||||||
|
Serial: serial,
|
||||||
|
Data: DenyData{
|
||||||
|
Type: SignalingTypeDeny,
|
||||||
|
ClientID: clientID,
|
||||||
|
ClientMax: maxClients,
|
||||||
|
CurrentClientsTotal: currentClients,
|
||||||
|
},
|
||||||
|
Timestamp: time.Now().Unix(),
|
||||||
|
Result: Result{
|
||||||
|
Ret: ResultFail,
|
||||||
|
Message: "Fail",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCCUResponse creates a new CCU response
|
||||||
|
func NewCCUResponse(serial string, currentClients int) *CCUResponse {
|
||||||
|
return &CCUResponse{
|
||||||
|
Method: "ACT",
|
||||||
|
MessageType: "Signaling",
|
||||||
|
Serial: serial,
|
||||||
|
Data: CCUData{
|
||||||
|
Type: SignalingTypeCCU,
|
||||||
|
CurrentClientsTotal: currentClients,
|
||||||
|
},
|
||||||
|
Timestamp: time.Now().Unix(),
|
||||||
|
Result: Result{
|
||||||
|
Ret: ResultSuccess,
|
||||||
|
Message: "Success",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
332
internal/adapter/whep.go
Normal file
332
internal/adapter/whep.go
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
package adapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WHEPConfig holds WHEP client configuration
|
||||||
|
type WHEPConfig struct {
|
||||||
|
BaseURL string
|
||||||
|
Timeout time.Duration
|
||||||
|
ICEServers []string
|
||||||
|
TURNServer string
|
||||||
|
TURNUsername string
|
||||||
|
TURNPassword string
|
||||||
|
STUNServers []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultWHEPConfig returns default WHEP configuration
|
||||||
|
func DefaultWHEPConfig() WHEPConfig {
|
||||||
|
return WHEPConfig{
|
||||||
|
BaseURL: "http://localhost:8889",
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
ICEServers: []string{},
|
||||||
|
STUNServers: []string{"stun:stun-connect.fcam.vn:3478", "stun:stunp-connect.fcam.vn:3478"},
|
||||||
|
TURNServer: "turn:turn-connect.fcam.vn:3478",
|
||||||
|
TURNUsername: "turnuser",
|
||||||
|
TURNPassword: "camfptvnturn133099",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WHEPClient handles communication with MediaMTX WHEP endpoints
|
||||||
|
type WHEPClient struct {
|
||||||
|
config WHEPConfig
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWHEPClient creates a new WHEP client
|
||||||
|
func NewWHEPClient(config WHEPConfig) *WHEPClient {
|
||||||
|
return &WHEPClient{
|
||||||
|
config: config,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: config.Timeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WHEPSession represents an active WHEP session
|
||||||
|
type WHEPSession struct {
|
||||||
|
SessionURL string
|
||||||
|
AnswerSDP string
|
||||||
|
ETag string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetICEServers returns the configured ICE servers as a formatted list
|
||||||
|
func (wc *WHEPClient) GetICEServers() []string {
|
||||||
|
servers := make([]string, 0)
|
||||||
|
|
||||||
|
// Add STUN servers
|
||||||
|
for _, stun := range wc.config.STUNServers {
|
||||||
|
// Extract just the host for the FPT format
|
||||||
|
host := strings.TrimPrefix(stun, "stun:")
|
||||||
|
servers = append(servers, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add TURN server
|
||||||
|
if wc.config.TURNServer != "" {
|
||||||
|
host := strings.TrimPrefix(wc.config.TURNServer, "turn:")
|
||||||
|
servers = append(servers, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
return servers
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOffer sends a WHEP request to get an SDP offer from MediaMTX
|
||||||
|
// This is used when we need MediaMTX to generate the offer (server-initiated)
|
||||||
|
func (wc *WHEPClient) CreateOffer(streamPath string) (*WHEPSession, error) {
|
||||||
|
url := fmt.Sprintf("%s/%s/whep", wc.config.BaseURL, streamPath)
|
||||||
|
|
||||||
|
// For WHEP, we send an empty body or minimal SDP to get server offer
|
||||||
|
// However, standard WHEP expects client to send offer
|
||||||
|
// MediaMTX might have specific handling for this
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/sdp")
|
||||||
|
|
||||||
|
resp, err := wc.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("WHEP request failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
answerSDP, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session := &WHEPSession{
|
||||||
|
SessionURL: resp.Header.Get("Location"),
|
||||||
|
AnswerSDP: string(answerSDP),
|
||||||
|
ETag: resp.Header.Get("ETag"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendOffer sends a client's SDP offer to MediaMTX and gets the answer
|
||||||
|
// This follows standard WHEP flow where client sends offer
|
||||||
|
func (wc *WHEPClient) SendOffer(streamPath string, offerSDP string) (*WHEPSession, error) {
|
||||||
|
url := fmt.Sprintf("%s/%s/whep", wc.config.BaseURL, streamPath)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", url, strings.NewReader(offerSDP))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/sdp")
|
||||||
|
req.Header.Set("Accept", "application/sdp")
|
||||||
|
|
||||||
|
resp, err := wc.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("WHEP request failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
answerSDP, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session := &WHEPSession{
|
||||||
|
SessionURL: resp.Header.Get("Location"),
|
||||||
|
AnswerSDP: string(answerSDP),
|
||||||
|
ETag: resp.Header.Get("ETag"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendAnswer sends the client's answer to complete the WHEP handshake
|
||||||
|
// Used in cases where server sends offer first
|
||||||
|
func (wc *WHEPClient) SendAnswer(sessionURL string, answerSDP string) error {
|
||||||
|
req, err := http.NewRequest("PATCH", sessionURL, strings.NewReader(answerSDP))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/sdp")
|
||||||
|
|
||||||
|
resp, err := wc.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("WHEP PATCH failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddICECandidate adds an ICE candidate to the session via WHEP
|
||||||
|
func (wc *WHEPClient) AddICECandidate(sessionURL string, candidate IceCandidate, eTag string) error {
|
||||||
|
// Format ICE candidate for WHEP
|
||||||
|
// MediaMTX expects specific format
|
||||||
|
candidateJSON, err := json.Marshal(candidate)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal candidate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("PATCH", sessionURL, bytes.NewReader(candidateJSON))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/trickle-ice-sdpfrag")
|
||||||
|
if eTag != "" {
|
||||||
|
req.Header.Set("If-Match", eTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := wc.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("ICE candidate failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSession deletes a WHEP session
|
||||||
|
func (wc *WHEPClient) DeleteSession(sessionURL string) error {
|
||||||
|
req, err := http.NewRequest("DELETE", sessionURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := wc.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("DELETE failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStreamInfo gets information about a stream from MediaMTX API
|
||||||
|
func (wc *WHEPClient) GetStreamInfo(streamPath string) (map[string]interface{}, error) {
|
||||||
|
url := fmt.Sprintf("%s/v3/paths/get/%s", strings.TrimSuffix(wc.config.BaseURL, "/whep"), streamPath)
|
||||||
|
|
||||||
|
resp, err := wc.httpClient.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get stream info: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("stream not found or not ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
var info map[string]interface{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckStreamReady checks if a stream is ready for playback
|
||||||
|
func (wc *WHEPClient) CheckStreamReady(streamPath string) bool {
|
||||||
|
info, err := wc.GetStreamInfo(streamPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if stream has readers or is ready
|
||||||
|
if ready, ok := info["ready"].(bool); ok {
|
||||||
|
return ready
|
||||||
|
}
|
||||||
|
|
||||||
|
return true // Assume ready if we got info
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddPath creates a new path configuration in MediaMTX with RTSP source
|
||||||
|
func (wc *WHEPClient) AddPath(streamPath, rtspURL string) error {
|
||||||
|
// Use API port 9997 (default MediaMTX API)
|
||||||
|
apiBase := strings.Replace(wc.config.BaseURL, ":8889", ":9997", 1)
|
||||||
|
url := fmt.Sprintf("%s/v3/config/paths/add/%s", apiBase, streamPath)
|
||||||
|
|
||||||
|
pathConf := map[string]interface{}{
|
||||||
|
"source": rtspURL,
|
||||||
|
"sourceOnDemand": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(pathConf)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal path config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewReader(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := wc.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("add path failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePath removes a path configuration from MediaMTX
|
||||||
|
func (wc *WHEPClient) DeletePath(streamPath string) error {
|
||||||
|
apiBase := strings.Replace(wc.config.BaseURL, ":8889", ":9997", 1)
|
||||||
|
url := fmt.Sprintf("%s/v3/config/paths/delete/%s", apiBase, streamPath)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("DELETE", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := wc.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("delete path failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -146,7 +146,7 @@ authJWTInHTTPQuery: true
|
||||||
# Global settings -> Control API
|
# Global settings -> Control API
|
||||||
|
|
||||||
# Enable controlling the server through the Control API.
|
# Enable controlling the server through the Control API.
|
||||||
api: no
|
api: yes
|
||||||
# Address of the Control API listener.
|
# Address of the Control API listener.
|
||||||
apiAddress: :9997
|
apiAddress: :9997
|
||||||
# Enable TLS/HTTPS on the Control API server.
|
# Enable TLS/HTTPS on the Control API server.
|
||||||
|
|
@ -752,6 +752,12 @@ pathDefaults:
|
||||||
# for example "~^(test1|test2)$" will match both "test1" and "test2",
|
# for example "~^(test1|test2)$" will match both "test1" and "test2",
|
||||||
# for example "~^prefix" will match all paths that start with "prefix".
|
# for example "~^prefix" will match all paths that start with "prefix".
|
||||||
paths:
|
paths:
|
||||||
|
# FPT Camera dynamic paths - adapter sẽ tạo RTSP source khi nhận credentials
|
||||||
|
~^fpt/:
|
||||||
|
# sourceOnDemand: yes
|
||||||
|
sourceOnDemandStartTimeout: 30s
|
||||||
|
sourceOnDemandCloseAfter: 60s
|
||||||
|
|
||||||
# example:
|
# example:
|
||||||
# my_camera:
|
# my_camera:
|
||||||
# source: rtsp://my_camera
|
# source: rtsp://my_camera
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue