Fase 4 · Minggu 13

WebSocket Real-Time

Komunikasi dua arah real-time — ESP32 push data sensor, Flutter terima langsung tanpa polling.

HTTP Polling vs WebSocket

❌ HTTP Polling Flutter ESP32 ada data baru? tidak ada ada data baru? tidak ada ada data baru? ya! ini datanya Banyak request sia-sia Data delay = polling interval Buang bandwidth & resource ✅ WebSocket Flutter ESP32 connect (1x saja) connected! ✓ koneksi tetap terbuka push: sensor berubah! push: button ditekan! send: LED ON Data langsung dikirim (real-time) Hemat bandwidth, low latency

Polling terus bertanya → WebSocket langsung terima notifikasi dari ESP32

ESP32: WebSocket Server

C++ (Arduino)#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>

const char* ssid = "ESP32_IoT_AP";
const char* password = "12345678";

#define LED_PIN 2
#define LDR_PIN 34
#define BTN_PIN 15

AsyncWebServer server(80);
AsyncWebSocket ws("/ws");    // WebSocket endpoint: ws://192.168.4.1/ws

unsigned long lastSend = 0;
const int SEND_INTERVAL = 500; // kirim data setiap 500ms

// ====================
// WebSocket Event Handler
// ====================
void onWsEvent(AsyncWebSocket *server,
               AsyncWebSocketClient *client,
               AwsEventType type,
               void *arg, uint8_t *data, size_t len) {
  switch (type) {
    case WS_EVT_CONNECT:
      Serial.printf("Client #%u connected\n", client->id());
      // Kirim status awal ke client yang baru connect
      sendStatus(client);
      break;

    case WS_EVT_DISCONNECT:
      Serial.printf("Client #%u disconnected\n", client->id());
      break;

    case WS_EVT_DATA: {
      // Parse pesan masuk dari Flutter
      JsonDocument doc;
      DeserializationError err = deserializeJson(doc, data, len);
      if (err) return;

      const char* action = doc["action"];
      if (strcmp(action, "led") == 0) {
        bool state = doc["value"];
        digitalWrite(LED_PIN, state ? HIGH : LOW);
        // Broadcast perubahan ke SEMUA client
        broadcastStatus();
      }
      break;
    }

    case WS_EVT_ERROR:
      Serial.printf("WS Error on client #%u\n", client->id());
      break;

    case WS_EVT_PONG:
      break;
  }
}

// Kirim status ke 1 client
void sendStatus(AsyncWebSocketClient *client) {
  JsonDocument doc;
  doc["led"] = digitalRead(LED_PIN) ? true : false;
  doc["cahaya"] = analogRead(LDR_PIN);
  doc["uptime"] = millis() / 1000;

  String json;
  serializeJson(doc, json);
  client->text(json);
}

// Broadcast status ke SEMUA client
void broadcastStatus() {
  JsonDocument doc;
  doc["led"] = digitalRead(LED_PIN) ? true : false;
  doc["cahaya"] = analogRead(LDR_PIN);
  doc["uptime"] = millis() / 1000;

  String json;
  serializeJson(doc, json);
  ws.textAll(json);
}

void setup() {
  Serial.begin(115200);
  pinMode(LED_PIN, OUTPUT);
  pinMode(BTN_PIN, INPUT_PULLUP);

  WiFi.softAP(ssid, password);
  Serial.print("AP IP: ");
  Serial.println(WiFi.softAPIP());

  ws.onEvent(onWsEvent);
  server.addHandler(&ws);
  server.begin();
  Serial.println("WebSocket server ready!");
}

void loop() {
  // Bersihkan client yang sudah disconnect
  ws.cleanupClients();

  // Kirim sensor data periodik
  if (millis() - lastSend > SEND_INTERVAL) {
    lastSend = millis();
    broadcastStatus();
  }
}

Flutter: WebSocket Client

Terminalflutter pub add web_socket_channel
Dartimport 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';

class WsService {
  static const String wsUrl = 'ws://192.168.4.1/ws';
  WebSocketChannel? _channel;
  bool _isConnected = false;

  bool get isConnected => _isConnected;

  /// Connect ke ESP32 WebSocket server
  void connect({
    required Function(Map<String, dynamic>) onData,
    required Function() onDisconnect,
  }) {
    try {
      _channel = WebSocketChannel.connect(Uri.parse(wsUrl));
      _isConnected = true;

      _channel!.stream.listen(
        (message) {
          // Setiap data masuk → parse JSON
          final data = jsonDecode(message);
          onData(data);
        },
        onDone: () {
          _isConnected = false;
          onDisconnect();
        },
        onError: (error) {
          _isConnected = false;
          onDisconnect();
        },
      );
    } catch (e) {
      _isConnected = false;
    }
  }

  /// Kirim perintah ke ESP32
  void send(Map<String, dynamic> data) {
    if (_isConnected && _channel != null) {
      _channel!.sink.add(jsonEncode(data));
    }
  }

  /// Kirim perintah LED
  void setLed(bool isOn) {
    send({'action': 'led', 'value': isOn});
  }

  /// Disconnect
  void disconnect() {
    _channel?.sink.close();
    _isConnected = false;
  }
}

Flutter: Real-Time Dashboard

Dartimport 'package:flutter/material.dart';

class WsDashboard extends StatefulWidget {
  const WsDashboard({super.key});
  @override
  State<WsDashboard> createState() => _WsDashboardState();
}

class _WsDashboardState extends State<WsDashboard> {
  final WsService _ws = WsService();
  bool _ledOn = false;
  int _cahaya = 0;
  int _uptime = 0;

  // Simpan history cahaya untuk chart
  final List<int> _cahayaHistory = [];

  @override
  void initState() {
    super.initState();
    _connectWs();
  }

  void _connectWs() {
    _ws.connect(
      onData: (data) {
        if (!mounted) return;
        setState(() {
          _ledOn = data['led'] ?? false;
          _cahaya = data['cahaya'] ?? 0;
          _uptime = data['uptime'] ?? 0;

          _cahayaHistory.add(_cahaya);
          if (_cahayaHistory.length > 50) {
            _cahayaHistory.removeAt(0);
          }
        });
      },
      onDisconnect: () {
        if (!mounted) return;
        setState(() {});
        // Coba reconnect setelah 3 detik
        Future.delayed(
          const Duration(seconds: 3), () => _connectWs(),
        );
      },
    );
  }

  @override
  void dispose() {
    _ws.disconnect();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF0f172a),
      appBar: AppBar(
        title: const Text('WebSocket Dashboard'),
        backgroundColor: const Color(0xFF1e293b),
        actions: [
          Container(
            margin: const EdgeInsets.only(right: 16),
            padding: const EdgeInsets.symmetric(
                horizontal: 10, vertical: 4),
            decoration: BoxDecoration(
              color: _ws.isConnected
                  ? Colors.green.withValues(alpha: 0.2)
                  : Colors.red.withValues(alpha: 0.2),
              borderRadius: BorderRadius.circular(12),
            ),
            child: Row(
              children: [
                Icon(
                  _ws.isConnected
                      ? Icons.sensors
                      : Icons.sensors_off,
                  color: _ws.isConnected
                      ? Colors.green
                      : Colors.red,
                  size: 16,
                ),
                const SizedBox(width: 4),
                Text(
                  _ws.isConnected ? 'LIVE' : 'OFFLINE',
                  style: TextStyle(
                    color: _ws.isConnected
                        ? Colors.green
                        : Colors.red,
                    fontSize: 12,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            // Status cards
            Row(
              children: [
                _sensorCard('☀️', 'Cahaya', '$_cahaya',
                    Colors.amber),
                const SizedBox(width: 12),
                _sensorCard('⏱️', 'Uptime', '${_uptime}s',
                    Colors.cyan),
              ],
            ),
            const SizedBox(height: 24),

            // Mini chart (simple bar visualization)
            Container(
              height: 80,
              padding: const EdgeInsets.all(8),
              decoration: BoxDecoration(
                color: const Color(0xFF1e293b),
                borderRadius: BorderRadius.circular(12),
              ),
              child: Row(
                crossAxisAlignment: CrossAxisAlignment.end,
                children: _cahayaHistory
                    .map((v) => Expanded(
                          child: Container(
                            margin: const EdgeInsets.symmetric(
                                horizontal: 0.5),
                            height: (v / 4095) * 64,
                            decoration: BoxDecoration(
                              color: Colors.amber
                                  .withValues(alpha: 0.6),
                              borderRadius:
                                  BorderRadius.circular(1),
                            ),
                          ),
                        ))
                    .toList(),
              ),
            ),
            const SizedBox(height: 24),

            // LED toggle
            GestureDetector(
              onTap: () => _ws.setLed(!_ledOn),
              child: AnimatedContainer(
                duration: const Duration(milliseconds: 300),
                padding: const EdgeInsets.all(36),
                decoration: BoxDecoration(
                  shape: BoxShape.circle,
                  color: _ledOn
                      ? Colors.amber.withValues(alpha: 0.15)
                      : const Color(0xFF1e293b),
                  border: Border.all(
                    color: _ledOn ? Colors.amber : Colors.grey,
                    width: 3,
                  ),
                  boxShadow: _ledOn
                      ? [BoxShadow(
                          color: Colors.amber
                              .withValues(alpha: 0.3),
                          blurRadius: 30)]
                      : [],
                ),
                child: Icon(Icons.lightbulb,
                    size: 50,
                    color: _ledOn ? Colors.amber : Colors.grey),
              ),
            ),
            const SizedBox(height: 12),
            Text(
              'LED: ${_ledOn ? "ON" : "OFF"}',
              style: TextStyle(
                color: _ledOn ? Colors.amber : Colors.grey,
                fontSize: 18,
                fontWeight: FontWeight.bold,
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _sensorCard(
      String emoji, String label, String value, Color color) {
    return Expanded(
      child: Container(
        padding: const EdgeInsets.all(16),
        decoration: BoxDecoration(
          color: const Color(0xFF1e293b),
          borderRadius: BorderRadius.circular(12),
        ),
        child: Column(
          children: [
            Text(emoji, style: const TextStyle(fontSize: 28)),
            const SizedBox(height: 4),
            Text(label,
                style: TextStyle(
                    color: Colors.grey[400], fontSize: 12)),
            Text(value,
                style: TextStyle(
                    color: color,
                    fontSize: 22,
                    fontWeight: FontWeight.bold)),
          ],
        ),
      ),
    );
  }
}

Checklist Minggu 13