feat(adapter): add adapter and api server; add client UI

This commit is contained in:
HoangBD 2025-12-04 11:10:13 +07:00
parent b8e8d8edab
commit ae9cde4400
23 changed files with 6790 additions and 1 deletions

1
VERSION Normal file
View file

@ -0,0 +1 @@
v0.0.0-local

97
adapter.mk Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

View 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
View 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
View 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"
}
}

View 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));
});

View 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
View 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
View file

@ -15,6 +15,7 @@ require (
github.com/bluenviron/gortsplib/v5 v5.2.0 github.com/bluenviron/gortsplib/v5 v5.2.0
github.com/bluenviron/mediacommon/v2 v2.5.2-0.20251201152746-8d059e8616fb github.com/bluenviron/mediacommon/v2 v2.5.2-0.20251201152746-8d059e8616fb
github.com/datarhei/gosrt v0.9.0 github.com/datarhei/gosrt v0.9.0
github.com/eclipse/paho.mqtt.golang v1.4.3
github.com/fsnotify/fsnotify v1.9.0 github.com/fsnotify/fsnotify v1.9.0
github.com/gin-contrib/pprof v1.5.3 github.com/gin-contrib/pprof v1.5.3
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0

2
go.sum
View file

@ -57,6 +57,8 @@ github.com/datarhei/gosrt v0.9.0/go.mod h1:rqTRK8sDZdN2YBgp1EEICSV4297mQk0oglwvp
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=

196
internal/adapter/README.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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, "&amp;", "&")
uri = strings.ReplaceAll(uri, "&lt;", "<")
uri = strings.ReplaceAll(uri, "&gt;", ">")
uri = strings.ReplaceAll(uri, "&quot;", "\"")
uri = strings.ReplaceAll(uri, "&apos;", "'")
// 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
View 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
View 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
}

View file

@ -146,7 +146,7 @@ authJWTInHTTPQuery: true
# Global settings -> Control API # Global settings -> Control API
# Enable controlling the server through the Control API. # Enable controlling the server through the Control API.
api: no api: yes
# Address of the Control API listener. # Address of the Control API listener.
apiAddress: :9997 apiAddress: :9997
# Enable TLS/HTTPS on the Control API server. # Enable TLS/HTTPS on the Control API server.
@ -752,6 +752,12 @@ pathDefaults:
# for example "~^(test1|test2)$" will match both "test1" and "test2", # for example "~^(test1|test2)$" will match both "test1" and "test2",
# for example "~^prefix" will match all paths that start with "prefix". # for example "~^prefix" will match all paths that start with "prefix".
paths: paths:
# FPT Camera dynamic paths - adapter sẽ tạo RTSP source khi nhận credentials
~^fpt/:
# sourceOnDemand: yes
sourceOnDemandStartTimeout: 30s
sourceOnDemandCloseAfter: 60s
# example: # example:
# my_camera: # my_camera:
# source: rtsp://my_camera # source: rtsp://my_camera