LynxJS ควบคุม IoT Devices จากมือถือ

LynxJS ควบคุม IoT Devices จากมือถือ

ShowkhunWorkshop

LynxJS ควบคุม IoT Devices จากมือถือ

Branch: step-12-mobile-control Phase: Mobile (3/5) — Device Control Repo: kangana1024/showkhun-workshop


ดูค่า sensor ได้แล้วมันก็ดี แต่ IoT ที่แท้จริงคือต้อง สั่งการได้ด้วย ใช่ไหมครับ? เปิดไฟจากมือถือ, reboot gateway ที่ค้าง, arm sensor ตรวจจับการเคลื่อนไหวก่อนออกจากบ้าน วันนี้เราจะทำหน้าจอที่ทำได้ทั้งหมดนั้น — list device, ค้นหา, กรอง, แล้วส่ง command จริงไป backend (◣_◢)

WHY ต้องมี dialog ยืนยันก่อนสั่ง? ลองคิดดูว่าถ้าปุ่ม “Turn off” กับ “Reboot” อยู่ติดกัน แล้วนิ้วโป้งเรากดพลาด — device ดับกลางดึกโดยไม่ตั้งใจ การมี confirmation dialog สำหรับ action ที่อันตรายคือการกัน “fat-finger” ที่ถูกที่สุด บทความนี้อ้างอิงโค้ดจริงใน branch step-12-mobile-control


ก่อนเริ่ม: น้องๆ จะได้อะไรจากบทนี้?

  • ทำ device list ที่ ค้นหา + กรองสถานะแบบ server-side (debounce + abort request เก่า)
  • เข้าใจ pattern การ “เข้าหน้า detail” ด้วย local state (ไม่ต้องใช้ router)
  • ส่ง command จริง ผ่าน POST /api/v1/devices/:id/commands
  • จัด command preset ตามชนิด device (relay/motion/gateway/…)
  • ทำ confirmation dialog กัน fat-finger สำหรับ action ที่ destructive
  • input/chips ของ Lynx: bindinput (uncontrolled) และ scroll-view แนวนอน

ภาพรวม: flow การสั่งงาน device

graph TD
    A[📋 DevicesScreen] -->|ค้นหา/กรอง| B[GET /devices?search=&status=]
    A -->|แตะ row| C[🔍 DeviceDetail]
    C -->|GET /devices/:id| D[โหลดข้อมูล device]
    C -->|แตะปุ่ม command| E[🛡️ Confirm Dialog]
    E -->|ยืนยัน| F[POST /devices/:id/commands]
    F -->|ตอบ accepted| G[💬 feedback บนจอ]

Step 1: useDevices — ค้นหา/กรองแบบ Server-side

แทนที่จะโหลด device มาทั้งหมดแล้วกรองในเครื่อง (เปลือง ถ้ามี device เป็นพัน) เราให้ backend กรองให้ ผ่าน query param search กับ status hook useDevices (screens/useDevices.ts) จัดการ debounce + abort ให้:

export function useDevices(): DevicesState {
  const [devices, setDevices] = useState<Device[]>([])
  const [loading, setLoading] = useState(true)
  const [refreshing, setRefreshing] = useState(false)
  const [error, setError] = useState<string | null>(null)
  const [search, setSearch] = useState('')
  const [status, setStatus] = useState<StatusFilter>('all')

  const abortRef = useRef<AbortController | null>(null)

  const load = useCallback(
    async (opts: { search: string; status: StatusFilter; refresh: boolean }) => {
      abortRef.current?.abort()           // ยกเลิก request เก่าที่ยังค้าง
      const controller = new AbortController()
      abortRef.current = controller

      if (opts.refresh) setRefreshing(true)
      else setLoading(true)
      setError(null)

      const params: ListDevicesParams = { limit: 100, sort: 'name' }
      const trimmed = opts.search.trim()
      if (trimmed) params.search = trimmed
      if (opts.status !== 'all') params.status = opts.status

      try {
        const { devices: list } = await api.listDevices(params, controller.signal)
        setDevices(list)
      } catch (err) {
        if (controller.signal.aborted) return  // ถูก abort? เงียบไว้
        setError(err instanceof ApiError ? err.message : 'failed to load devices')
      } finally {
        if (!controller.signal.aborted) {
          setLoading(false)
          setRefreshing(false)
        }
      }
    },
    [],
  )
}

ส่วน debounce คือกุญแจ — เราไม่ยิง request ทุกตัวอักษรที่ user พิมพ์ แต่รอ 250ms ให้พิมพ์เสร็จก่อน:

  // โหลดใหม่เมื่อ search หรือ status เปลี่ยน (debounce 250ms)
  useEffect(() => {
    const handle = setTimeout(() => {
      void load({ search, status, refresh: false })
    }, 250)
    return () => clearTimeout(handle)  // ยกเลิก timer เก่าถ้าพิมพ์ต่อ
  }, [search, status, load])

อุปมา debounce: เหมือนพนักงานต้อนรับที่รอให้เราพูดประโยคจบก่อน ค่อยจดออเดอร์ ไม่ใช่จดทีละพยางค์ที่เราพูด


Step 2: SearchInput + FilterChips — Input ของ Lynx

ฝั่ง UI ใน Lynx text input เป็น uncontrolled — เราไม่ push ค่ากลับด้วย value prop แต่ฟังผ่าน bindinput:

import type { BaseEvent } from '@lynx-js/types'

interface InputDetail { value: string }

export function SearchInput(props: SearchInputProps) {
  const handleInput = (e: BaseEvent<'bindinput', InputDetail>) => {
    props.onChange(e.detail.value)   // ค่าปัจจุบันอยู่ใน e.detail.value
  }
  return (
    <view className='SearchInput'>
      <text className='SearchInput__icon'>🔍</text>
      <input
        className='SearchInput__field'
        placeholder={props.placeholder ?? 'Search'}
        type='text'
        maxlength={128}
        bindinput={handleInput}
      />
    </view>
  )
}

FilterChips เป็น single-select แนวนอน วางบน scroll-view ที่ scroll ได้ (เผื่อ chip เยอะ):

export function FilterChips<T extends string>(props: FilterChipsProps<T>) {
  return (
    <scroll-view className='FilterChips' scroll-orientation='horizontal'>
      <view className='FilterChips__row'>
        {props.options.map((opt) => {
          const isActive = opt.value === props.active
          return (
            <view key={opt.value}
              className={isActive ? 'Chip Chip--active' : 'Chip'}
              bindtap={() => props.onSelect(opt.value)}>
              <text className={isActive ? 'Chip__label Chip__label--active' : 'Chip__label'}>
                {opt.label}
              </text>
            </view>
          )
        })}
      </view>
    </scroll-view>
  )
}

Step 3: DevicesScreen — List + เข้า Detail ด้วย Local State

จำที่บอกในบทแรกได้ไหมว่าเราไม่ใช้ router? การ “เข้าไปดู detail” เราใช้ local state selectedId ง่ายๆ ถ้ามีค่าก็ render DeviceDetail, ถ้าไม่มีก็ render list:

export function DevicesScreen() {
  const state = useDevices()
  const [selectedId, setSelectedId] = useState<string | null>(null)

  // มี selectedId = แสดงหน้า detail แทน
  if (selectedId) {
    return <DeviceDetail deviceId={selectedId} onBack={() => setSelectedId(null)} />
  }

  return (
    <view className='Screen'>
      <ScreenHeader title='Devices' subtitle='Manage and control devices'
        action={{ label: 'Refresh', onTap: state.refresh }} />
      <view className='Screen__body'>
        <SearchInput placeholder='Search by name or id' onChange={state.setSearch} />
        <FilterChips options={STATUS_OPTIONS} active={state.status} onSelect={state.setStatus} />
      </view>

      {/* loading / error / empty / list ... */}
      <scroll-view className='Devices__scroll' scroll-orientation='vertical'>
        <view className='Screen__body'>
          {state.devices.map((device) => (
            <DeviceRow key={device.id} device={device}
              onTap={() => setSelectedId(device.device_id)} />
          ))}
        </view>
      </scroll-view>
    </view>
  )
}

ตัวเลือกกรองสถานะตรงกับ DeviceStatus ของ backend ทุกค่า:

const STATUS_OPTIONS: ChipOption<StatusFilter>[] = [
  { value: 'all', label: 'All' },
  { value: 'online', label: 'Online' },
  { value: 'offline', label: 'Offline' },
  { value: 'error', label: 'Error' },
  { value: 'maintenance', label: 'Maintenance' },
]

Step 4: Command Preset ตามชนิด Device

ไม่ใช่ทุก device จะสั่งเหมือนกัน — relay มี “เปิด/ปิด”, motion sensor มี “arm/disarm”, gateway มี “sync” เราจึง map command ตาม DeviceType ใน screens/commands.ts:

export interface CommandPreset {
  label: string
  /** action ต้องผ่าน validator ของ backend: ตัวอักษร/ตัวเลข + dash */
  command: CommandPayload
  destructive?: boolean   // true = ต้องยืนยันแบบเน้นสีแดง
}

// command ที่ทุก device มีเหมือนกัน
const COMMON: CommandPreset[] = [
  { label: 'Reboot', command: { action: 'reboot' }, destructive: true },
  { label: 'Refresh status', command: { action: 'refresh-status' } },
]

const BY_TYPE: Record<DeviceType, CommandPreset[]> = {
  relay: [
    { label: 'Turn on', command: { action: 'turn-on' } },
    { label: 'Turn off', command: { action: 'turn-off' }, destructive: true },
  ],
  temperature_humidity: [
    { label: 'Calibrate', command: { action: 'calibrate' } },
  ],
  motion: [
    { label: 'Arm', command: { action: 'arm' } },
    { label: 'Disarm', command: { action: 'disarm' }, destructive: true },
  ],
  gateway: [
    { label: 'Sync devices', command: { action: 'sync' } },
  ],
}

/** คืน preset ของ device type นั้น (เฉพาะของชนิด + ของ common) */
export function commandsForType(type: DeviceType): CommandPreset[] {
  return [...(BY_TYPE[type] ?? []), ...COMMON]
}

WHY mark destructive? เพราะ “Refresh status” กับ “Turn off” มันคนละน้ำหนัก — อันแรกพลาดก็ไม่เป็นไร อันหลังพลาดอาจทำงานสะดุด เรา flag ตัวอันตรายไว้เพื่อให้ UI เน้นสีแดง + บังคับยืนยัน


Step 5: ส่ง Command จริง ผ่าน useDeviceDetail

hook useDeviceDetail (screens/useDeviceDetail.ts) โหลด device หนึ่งตัว แล้วมี sendCommand ที่คืนค่าว่า command ผ่านไหม (ให้ UI เอาไปตัดสินใจต่อ):

  const sendCommand = useCallback(
    async (cmd: CommandPayload): Promise<boolean> => {
      if (!device) return false
      setSending(true)
      setFeedback(null)
      try {
        // command ยิงไปที่ device_id (ที่คนอ่านออก) ไม่ใช่ Mongo _id
        await api.sendCommand(device.device_id, cmd)
        setFeedback(`Command "${cmd.action}" sent.`)
        return true
      } catch (err) {
        setFeedback(err instanceof ApiError ? err.message : 'failed to send command')
        return false
      } finally {
        setSending(false)
      }
    },
    [device],
  )

ฝั่ง client method api.sendCommand ยิง POST ไปที่ endpoint จริงแล้ว unwrap envelope ออกมา:

async sendCommand(id, cmd, signal) {
  const env = await request<DataEnvelope<{ device_id: string; action: string; status: string }>>(
    `/devices/${encodeURIComponent(id)}/commands`,
    { method: 'POST', body: cmd, signal },
  )
  return env.data
}

เกร็ดสำคัญ: command ใช้ device.device_id (เช่น sensor-01) ไม่ใช่ device.id (Mongo ObjectID) — เพราะ backend route รับ human-readable id comment ในโค้ดจริงเขียนเตือนไว้ตรงนี้เลย


Step 6: DeviceDetail + Confirmation Dialog

หน้า detail โชว์ข้อมูล device แล้ว render ปุ่ม command จาก preset ปุ่ม destructive เป็นสีแดง พอแตะ จะ ไม่ยิงทันที แต่เปิด dialog ก่อน:

export function DeviceDetail(props: DeviceDetailProps) {
  const { loading, error, device, sending, feedback, reload, sendCommand } =
    useDeviceDetail(props.deviceId)
  const [pending, setPending] = useState<CommandPreset | null>(null)

  const confirmCommand = async () => {
    if (!pending) return
    await sendCommand(pending.command)
    setPending(null)
  }

  // ... ส่วนบนแสดงข้อมูล device (status, type, last seen ฯลฯ) ...

  return (
    <view className='Screen'>
      {/* ปุ่ม command */}
      <Card>
        <text className='Detail__sectionTitle'>Commands</text>
        <view className='Detail__commandList'>
          {commandsForType(device.type).map((preset) => (
            <view key={preset.command.action} className='Detail__commandButton'>
              <Button
                label={preset.label}
                block
                variant={preset.destructive ? 'danger' : 'secondary'}
                disabled={sending}
                onTap={() => setPending(preset)}  {/* แค่เปิด dialog ยังไม่ยิง */}
              />
            </view>
          ))}
        </view>
        {feedback ? <text className='Detail__feedback'>{feedback}</text> : null}
      </Card>

      <Dialog
        visible={pending !== null}
        title={pending ? `${pending.label}?` : ''}
        message={pending && device ? `Send "${pending.command.action}" to ${device.name}?` : undefined}
        confirmLabel={pending?.label}
        destructive={pending?.destructive}
        busy={sending}
        onConfirm={confirmCommand}
        onCancel={() => setPending(null)}
      />
    </view>
  )
}

ตัว Dialog เอง render เป็น overlay และ คืน null เมื่อซ่อน เพื่อไม่กิน layout เลย:

export function Dialog(props: DialogProps) {
  if (!props.visible) return null   // ซ่อน = ไม่อยู่ใน tree เลย
  return (
    <view className='Dialog__backdrop'>
      <view className='Dialog__card'>
        <text className='Dialog__title'>{props.title}</text>
        {props.message ? <text className='Dialog__message'>{props.message}</text> : null}
        <view className='Dialog__actions'>
          <Button label={props.cancelLabel ?? 'Cancel'} variant='secondary'
            onTap={props.onCancel} disabled={props.busy} />
          <view className='Dialog__spacer' />
          <Button label={props.busy ? 'Working…' : props.confirmLabel ?? 'Confirm'}
            variant={props.destructive ? 'danger' : 'primary'}
            onTap={props.onConfirm} disabled={props.busy} />
        </view>
      </view>
    </view>
  )
}
เมื่อนิ้วโป้งกดพลาด แต่ dialog ดักไว้ทัน:

   ╔══════════════════════════════════╗
   ║                                  ║
   ║       ( •_•)>⌐■-■  Phew!         ║
   ║                                  ║
   ║   "ดีนะมี Confirm ไม่งั้นดับยาว"  ║
   ║                                  ║
   ╚══════════════════════════════════╝

สรุป: บทนี้เราทำอะไรไปบ้าง

ส่วนประกอบ รายละเอียด
useDevices ค้นหา/กรองแบบ server-side, debounce 250ms, abort request เก่า
Lynx input bindinput (uncontrolled) + scroll-view แนวนอนสำหรับ chips
Navigation เข้า detail ด้วย local state selectedId (ไม่ใช้ router)
Commands preset ตาม DeviceType + flag destructive
ส่งจริง POST /api/v1/devices/:id/commands ด้วย device_id (ไม่ใช่ Mongo _id)
ความปลอดภัย UX confirmation Dialog กัน fat-finger, ปุ่ม busy ระหว่างส่ง

ตอนนี้ app เรา “สั่งงานได้” แล้วครับ ไม่ใช่แค่ดูอย่างเดียว และยังมีตาข่ายกันพลาดด้วย


Next Step

ดูค่าปัจจุบันได้ สั่งงานได้ แต่ “แนวโน้มย้อนหลัง” ล่ะ? บทหน้าเราจะดึง time-series readings จาก InfluxDB มาวาดเป็น line chart จริงๆ บนมือถือ — เลือกช่วงเวลา/metric/downsample window ได้ด้วย มาลุยกันต่อ! (^_^)v


Navigation: