WebSocket Tunnel
Real-time bidirectional communication between Flutter apps and AsaHome OS devices.
Overview
The WebSocket tunnel enables instant communication between mobile apps and home automation devices, even when users are outside their local network.
Connection
Endpoint
wss://cloud.asahome.io/tunnel
Authentication
Include your JWT access token in the connection handshake:
import { io } from 'socket.io-client';
const socket = io('wss://cloud.asahome.io/tunnel', {
transports: ['websocket'],
auth: {
token: 'your-jwt-access-token'
}
});
Connection Types
The gateway distinguishes between two connection types based on the JWT payload:
| Type | JWT Contains | Purpose |
|---|---|---|
| User Connection | sub (user ID) only | Flutter app controlling devices |
| Device Connection | deviceUuid claim | AsaHome OS maintaining tunnel |
Events
From Flutter App to Device
tunnel:message
Send a command to a specific device.
socket.emit('tunnel:message', {
deviceUuid: 'device-uuid-here',
message: {
type: 'call_service',
domain: 'light',
service: 'turn_on',
target: {
entity_id: 'light.living_room'
},
data: {
brightness_pct: 80
}
}
});
Message Structure
| Field | Type | Required | Description |
|---|---|---|---|
deviceUuid | string | Yes | Target device UUID |
message | object | Yes | Command payload |
message.type | string | Yes | Command type |
message.domain | string | Depends | Service domain |
message.service | string | Depends | Service to call |
message.target | object | Depends | Target entities |
message.data | object | No | Additional data |
From Device to Flutter App
tunnel:response
Device sends response back to the user.
// Device-side code
socket.emit('tunnel:response', {
userId: 'user-uuid-here',
requestId: 'original-request-id',
message: {
success: true,
result: {
state: 'on',
brightness: 80
}
}
});
Listening for Events
Flutter App
// Receive responses from devices
socket.on('tunnel:response', (data) => {
console.log('Device response:', data.message);
// Handle the response
});
// Receive real-time state updates
socket.on('tunnel:state', (data) => {
console.log('State update:', data);
// Update UI with new state
});
// Handle errors
socket.on('tunnel:error', (error) => {
console.error('Tunnel error:', error.message);
});
AsaHome OS Device
// Receive commands from users
socket.on('tunnel:message', (data) => {
console.log('Command from user:', data.userId);
console.log('Command:', data.message);
// Execute command and send response
executeCommand(data.message)
.then(result => {
socket.emit('tunnel:response', {
userId: data.userId,
requestId: data.requestId,
message: { success: true, result }
});
})
.catch(error => {
socket.emit('tunnel:response', {
userId: data.userId,
requestId: data.requestId,
message: { success: false, error: error.message }
});
});
});
Flutter Implementation
Complete Example
import 'package:socket_io_client/socket_io_client.dart' as IO;
class TunnelService {
late IO.Socket socket;
final String accessToken;
final void Function(Map<String, dynamic>) onResponse;
final void Function(Map<String, dynamic>) onStateUpdate;
TunnelService({
required this.accessToken,
required this.onResponse,
required this.onStateUpdate,
});
void connect() {
socket = IO.io(
'wss://cloud.asahome.io/tunnel',
IO.OptionBuilder()
.setTransports(['websocket'])
.setAuth({'token': accessToken})
.build(),
);
socket.onConnect((_) {
print('Connected to tunnel');
});
socket.onDisconnect((_) {
print('Disconnected from tunnel');
});
socket.onConnectError((error) {
print('Connection error: $error');
});
socket.on('tunnel:response', (data) {
onResponse(data as Map<String, dynamic>);
});
socket.on('tunnel:state', (data) {
onStateUpdate(data as Map<String, dynamic>);
});
socket.connect();
}
void sendCommand(String deviceUuid, Map<String, dynamic> command) {
socket.emit('tunnel:message', {
'deviceUuid': deviceUuid,
'message': command,
});
}
void turnOnLight(String deviceUuid, String entityId, {int? brightness}) {
sendCommand(deviceUuid, {
'type': 'call_service',
'domain': 'light',
'service': 'turn_on',
'target': {'entity_id': entityId},
'data': brightness != null ? {'brightness_pct': brightness} : {},
});
}
void turnOffLight(String deviceUuid, String entityId) {
sendCommand(deviceUuid, {
'type': 'call_service',
'domain': 'light',
'service': 'turn_off',
'target': {'entity_id': entityId},
});
}
void disconnect() {
socket.disconnect();
socket.dispose();
}
}
Usage in Widget
class HomeControlPage extends StatefulWidget {
_HomeControlPageState createState() => _HomeControlPageState();
}
class _HomeControlPageState extends State<HomeControlPage> {
late TunnelService tunnel;
Map<String, dynamic> deviceState = {};
void initState() {
super.initState();
initTunnel();
}
void initTunnel() async {
final token = await AuthStorage().getAccessToken();
tunnel = TunnelService(
accessToken: token!,
onResponse: (data) {
print('Received response: $data');
// Handle command response
},
onStateUpdate: (data) {
setState(() {
deviceState = data;
});
},
);
tunnel.connect();
}
void dispose() {
tunnel.disconnect();
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
SwitchListTile(
title: Text('Living Room Light'),
value: deviceState['light.living_room']?['state'] == 'on',
onChanged: (value) {
if (value) {
tunnel.turnOnLight('device-uuid', 'light.living_room');
} else {
tunnel.turnOffLight('device-uuid', 'light.living_room');
}
},
),
],
),
);
}
}
Command Types
Supported Commands
| Type | Description | Example |
|---|---|---|
call_service | Call a device service | Turn on light |
get_states | Get current entity states | Fetch all states |
get_state | Get single entity state | Fetch one entity |
subscribe_events | Subscribe to state changes | Real-time updates |
Call Service Examples
Climate Control
{
type: 'call_service',
domain: 'climate',
service: 'set_temperature',
target: { entity_id: 'climate.living_room' },
data: { temperature: 22 }
}
Cover/Blinds
{
type: 'call_service',
domain: 'cover',
service: 'set_cover_position',
target: { entity_id: 'cover.bedroom_blinds' },
data: { position: 50 }
}
Scene Activation
{
type: 'call_service',
domain: 'scene',
service: 'turn_on',
target: { entity_id: 'scene.movie_night' }
}
Connection Lifecycle
Reconnection Strategy
Socket.IO handles reconnection automatically, but you should:
- Detect token expiry: Listen for auth errors and refresh token
- Re-authenticate: Update socket auth with new token
- Resync state: Request current states after reconnection
socket.on('connect_error', async (error) => {
if (error.message === 'unauthorized') {
// Refresh access token
const newToken = await refreshAccessToken();
// Update socket auth
socket.auth = { token: newToken };
// Reconnect
socket.connect();
}
});
socket.on('connect', () => {
// Resync state after reconnection
socket.emit('tunnel:message', {
deviceUuid: currentDevice,
message: { type: 'get_states' }
});
});
Error Handling
Common Errors
| Error | Cause | Solution |
|---|---|---|
unauthorized | Invalid/expired token | Refresh token and reconnect |
device_offline | Device not connected | Show offline status to user |
device_not_found | Invalid device UUID | Check device registration |
timeout | Device didn't respond | Retry or show error |
Error Event
socket.on('tunnel:error', (error) => {
switch (error.code) {
case 'DEVICE_OFFLINE':
showSnackbar('Device is offline');
break;
case 'COMMAND_TIMEOUT':
showSnackbar('Command timed out');
break;
default:
showSnackbar(`Error: ${error.message}`);
}
});
Best Practices
- Connection Management: Connect when app is active, disconnect in background
- Token Refresh: Handle token expiry gracefully with auto-refresh
- Error Handling: Show meaningful errors to users
- State Sync: Re-fetch state after reconnection
- Debounce Commands: Prevent rapid-fire commands (e.g., slider changes)
Next Steps
- API Reference: WebSocket Events - Complete event reference
- Authentication Guide - Token management
- Device Management - Register and manage devices