LynxJS ควบคุม IoT Devices จากมือถือ
LynxJS ควบคุม IoT Devices จากมือถือ
Branch:
workshop/dev-12-lynxjs-controlPhase: Mobile Frontend (3/5) Repo: kangana1024/iot-workshop
Hook: ทำไมเราถึงต้องมี Control Interface?
สวัสดีน้องๆ พี่โชว์มาแล้ว! (ง •̀_•́)ง
ลองนึกภาพดูนะ… น้องๆ ไปติดตั้ง IoT sensor ทั่วโรงงาน ทั้ง temperature sensor, actuator, gateway รวมๆ แล้วสัก 50-100 ตัว แล้วเวลาอยากจะปรับค่าหรือสั่งการสักตัว น้องต้องทำยังไง? เปิด SSH เข้าไป manual? ไม่ไหวแล้วนะ!
นั่นแหละคือเหตุผลที่เราต้องมี Device Control Interface บนมือถือ ให้สามารถ:
- ค้นหา device ที่ต้องการได้ทันที
- สั่ง on/off หรือปรับค่าได้จากที่ไหนก็ได้
- เห็นประวัติคำสั่งที่ส่งไปแล้ว (สำคัญมากถ้าเกิดปัญหา!)
- มี confirmation dialog ป้องกันการกดพลาด
เหมือนเราเปลี่ยนจาก “วิ่งไปกดสวิตช์ทีละตัว” เป็น “กด app แล้วจบ” นั่นเอง!
สิ่งที่น้องๆ จะได้เรียนรู้
[Device Store] ──→ [Search & Filter Bar]
↓ ↓
[Devices List Screen] ←──────┘
↓
[Device Detail Screen]
├── [Toggle Control] ← เปิด/ปิด
├── [Slider Control] ← ปรับค่าต่อเนื่อง
├── [Command History] ← ดูประวัติ
└── [Confirm Dialog] ← ยืนยันก่อนยิง
มาลุยกันเลย!
ภาพรวม Data Flow ของทั้งระบบ
ก่อน HOW เราต้องเข้าใจ WHY ก่อน — ทำไม data ถึงไหลแบบนี้?
พี่อยากให้น้องๆ เห็นภาพรวมก่อนว่า Store เป็นตัวกลางจริงๆ ทุก component ดึงข้อมูลจาก Store และ push state กลับเข้า Store เหมือน “ห้องแชร์ข้อมูลกลาง” ของทีมงาน!
Step 1: Device Store — “คลังกลาง” ของทุกอย่าง
ทำไมต้องมี Store?
เปรียบง่ายๆ เลย: Store คือ กระดานไวท์บอร์ดกลางออฟฟิศ ที่ทุกคนมาเขียนและอ่านได้ ถ้าไม่มี Store แต่ละ component ก็ต้องส่งข้อมูลผ่าน props ซึ่งพอแอปใหญ่ขึ้น มันจะกลายเป็น “prop drilling นรก” ทันที
เราใช้ Zustand + Immer เพราะ:
- Zustand เบา ไม่ boilerplate เยอะ
- Immer ให้เขียน mutable code แต่ข้างในมัน immutable จริงๆ (magic!)
// src/stores/useDeviceStore.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import type { Device } from '../types';
export interface DeviceCommand {
id: string;
deviceId: string;
command: string;
payload: Record<string, unknown>;
status: 'pending' | 'success' | 'failed';
sentAt: string;
}
interface DeviceState {
devices: Device[];
selectedDevice: Device | null;
commandHistory: Record<string, DeviceCommand[]>;
searchQuery: string;
filterType: 'all' | 'sensor' | 'actuator' | 'gateway';
filterStatus: 'all' | 'online' | 'offline' | 'error';
isLoading: boolean;
setDevices: (devices: Device[]) => void;
selectDevice: (device: Device | null) => void;
addCommand: (cmd: DeviceCommand) => void;
updateCommandStatus: (cmdId: string, status: DeviceCommand['status']) => void;
setSearchQuery: (q: string) => void;
setFilterType: (f: DeviceState['filterType']) => void;
setFilterStatus: (f: DeviceState['filterStatus']) => void;
setLoading: (v: boolean) => void;
}
export const useDeviceStore = create<DeviceState>()(
immer((set) => ({
devices: [],
selectedDevice: null,
commandHistory: {},
searchQuery: '',
filterType: 'all',
filterStatus: 'all',
isLoading: false,
setDevices: (devices) =>
set((s) => {
s.devices = devices;
}),
selectDevice: (device) =>
set((s) => {
s.selectedDevice = device;
}),
addCommand: (cmd) =>
set((s) => {
if (!s.commandHistory[cmd.deviceId]) {
s.commandHistory[cmd.deviceId] = [];
}
s.commandHistory[cmd.deviceId].unshift(cmd);
// เก็บไว้แค่ 50 commands ล่าสุด
if (s.commandHistory[cmd.deviceId].length > 50) {
s.commandHistory[cmd.deviceId].pop();
}
}),
updateCommandStatus: (cmdId, status) =>
set((s) => {
for (const key of Object.keys(s.commandHistory)) {
const cmd = s.commandHistory[key].find((c) => c.id === cmdId);
if (cmd) {
cmd.status = status;
break;
}
}
}),
setSearchQuery: (q) =>
set((s) => {
s.searchQuery = q;
}),
setFilterType: (f) =>
set((s) => {
s.filterType = f;
}),
setFilterStatus: (f) =>
set((s) => {
s.filterStatus = f;
}),
setLoading: (v) =>
set((s) => {
s.isLoading = v;
}),
}))
);
สังเกตว่า commandHistory เป็น Record<string, DeviceCommand[]> — คือ map จาก deviceId ไปหา array ของ commands นะ ไม่ใช่ array เดียวรวมกัน เหตุผลคือพอน้องเปิดหน้า Device Detail จะได้ดึงประวัติแยก device ได้ทันที ไม่ต้องกรองใหม่ทุกครั้ง
Step 2: Search & Filter Bar — “ช่องค้นหาสมาร์ต”
ทำไมต้องแยก component นี้ออกมา?
เพราะ SearchFilterBar ถูกใช้ใน DevicesScreen แน่ๆ และอาจถูกใช้ที่อื่นอีก ถ้าเราเขียนฝังไว้ใน Screen เลย พอวันหนึ่งอยากจะ reuse มันก็เจ็บ ดีกว่าแยกออกมาตั้งแต่แรก
// src/components/devices/SearchFilterBar.tsx
import { View, TextInput, TouchableOpacity, Text, ScrollView, StyleSheet } from '@lynx-js/react';
import { useTheme } from '../../theme';
import { useDeviceStore } from '../../stores/useDeviceStore';
const TYPE_FILTERS = ['all', 'sensor', 'actuator', 'gateway'] as const;
const STATUS_FILTERS = ['all', 'online', 'offline', 'error'] as const;
export function SearchFilterBar() {
const { colors } = useTheme();
const { searchQuery, filterType, filterStatus, setSearchQuery, setFilterType, setFilterStatus } =
useDeviceStore();
return (
<View style={styles.container}>
{/* Search */}
<TextInput
style={[
styles.searchInput,
{ backgroundColor: colors.inputBackground, color: colors.text, borderColor: colors.border },
]}
placeholder="Search devices..."
placeholderTextColor={colors.textSecondary}
value={searchQuery}
onChangeText={setSearchQuery}
clearButtonMode="while-editing"
/>
{/* Type Filter */}
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.filterRow}>
{TYPE_FILTERS.map((type) => (
<TouchableOpacity
key={type}
style={[
styles.chip,
{
backgroundColor: filterType === type ? colors.primary : colors.surface,
borderColor: filterType === type ? colors.primary : colors.border,
},
]}
onPress={() => setFilterType(type)}
>
<Text
style={[
styles.chipText,
{ color: filterType === type ? '#fff' : colors.textSecondary },
]}
>
{type.charAt(0).toUpperCase() + type.slice(1)}
</Text>
</TouchableOpacity>
))}
</ScrollView>
{/* Status Filter */}
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.filterRow}>
{STATUS_FILTERS.map((status) => (
<TouchableOpacity
key={status}
style={[
styles.chip,
{
backgroundColor: filterStatus === status ? colors.secondary : colors.surface,
borderColor: filterStatus === status ? colors.secondary : colors.border,
},
]}
onPress={() => setFilterStatus(status)}
>
<Text
style={[
styles.chipText,
{ color: filterStatus === status ? '#fff' : colors.textSecondary },
]}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: { paddingHorizontal: 16, paddingTop: 12, paddingBottom: 4 },
searchInput: {
height: 44,
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: 12,
fontSize: 15,
marginBottom: 10,
},
filterRow: { marginBottom: 8 },
chip: {
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 20,
borderWidth: 1,
marginRight: 8,
},
chipText: { fontSize: 13, fontWeight: '500' },
});
chip ที่ active จะเปลี่ยนสีเป็น primary/secondary แยกกันระหว่าง type กับ status — ทำให้ user รู้ทันทีว่ากรองอยู่ด้วยอะไร ไม่ต้องเดา
Step 3: Device List Screen — “หน้าแรกที่ user เห็น”
ทำไม useMemo ถึงสำคัญมากที่นี่?
ถ้า devices มี 100 ตัว แล้ว user พิมพ์ search ทีละตัวอักษร มันจะ re-render และกรองข้อมูลใหม่ทุกครั้ง useMemo ช่วยให้คำนวณใหม่เฉพาะเมื่อ dependencies เปลี่ยนจริงๆ เหมือน “จำคำตอบไว้ก่อน ถ้าคำถามไม่เปลี่ยน ก็ไม่ต้องคิดใหม่”
// src/screens/DevicesScreen.tsx
import {
View,
FlatList,
TouchableOpacity,
Text,
StyleSheet,
RefreshControl,
} from '@lynx-js/react';
import { useEffect, useCallback, useMemo } from '@lynx-js/react';
import { useNavigation } from '@lynx-js/react-navigation';
import { useDeviceStore } from '../stores/useDeviceStore';
import { api } from '../services/api';
import { SearchFilterBar } from '../components/devices/SearchFilterBar';
import { Badge } from '../components/ui/Badge';
import { useTheme } from '../theme';
import type { Device } from '../types';
export default function DevicesScreen() {
const { colors } = useTheme();
const navigation = useNavigation();
const {
devices,
searchQuery,
filterType,
filterStatus,
isLoading,
setDevices,
selectDevice,
setLoading,
} = useDeviceStore();
const loadDevices = useCallback(async () => {
setLoading(true);
try {
const res = await api.getDevices();
setDevices(res.data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadDevices();
}, []);
// Filter devices
const filteredDevices = useMemo(() => {
return devices.filter((d) => {
const matchSearch =
!searchQuery ||
d.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
d.location.toLowerCase().includes(searchQuery.toLowerCase());
const matchType = filterType === 'all' || d.type === filterType;
const matchStatus = filterStatus === 'all' || d.status === filterStatus;
return matchSearch && matchType && matchStatus;
});
}, [devices, searchQuery, filterType, filterStatus]);
const handleSelect = (device: Device) => {
selectDevice(device);
navigation.navigate('DeviceDetail', { deviceId: device.id });
};
const renderDevice = ({ item }: { item: Device }) => (
<TouchableOpacity
style={[styles.deviceRow, { backgroundColor: colors.surface }]}
onPress={() => handleSelect(item)}
activeOpacity={0.7}
>
<View style={styles.deviceInfo}>
<Text style={[styles.deviceName, { color: colors.text }]}>{item.name}</Text>
<Text style={[styles.deviceLocation, { color: colors.textSecondary }]}>
{item.location}
</Text>
<Text style={[styles.deviceType, { color: colors.textTertiary }]}>
{item.type}
</Text>
</View>
<View style={styles.deviceRight}>
<Badge
label={item.status}
variant={
item.status === 'online'
? 'success'
: item.status === 'error'
? 'danger'
: 'default'
}
size="sm"
/>
<Text style={[styles.chevron, { color: colors.textSecondary }]}>›</Text>
</View>
</TouchableOpacity>
);
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<SearchFilterBar />
<FlatList
data={filteredDevices}
keyExtractor={(item) => item.id}
renderItem={renderDevice}
contentContainerStyle={styles.list}
ItemSeparatorComponent={() => (
<View style={[styles.separator, { backgroundColor: colors.border }]} />
)}
refreshControl={
<RefreshControl
refreshing={isLoading}
onRefresh={loadDevices}
tintColor={colors.primary}
/>
}
ListEmptyComponent={
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>
No devices match your filters.
</Text>
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
list: { paddingBottom: 20 },
deviceRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 14,
},
deviceInfo: { flex: 1 },
deviceName: { fontSize: 15, fontWeight: '600', marginBottom: 2 },
deviceLocation: { fontSize: 13, marginBottom: 2 },
deviceType: { fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5 },
deviceRight: { flexDirection: 'row', alignItems: 'center', gap: 10 },
chevron: { fontSize: 22, fontWeight: '300' },
separator: { height: 1, marginLeft: 16 },
emptyText: { textAlign: 'center', marginTop: 40, fontSize: 14 },
});
เรื่อง pull-to-refresh — อย่าลืมว่า refreshControl ผูกกับ isLoading จาก Store นะ ถ้าน้องลืมใส่ user จะดึงลงมาแล้วไม่เห็น spinner เลย ดูไม่ดีเลย!
Step 4: Control Components — “ปุ่มและ Slider สุดพิเศษ”
ทำไมต้องแยก ToggleControl กับ SliderControl ออกมา?
เพราะ controls พวกนี้ซ้ำกันหลายที่มาก อุปกรณ์หนึ่งตัวอาจมีหลาย toggle และหลาย slider แถมยังต้องจัดการ loading state ของตัวเองด้วย ถ้าเขียนรวมใน Screen เดียวจะยาวและ maintain ยากมาก
เปรียบเหมือน “รีโมทคอนโทรล” — ปุ่มกดแต่ละปุ่มเป็น component ที่รู้จักตัวเองดี ไม่ใช่ให้ TV คอยจัดการว่าปุ่มไหนควรทำอะไร
4.1 Toggle Control — สวิตช์พร้อม Loading
// src/components/controls/ToggleControl.tsx
import { View, Text, Switch, StyleSheet } from '@lynx-js/react';
import { useState } from '@lynx-js/react';
import { useTheme } from '../../theme';
interface ToggleControlProps {
label: string;
description?: string;
value: boolean;
onToggle: (value: boolean) => Promise<void>;
disabled?: boolean;
}
export function ToggleControl({
label,
description,
value,
onToggle,
disabled = false,
}: ToggleControlProps) {
const { colors } = useTheme();
const [isLoading, setIsLoading] = useState(false);
const handleToggle = async (newValue: boolean) => {
setIsLoading(true);
try {
await onToggle(newValue);
} catch (err) {
console.error('Toggle failed:', err);
} finally {
setIsLoading(false);
}
};
return (
<View style={[styles.row, { borderBottomColor: colors.border }]}>
<View style={styles.labelContainer}>
<Text style={[styles.label, { color: colors.text }]}>{label}</Text>
{description && (
<Text style={[styles.description, { color: colors.textSecondary }]}>
{description}
</Text>
)}
</View>
<Switch
value={value}
onValueChange={handleToggle}
disabled={disabled || isLoading}
trackColor={{ false: colors.border, true: colors.primary }}
thumbColor={value ? '#fff' : colors.textSecondary}
/>
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 14,
borderBottomWidth: 1,
},
labelContainer: { flex: 1, marginRight: 16 },
label: { fontSize: 15, fontWeight: '500' },
description: { fontSize: 12, marginTop: 2 },
});
สังเกต disabled={disabled || isLoading} — ระหว่างรอ API ตอบกลับ สวิตช์จะ disable อัตโนมัติ ป้องกัน user กดซ้ำก่อน command แรกจะเสร็จ!
4.2 Slider Control — ลื่นมือ แต่ส่ง API ตอน slidingComplete
// src/components/controls/SliderControl.tsx
import { View, Text, StyleSheet } from '@lynx-js/react';
import { useState } from '@lynx-js/react';
import Slider from '@lynx-js/community-slider';
import { useTheme } from '../../theme';
interface SliderControlProps {
label: string;
value: number;
min: number;
max: number;
step?: number;
unit?: string;
onValueChange?: (value: number) => void;
onSlidingComplete: (value: number) => Promise<void>;
disabled?: boolean;
}
export function SliderControl({
label,
value,
min,
max,
step = 1,
unit = '',
onValueChange,
onSlidingComplete,
disabled = false,
}: SliderControlProps) {
const { colors } = useTheme();
const [localValue, setLocalValue] = useState(value);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (v: number) => {
setLocalValue(v);
onValueChange?.(v);
};
const handleComplete = async (v: number) => {
setIsSubmitting(true);
try {
await onSlidingComplete(v);
} finally {
setIsSubmitting(false);
}
};
return (
<View style={[styles.container, { borderBottomColor: colors.border }]}>
<View style={styles.header}>
<Text style={[styles.label, { color: colors.text }]}>{label}</Text>
<Text style={[styles.value, { color: colors.primary }]}>
{localValue}
{unit}
</Text>
</View>
<Slider
style={styles.slider}
value={localValue}
minimumValue={min}
maximumValue={max}
step={step}
minimumTrackTintColor={isSubmitting ? colors.textSecondary : colors.primary}
maximumTrackTintColor={colors.border}
thumbTintColor={colors.primary}
onValueChange={handleChange}
onSlidingComplete={handleComplete}
disabled={disabled || isSubmitting}
/>
<View style={styles.range}>
<Text style={[styles.rangeText, { color: colors.textSecondary }]}>
{min}{unit}
</Text>
<Text style={[styles.rangeText, { color: colors.textSecondary }]}>
{max}{unit}
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { paddingVertical: 14, borderBottomWidth: 1 },
header: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 8 },
label: { fontSize: 15, fontWeight: '500' },
value: { fontSize: 15, fontWeight: '700' },
slider: { width: '100%', height: 40 },
range: { flexDirection: 'row', justifyContent: 'space-between' },
rangeText: { fontSize: 11 },
});
เคล็ดลับสำคัญ: เราใช้ localValue เพื่อ update UI แบบ real-time ขณะเลื่อน แต่เรียก API เฉพาะตอน onSlidingComplete เท่านั้น ถ้าส่ง API ทุก tick มันจะยิง request เป็น 100 ครั้งตอนเลื่อน slider!
Step 5: Confirmation Dialog — “เกราะป้องกันการกดผิด”
ทำไมต้องมี Dialog ยืนยัน?
ลองนึกว่าเราส่ง command set_power: false ไปยัง sensor ในห้อง server โดยไม่ได้ตั้งใจ… อุณหภูมิห้องพุ่งขึ้น data หาย ทีมงานวิ่งกันวุ่น Dialog ยืนยันนี้คือ “ลูกกรงนิรภัย” ชั้นสุดท้ายก่อน execute!
+----------------------------------+
| Confirm Command |
| |
| Are you sure you want to |
| execute: Power: OFF? |
| |
| [ Cancel ] [ Execute ] |
+----------------------------------+
// src/components/controls/ConfirmDialog.tsx
import { View, Text, Modal, TouchableOpacity, StyleSheet } from '@lynx-js/react';
import { useTheme } from '../../theme';
interface ConfirmDialogProps {
visible: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
dangerous?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog({
visible,
title,
message,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
dangerous = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const { colors } = useTheme();
return (
<Modal transparent visible={visible} animationType="fade" onRequestClose={onCancel}>
<TouchableOpacity
style={[styles.overlay, { backgroundColor: colors.overlay }]}
activeOpacity={1}
onPress={onCancel}
>
<View
style={[styles.dialog, { backgroundColor: colors.surface }]}
// Prevent tap from bubbling
onStartShouldSetResponder={() => true}
>
<Text style={[styles.title, { color: colors.text }]}>{title}</Text>
<Text style={[styles.message, { color: colors.textSecondary }]}>{message}</Text>
<View style={styles.buttons}>
<TouchableOpacity
style={[styles.button, styles.cancelButton, { borderColor: colors.border }]}
onPress={onCancel}
>
<Text style={[styles.buttonText, { color: colors.text }]}>{cancelLabel}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.button,
styles.confirmButton,
{ backgroundColor: dangerous ? colors.danger : colors.primary },
]}
onPress={onConfirm}
>
<Text style={[styles.buttonText, { color: '#fff' }]}>{confirmLabel}</Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 24,
},
dialog: {
width: '100%',
borderRadius: 16,
padding: 24,
},
title: { fontSize: 18, fontWeight: '700', marginBottom: 10 },
message: { fontSize: 14, lineHeight: 22, marginBottom: 24 },
buttons: { flexDirection: 'row', gap: 12 },
button: {
flex: 1,
height: 44,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 10,
},
cancelButton: { borderWidth: 1 },
confirmButton: {},
buttonText: { fontSize: 15, fontWeight: '600' },
});
prop dangerous ทำให้ปุ่ม Confirm เปลี่ยนเป็นสีแดง สำหรับ action ที่ risk สูง เช่น reset device หรือ factory reset ดีมากสำหรับ UX!
Step 6: Device Detail Screen — รวมทุก piece เข้าด้วยกัน
ทำไม pattern “pendingCommand” ถึงดีกว่า confirm ทันที?
เพราะ ToggleControl ส่ง callback มาว่า “อยากทำอะไร” แต่ตัดสินใจ execute จริงๆ ที่ Screen แทน ทำให้ control ไม่ต้องรู้ว่ามี Dialog อยู่ ลด coupling ระหว่าง component!
เหมือนพนักงานธนาคาร (ToggleControl) ที่รับคำขอมา แล้วส่งต่อหัวหน้า (Screen) ให้ approve ก่อนทำ — ไม่ใช่พนักงานตัดสินใจเองทันที
// src/screens/DeviceDetailScreen.tsx
import {
View,
Text,
ScrollView,
StyleSheet,
} from '@lynx-js/react';
import { useState, useCallback } from '@lynx-js/react';
import { useRoute } from '@lynx-js/react-navigation';
import { useDeviceStore, DeviceCommand } from '../stores/useDeviceStore';
import { api } from '../services/api';
import { Card } from '../components/ui/Card';
import { Badge } from '../components/ui/Badge';
import { ToggleControl } from '../components/controls/ToggleControl';
import { SliderControl } from '../components/controls/SliderControl';
import { ConfirmDialog } from '../components/controls/ConfirmDialog';
import { useTheme } from '../theme';
import { format } from 'date-fns';
import { nanoid } from '../utils/helpers';
export default function DeviceDetailScreen() {
const { colors } = useTheme();
const route = useRoute<{ deviceId: string }>();
const { selectedDevice, commandHistory, addCommand, updateCommandStatus } =
useDeviceStore();
const [pendingCommand, setPendingCommand] = useState<{
command: string;
payload: Record<string, unknown>;
label: string;
} | null>(null);
const device = selectedDevice;
const history = device ? (commandHistory[device.id] ?? []) : [];
const executeCommand = useCallback(
async (command: string, payload: Record<string, unknown>) => {
if (!device) return;
const cmd: DeviceCommand = {
id: nanoid(),
deviceId: device.id,
command,
payload,
status: 'pending',
sentAt: new Date().toISOString(),
};
addCommand(cmd);
try {
await api.sendCommand(device.id, command, payload);
updateCommandStatus(cmd.id, 'success');
} catch {
updateCommandStatus(cmd.id, 'failed');
}
},
[device]
);
const handleToggle = (command: string, label: string) => async (value: boolean) => {
setPendingCommand({ command, payload: { value }, label: `${label}: ${value ? 'ON' : 'OFF'}` });
};
const handleConfirm = async () => {
if (!pendingCommand) return;
setPendingCommand(null);
await executeCommand(pendingCommand.command, pendingCommand.payload);
};
if (!device) {
return (
<View style={[styles.center, { backgroundColor: colors.background }]}>
<Text style={{ color: colors.textSecondary }}>Device not found.</Text>
</View>
);
}
return (
<ScrollView
style={[styles.container, { backgroundColor: colors.background }]}
contentContainerStyle={styles.content}
>
{/* Device Info */}
<Card variant="elevated">
<View style={styles.deviceHeader}>
<View>
<Text style={[styles.deviceName, { color: colors.text }]}>{device.name}</Text>
<Text style={[styles.deviceMeta, { color: colors.textSecondary }]}>
{device.type} • {device.location}
</Text>
</View>
<Badge
label={device.status}
variant={
device.status === 'online' ? 'success' : device.status === 'error' ? 'danger' : 'default'
}
/>
</View>
<Text style={[styles.lastSeen, { color: colors.textSecondary }]}>
Last seen: {format(new Date(device.lastSeen), 'dd MMM yyyy HH:mm')}
</Text>
</Card>
{/* Controls */}
<Card title="Controls" variant="elevated">
<ToggleControl
label="Power"
description="Turn device on or off"
value={device.status === 'online'}
onToggle={handleToggle('set_power', 'Power')}
disabled={device.status === 'error'}
/>
<SliderControl
label="Reporting Interval"
value={30}
min={5}
max={300}
step={5}
unit="s"
onSlidingComplete={async (v) => {
await executeCommand('set_interval', { seconds: v });
}}
/>
<SliderControl
label="Alert Threshold (Temp)"
value={35}
min={20}
max={60}
step={0.5}
unit="°C"
onSlidingComplete={async (v) => {
await executeCommand('set_threshold', { type: 'temperature', value: v });
}}
/>
</Card>
{/* Command History */}
<Card title={`Command History (${history.length})`} variant="elevated">
{history.length === 0 ? (
<Text style={[styles.noHistory, { color: colors.textSecondary }]}>
No commands sent yet.
</Text>
) : (
history.slice(0, 10).map((cmd) => (
<View
key={cmd.id}
style={[styles.historyItem, { borderBottomColor: colors.border }]}
>
<View style={styles.historyLeft}>
<Text style={[styles.historyCmd, { color: colors.text }]}>{cmd.command}</Text>
<Text style={[styles.historyTime, { color: colors.textSecondary }]}>
{format(new Date(cmd.sentAt), 'HH:mm:ss')}
</Text>
</View>
<Badge
label={cmd.status}
variant={
cmd.status === 'success'
? 'success'
: cmd.status === 'failed'
? 'danger'
: 'info'
}
size="sm"
/>
</View>
))
)}
</Card>
<ConfirmDialog
visible={!!pendingCommand}
title="Confirm Command"
message={`Are you sure you want to execute: ${pendingCommand?.label}?`}
confirmLabel="Execute"
onConfirm={handleConfirm}
onCancel={() => setPendingCommand(null)}
/>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
content: { padding: 16 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
deviceHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 8,
},
deviceName: { fontSize: 18, fontWeight: '700', marginBottom: 2 },
deviceMeta: { fontSize: 13 },
lastSeen: { fontSize: 12 },
noHistory: { fontSize: 13, textAlign: 'center', paddingVertical: 12 },
historyItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 10,
borderBottomWidth: 1,
},
historyLeft: {},
historyCmd: { fontSize: 13, fontWeight: '500', marginBottom: 2 },
historyTime: { fontSize: 11 },
});
สรุปกับพี่โชว์
เยี่ยมมากน้องๆ! เราผ่านมาครบทุก piece แล้ว มาดูกันว่าวันนี้ทำอะไรไปบ้าง:
| สิ่งที่สร้าง | หน้าที่ | เคล็ดลับ |
|---|---|---|
| Device Store | คลังกลาง state ทั้งหมด | Zustand + Immer = mutable code, immutable data |
| SearchFilterBar | ค้นหา + กรอง device | แยก component ไว้ reuse |
| DevicesScreen | แสดงรายการ devices | useMemo ช่วย performance |
| ToggleControl | สวิตช์เปิด/ปิด | disabled ระหว่าง loading |
| SliderControl | ปรับค่าต่อเนื่อง | API เฉพาะตอน onSlidingComplete |
| ConfirmDialog | ยืนยันก่อน execute | prop dangerous เปลี่ยนสีแดง |
| DeviceDetailScreen | รวมทุก control | pattern pendingCommand ลด coupling |
Next Step
บทหน้าเราจะไปสร้าง Data Visualization ด้วย Charts ใน LynxJS กัน จะ plot กราฟ temperature, humidity แบบ real-time ด้วยนะ รอติดตาม!
(o^▽^o) ขอบคุณที่อ่านมาถึงตรงนี้นะน้องๆ แล้วเจอกันบทหน้า!
Navigation:
- Prev: #14 Real-time Dashboard
- Next: #16 Data Visualization