Skip to main content

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:

TypeJWT ContainsPurpose
User Connectionsub (user ID) onlyFlutter app controlling devices
Device ConnectiondeviceUuid claimAsaHome 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

FieldTypeRequiredDescription
deviceUuidstringYesTarget device UUID
messageobjectYesCommand payload
message.typestringYesCommand type
message.domainstringDependsService domain
message.servicestringDependsService to call
message.targetobjectDependsTarget entities
message.dataobjectNoAdditional 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

TypeDescriptionExample
call_serviceCall a device serviceTurn on light
get_statesGet current entity statesFetch all states
get_stateGet single entity stateFetch one entity
subscribe_eventsSubscribe to state changesReal-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:

  1. Detect token expiry: Listen for auth errors and refresh token
  2. Re-authenticate: Update socket auth with new token
  3. 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

ErrorCauseSolution
unauthorizedInvalid/expired tokenRefresh token and reconnect
device_offlineDevice not connectedShow offline status to user
device_not_foundInvalid device UUIDCheck device registration
timeoutDevice didn't respondRetry 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

  1. Connection Management: Connect when app is active, disconnect in background
  2. Token Refresh: Handle token expiry gracefully with auto-refresh
  3. Error Handling: Show meaningful errors to users
  4. State Sync: Re-fetch state after reconnection
  5. Debounce Commands: Prevent rapid-fire commands (e.g., slider changes)

Next Steps