Files
hamview/ui/src/services/Stream.ts
2026-03-08 22:22:51 +01:00

318 lines
9.4 KiB
TypeScript

import mqtt from "mqtt";
import type { StreamConnectionOptions, StreamState, TopicSubscription } from "../types/stream.types";
const defaultConnectionOptions: StreamConnectionOptions = {
url: import.meta.env.DEV ?
'wss://pd0mz.hamnet.nl/broker' :
((window.location.protocol === 'http:') ? 'ws:' : 'wss:') + '//' + window.location.host + '/broker'
}
export abstract class BaseStream {
protected client: mqtt.MqttClient | null = null;
protected connectionOptions: StreamConnectionOptions;
protected subscribers: Map<string, Set<(data: any, topic: string) => void>> = new Map();
protected stateSubscribers: Set<(state: StreamState) => void> = new Set();
protected reconnectTimer: ReturnType<typeof setTimeout> | null = null;
protected autoConnect: boolean;
protected state: StreamState = {
isConnected: false,
isConnecting: false,
error: null,
subscriptions: new Map(),
lastMessages: new Map(),
};
constructor(connectionOptions: Partial<StreamConnectionOptions>, autoConnect: boolean = true) {
this.connectionOptions = {
...defaultConnectionOptions,
...connectionOptions,
}
this.autoConnect = autoConnect;
if (autoConnect) {
this.connect();
}
}
protected abstract decodeMessage(topic: string, payload: Uint8Array): any;
protected validateMessage?(topic: string, data: any): boolean;
public connect(): void {
if (this.client?.connected || this.state.isConnecting) {
return;
}
this.updateState({ isConnecting: true, error: null });
try {
const randomId = Math.random().toString(16).slice(2, 10);
const prefix = import.meta.env.DEV ? 'dev_' : '';
const defaultClientId = `${prefix}web_${randomId}`;
console.log(`Connecting to MQTT broker at ${this.connectionOptions.url} with clientId ${defaultClientId}`);
this.client = mqtt.connect(this.connectionOptions.url, {
...this.connectionOptions.options,
clientId: this.connectionOptions.options?.clientId || defaultClientId,
});
this.setupEventHandlers();
} catch (error) {
this.handleError(error);
}
}
public disconnect(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.client) {
this.client.end(true, () => {
this.updateState({
isConnected: false,
isConnecting: false,
subscriptions: new Map(),
lastMessages: new Map(),
});
});
this.client = null;
}
}
public subscribe<T = any>(
topic: string,
callback: (data: T, topic: string) => void,
qos: 0 | 1 | 2 = 0
): () => void {
// Add to subscribers map
if (!this.subscribers.has(topic)) {
this.subscribers.set(topic, new Set());
}
const topicSubscribers = this.subscribers.get(topic)!;
topicSubscribers.add(callback);
// Update state subscriptions
const subscription: TopicSubscription<T> = { topic, qos, dataType: undefined as any };
this.state.subscriptions.set(topic, subscription);
// If connected, subscribe to the topic on the broker
if (this.client?.connected) {
this.client.subscribe(topic, { qos }, (error) => {
if (error) {
console.error(`Failed to subscribe to ${topic}:`, error);
}
});
}
// Return unsubscribe function
return () => {
const topicSubscribers = this.subscribers.get(topic);
if (topicSubscribers) {
topicSubscribers.delete(callback);
// If no more subscribers for this topic, unsubscribe from broker
if (topicSubscribers.size === 0) {
this.subscribers.delete(topic);
this.state.subscriptions.delete(topic);
this.state.lastMessages.delete(topic);
if (this.client?.connected) {
this.client.unsubscribe(topic);
}
}
}
};
}
public subscribeMany(
subscriptions: Array<{ topic: string; qos?: 0 | 1 | 2 }>,
callback: (data: any, topic: string) => void
): () => void {
const unsubscribers = subscriptions.map(({ topic, qos = 0 }) =>
this.subscribe(topic, callback, qos)
);
return () => {
unsubscribers.forEach(unsub => unsub());
};
}
public subscribeToState(callback: (state: StreamState) => void): () => void {
this.stateSubscribers.add(callback);
callback(this.state); // Immediately send current state
return () => {
this.stateSubscribers.delete(callback);
};
}
public getLastMessage<T = any>(topic: string): T | undefined {
return this.state.lastMessages.get(topic) as T | undefined;
}
public getSubscriptions(): Map<string, TopicSubscription> {
return new Map(this.state.subscriptions);
}
public getState(): StreamState {
return {
...this.state,
subscriptions: new Map(this.state.subscriptions),
lastMessages: new Map(this.state.lastMessages),
};
}
public isSubscribed(topic: string): boolean {
return this.state.subscriptions.has(topic);
}
public destroy(): void {
this.disconnect();
this.subscribers.clear();
this.stateSubscribers.clear();
}
private setupEventHandlers(): void {
if (!this.client) return;
this.client.on('connect', () => {
this.updateState({ isConnected: true, isConnecting: false, error: null });
// Resubscribe to all topics after reconnection
const subscriptions = Array.from(this.state.subscriptions.entries());
if (subscriptions.length > 0 && this.client) {
const subscribePromises = subscriptions.map(([topic, sub]) => {
return new Promise<void>((resolve, reject) => {
this.client?.subscribe(topic, { qos: sub.qos || 0 }, (error) => {
if (error) reject(error);
else resolve();
});
});
});
Promise.all(subscribePromises).catch(error => {
console.error('Failed to resubscribe to topics:', error);
});
}
});
this.client.on('message', (topic, payload) => {
try {
const uint8Array = new Uint8Array(payload);
const data = this.decodeMessage(topic, uint8Array);
// Validate if validator is provided
if (this.validateMessage && !this.validateMessage(topic, data)) {
console.warn('Invalid message received on topic:', topic, data);
return;
}
// Update last message for this topic
this.state.lastMessages.set(topic, data);
this.updateState({ lastMessages: new Map(this.state.lastMessages) });
// Notify subscribers for this topic
this.notifySubscribers(topic, data);
} catch (error) {
console.error(`Error processing message on topic ${topic}:`, error);
}
});
this.client.on('error', (error) => {
this.handleError(error);
});
this.client.on('close', () => {
this.updateState({ isConnected: false });
this.attemptReconnect();
});
}
private notifySubscribers(topic: string, data: any): void {
const topicSubscribers = this.subscribers.get(topic);
if (topicSubscribers) {
topicSubscribers.forEach(callback => {
try {
callback(data, topic);
} catch (error) {
console.error('Error in subscriber callback:', error);
}
});
}
// Also notify wildcard subscribers (those subscribed to patterns like 'sensors/+')
this.subscribers.forEach((subscribers, subscribedTopic) => {
if (subscribedTopic !== topic && this.topicMatches(subscribedTopic, topic)) {
subscribers.forEach(callback => {
try {
callback(data, topic);
} catch (error) {
console.error('Error in wildcard subscriber callback:', error);
}
});
}
});
}
private topicMatches(subscription: string, topic: string): boolean {
const subParts = subscription.split('/');
const topicParts = topic.split('/');
// Handle multi-level wildcard
if (subscription.includes('#')) {
const wildcardIndex = subParts.indexOf('#');
// Check if all parts before the wildcard match
for (let i = 0; i < wildcardIndex; i++) {
if (subParts[i] !== '+' && subParts[i] !== topicParts[i]) {
return false;
}
}
return true;
}
// Handle single-level wildcards and exact matches
if (subParts.length !== topicParts.length) return false;
for (let i = 0; i < subParts.length; i++) {
if (subParts[i] !== '+' && subParts[i] !== topicParts[i]) {
return false;
}
}
return true;
}
private updateState(updates: Partial<StreamState>) {
this.state = { ...this.state, ...updates };
this.stateSubscribers.forEach(callback => {
try {
callback(this.state);
} catch (error) {
console.error('Error in state subscriber callback:', error);
}
})
}
private handleError(error: any): void {
const errorObj = error instanceof Error ? error : new Error(String(error));
this.updateState({ error: errorObj, isConnecting: false });
console.error('Stream error:', errorObj);
}
private attemptReconnect(): void {
if (!this.autoConnect || this.reconnectTimer) return;
const reconnectPeriod = this.connectionOptions.options?.reconnectPeriod || 5000;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (!this.state.isConnected && !this.state.isConnecting) {
this.connect();
}
}, reconnectPeriod);
}
}