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/mediacommon/v2 v2.5.2-0.20251201152746-8d059e8616fb
|
||||
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/gin-contrib/pprof v1.5.3
|
||||
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
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
|
||||
|
||||
# Enable controlling the server through the Control API.
|
||||
api: no
|
||||
api: yes
|
||||
# Address of the Control API listener.
|
||||
apiAddress: :9997
|
||||
# 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 "~^prefix" will match all paths that start with "prefix".
|
||||
paths:
|
||||
# FPT Camera dynamic paths - adapter sẽ tạo RTSP source khi nhận credentials
|
||||
~^fpt/:
|
||||
# sourceOnDemand: yes
|
||||
sourceOnDemandStartTimeout: 30s
|
||||
sourceOnDemandCloseAfter: 60s
|
||||
|
||||
# example:
|
||||
# my_camera:
|
||||
# source: rtsp://my_camera
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue