ทำไมเน็ตช้า! หาคำตอบด้วย M5Stack และ Raspberry Pi

หลายสัปดาห์ก่อนเพิ่ง Download Dota 2 กลับมาครับ แล้วก็พบว่าเวลามัน patch เน็ตมันช้ามาก แถมรันเป็น background ไม่รู้อีกว่าเน็ตโดนสูบอยู่ นึกว่าคอมมีปัญหาเพราะไม่มีใครใช้เน็ตแล้วมันจะช้าได้ยังไง

ถ้าใช้ router แพงๆ มันมักจะมีจอมาด้วยซึ่งจะแสดง network speed อยู่ ก็เลยคิดว่าจะลองเขียนอะไรแบบนั้นดู

แผนการ

สำหรับแผนการในรอบนี้คือผมจะเอา M5Stack อ่าน SNMP จาก router ผม แล้วมาแสดงบนหน้าจอ

M5Stack

espressif.com

พอดีไปเดินงาน Maker Faire Bangkok ก็เลยยุให้พ่อซื้อ M5Stack ให้ตัวหนึ่ง (บังเอิญเจอพี่ลิ่ว Blognone ด้วย เลยให้พี่ลิ่วช่วยยุ) ซึ่งผมว่ามันเจ๋งมากเพราะมันมีจอ ปุ่มกด และ battery ในตัว (แบตเท่าที่ลองอยู่ได้ประมาณ 10 นาที อย่าคาดหวังอะไรมันมาก และมันจะ reset ตอนเสียบไฟ)

สำหรับการเขียนโปรแกรมนั้น M5Stack ใช้ CPU ESP32 (ตัวพี่ของ ESP8266 ยอดนิยม) สามารถใช้ Arduino IDE เขียนได้ ซึ่งปรากฏว่า Arduino ไม่มี SNMP client library ครั้นจะเขียนเองก็ยุ่งยากไป เลยคิดว่าจะเอา Raspberry Pi มาอ่านแล้วส่งข้อมูลให้ M5Stack อีกที

Router configuration

ก่อนจะไปถึง Raspberry Pi มาตั้งค่า router ให้มีข้อมูลพร้อมอ่านกันก่อน สำหรับ Router Mikrotik ทำได้ดังนี้

  1. เปิด WebFig/WinBox ขึ้นมาแล้ว login ให้เรียบร้อย
  2. ไปที่เมนู IP > SNMP
  3. เลือก Enabled
  4. คลิกที่ Communities ด้านบน กด Add New
  5. ระบุข้อมูลดังนี้
    • Name: อะไรก็ได้
    • Addresses: ไม่ใส่ก็ได้ หรือถ้าต้องการให้ปลอดภัยระบุเป็น IP ของ Raspberry Pi หรือ CIDR ก็ได้
    • Read Access: ติ๊กไว้
    • ผมยังไม่เปิดใช้ security เนื่องจากเป็น read only และ allow เฉพาะ LAN IP ไว้เลยคิดว่าความเสี่ยงต่ำพอแล้ว
  6. ตอนนี้น่าจะพร้อมใช้งาน SNMP แล้ว

ถัดมาเราจะหา OID ของ interface ที่ออกเน็ต ขั้นตอนนี้จะต้องใช้เมนู Terminal แล้วใช้คำสั่ง /interface print oid จะได้ผลดังนี้

 0  R  name=.1.3.6.1.2.1.2.2.1.2.1 actual-mtu=.1.3.6.1.2.1.2.2.1.4.1 mac-address=.1.3.6.1.2.1.2.2.1.6.1 admin-status=.1.3.6.1.2.1.2.2.1.7.1 oper-status=.1.3.6.1.2.1.2.2.1.8.1 bytes-in=.1.3.6.1.2.1.31.1.1.1.6.1 packets-in=.1.3.6.1.2.1.31.1.1.1.7.1 discards-in=.1.3.6.1.2.1.2.2.1.13.1 
       errors-in=.1.3.6.1.2.1.2.2.1.14.1 bytes-out=.1.3.6.1.2.1.31.1.1.1.10.1 packets-out=.1.3.6.1.2.1.31.1.1.1.11.1 discards-out=.1.3.6.1.2.1.2.2.1.19.1 errors-out=.1.3.6.1.2.1.2.2.1.20.1 

 1  RS name=.1.3.6.1.2.1.2.2.1.2.2 actual-mtu=.1.3.6.1.2.1.2.2.1.4.2 mac-address=.1.3.6.1.2.1.2.2.1.6.2 admin-status=.1.3.6.1.2.1.2.2.1.7.2 oper-status=.1.3.6.1.2.1.2.2.1.8.2 bytes-in=.1.3.6.1.2.1.31.1.1.1.6.2 packets-in=.1.3.6.1.2.1.31.1.1.1.7.2 discards-in=.1.3.6.1.2.1.2.2.1.13.2 
       errors-in=.1.3.6.1.2.1.2.2.1.14.2 bytes-out=.1.3.6.1.2.1.31.1.1.1.10.2 packets-out=.1.3.6.1.2.1.31.1.1.1.11.2 discards-out=.1.3.6.1.2.1.2.2.1.19.2 errors-out=.1.3.6.1.2.1.2.2.1.20.2 

 2  RS name=.1.3.6.1.2.1.2.2.1.2.3 actual-mtu=.1.3.6.1.2.1.2.2.1.4.3 mac-address=.1.3.6.1.2.1.2.2.1.6.3 admin-status=.1.3.6.1.2.1.2.2.1.7.3 oper-status=.1.3.6.1.2.1.2.2.1.8.3 bytes-in=.1.3.6.1.2.1.31.1.1.1.6.3 packets-in=.1.3.6.1.2.1.31.1.1.1.7.3 discards-in=.1.3.6.1.2.1.2.2.1.13.3 
       errors-in=.1.3.6.1.2.1.2.2.1.14.3 bytes-out=.1.3.6.1.2.1.31.1.1.1.10.3 packets-out=.1.3.6.1.2.1.31.1.1.1.11.3 discards-out=.1.3.6.1.2.1.2.2.1.19.3 errors-out=.1.3.6.1.2.1.2.2.1.20.3 

แล้วดูเทียบกับเมนู /interface print

 #     NAME                                TYPE       ACTUAL-MTU L2MTU  MAX-L2MTU MAC-ADDRESS      
 0  R  ether1-internet                     ether            1500  1596       2026
 1  RS ether2-celty                        ether            1500  1596       2026
 2  RS ether3-switch                       ether            1500  1596       2026

ซึ่งของผม ether1-internet เป็นตัวที่ต่อกับ router ภายนอก ก็เลยจะต้องดูว่าเบอร์ 0 นั้น oid ที่แสดงข้อมูล internet คืออะไร ก็จะเห็นว่า

  • Bytes in: .1.3.6.1.2.1.31.1.1.1.6.1 (Download)
  • Bytes out: .1.3.6.1.2.1.31.1.1.1.10.1 (Upload)

ซึ่งเราจะจดไว้ใช้ในโค้ดเราต่อไป

Raspberry Pi

สำหรับบน Raspberry Pi นั้นของที่ผมมีอยู่จะเป็น Model B+ ค่อนข้างเก่าแต่ก็ไม่ได้จำเป็นต้องใช้อะไรแรงอยู่แล้ว (งานแค่นี้ Arduino ก็คงทำได้ถ้ามันมี library) โดยใช้ดิสโตรโปรดของผมคือ Arch Linux ARM

โดยโค้ดจะประมาณนี้


import time import socket import struct from easysnmp import Session SLEEP_DURATION = 2 session = Session(hostname='192.168.2.1', community='local', version=2) client = socket.socket(type=socket.SOCK_DGRAM) last_download = 0 last_upload = 0 while True: download = int(int(session.get('.1.3.6.1.2.1.31.1.1.1.6.1').value)/SLEEP_DURATION) upload = int(int(session.get('.1.3.6.1.2.1.31.1.1.1.10.1').value)/SLEEP_DURATION) if last_download != 0: print(download - last_download, upload - last_upload) client.sendto(struct.pack('<2I', download - last_download, upload - last_upload), ('192.168.3.7', 1)) last_download = download last_upload = upload time.sleep(SLEEP_DURATION)

คร่าวๆ ก็คือโปรแกรมจะอ่่านค่า OID ทั้ง 2 ตัวจาก 192.168.2.1 แล้วส่งไปให้ 192.168.3.7 UDP port 1

การนำไปติดตั้งบน Raspberry Pi ก็จะต้องติดตั้ง Python และ easysnmp เพิ่มเติม ดังนี้

# pacman -S python gcc net-snmp python-pip
# pip install easysnmp

M5Stack

ถัดมาเราจะเขียนโปรแกรมสำหรับรับข้อมูลมาแสดงกัน ก็จะประมาณนี้

#include <M5Stack.h>
#include <WiFi.h>
#include <WiFiUdp.h>

const char *WIFI_SSID = "WHS-IoT";
const char *WIFI_PASSWORD = "";
const int SERVICE_PORT = 1;
const int MAX_DOWNLOAD = 35 * 1000000 / 8;
const int MAX_UPLOAD = 6 * 1000000 / 8;
const int GRAPH_WIDTH = 1;

typedef struct netPacket {
  unsigned int download;
  unsigned int upload;
} netPacket;

WiFiUDP udp;
netPacket data;
int graphPos = 0;
int graphOriginY, downloadHeight, uploadHeight;

#define LCD_WIDTH M5.Lcd.width()
#define LCD_HEIGHT M5.Lcd.height()
#define FONT_BASE 31

void wifiConnect(){
  int wifiStatus = WL_IDLE_STATUS;
  bool firstTry = true;

  while(wifiStatus != WL_CONNECTED){
    if(!firstTry){
      switch(wifiStatus){
        case WL_NO_SHIELD:
          M5.Lcd.println("E: No module");
          break;
         case WL_CONNECT_FAILED:
          M5.Lcd.println("E: Connection fail");
          break;
         case WL_CONNECTION_LOST:
          M5.Lcd.println("E: Connection lost");
          break;
         case WL_DISCONNECTED:
          M5.Lcd.print(".");
          break;
         default:
          M5.Lcd.printf("E: %d\n", wifiStatus);
      }
    }else{
      M5.Lcd.printf("Connecting to %s\n", WIFI_SSID);      
    }

    wifiStatus = WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
    delay(5000);
    firstTry = false;
  }
  M5.Lcd.println();
  M5.Lcd.println(WiFi.localIP());

  graphPos = 0;
}

void checkWifiConnected(){
  if(WiFi.status() != WL_CONNECTED){
    M5.Lcd.clearDisplay();
    M5.Lcd.setCursor(0, FONT_BASE);
    M5.Lcd.println("Wifi disconnected!");
    wifiConnect();
  }
}

void setup() {
  M5.begin();
  M5.Speaker.mute();
  M5.Lcd.setFont(&FreeSans12pt7b);
  M5.Lcd.setCursor(0, FONT_BASE);
  M5.Lcd.setTextColor(WHITE, BLACK);
  M5.Lcd.setRotation(1);
  M5.Lcd.setTextWrap(false);

  int titleSize = FONT_BASE * 2;
  graphOriginY = titleSize + ((LCD_HEIGHT - titleSize) * 3 / 4);
  downloadHeight = graphOriginY;
  uploadHeight = LCD_HEIGHT - graphOriginY;

  Serial.printf("Graph origin %d\nDownload height %d\nUpload height %d\n", graphOriginY, downloadHeight, uploadHeight);

  wifiConnect();
  udp.begin(SERVICE_PORT);
}

void loop() {
  checkWifiConnected();

  bool drawText = false;
  if(udp.parsePacket()){
    udp.read((char *) &data, sizeof(netPacket));

    Serial.printf("DL %d UL %d\n", data.download, data.upload);
    drawText = true;
  }
  M5.Lcd.fillRect(graphPos, FONT_BASE * 2, GRAPH_WIDTH, LCD_HEIGHT, BLACK);

  int downloadPercent = min(max(1, data.download * downloadHeight / MAX_DOWNLOAD), graphOriginY - (FONT_BASE * 2));
  M5.Lcd.fillRect(graphPos, graphOriginY - downloadPercent, GRAPH_WIDTH, downloadPercent, RED);

  int uploadPercent = max(1, data.upload * uploadHeight / MAX_UPLOAD);
  M5.Lcd.fillRect(graphPos, graphOriginY, GRAPH_WIDTH, uploadPercent, YELLOW);

  if(drawText) {
    M5.Lcd.fillRect(0, 0, LCD_WIDTH, FONT_BASE * 2, BLACK);
     M5.Lcd.setCursor(0, FONT_BASE);
     M5.Lcd.printf("DL %.2f kb/s\nUL %.2f kb/s", (float) data.download/1000.0, (float) data.upload/1000.0);
  }

  graphPos = graphPos + GRAPH_WIDTH;
  if(graphPos > LCD_WIDTH){
    graphPos = 0;
  }

  delay(100);
}

Testing

ทีนี้เราก็จะต้องเทสว่ากราฟของเราขยับได้จริง โดยทำให้เน็ตมันโหลดขึ้นมา วิธีง่ายสุดคงเป็น speedtest

ถ้ากราฟขึ้นมาแบบนี้ก็เป็นอันใช้ได้

(Note: ความเร็วที่แสดงเป็น Kilobytes/s หรือจะตรงกับความเร็วที่แสดงในโปรแกรมดาวน์โหลด แต่โปรแกรม speedtest ส่วนมากจะแสดงผลเป็น Kilobits/s ฉะนั้นจะต้องคูณ 8 จากเลขที่แสดงบนจอด้วย)

สุดท้ายก็ไปแปะหน้า Switch ตามภาพ (M5Stack มีแม่เหล็กในตัว ส่วน power ดึงมาจาก Raspberry Pi เลย) คราวนี้ก็รู้สึกหรูขึ้นมา 10 เท่าเลย 555

Real world use

ติดเจ้าอุปกรณ์นี้มาสักพักแล้วครับ ประสบการณ์ที่เจอมาก็คือ

  • เวลากลับบ้านมาจะเห็น data use ขึ้นสูงเลยเพราะมือถือจะชอบ sync เมื่อชาร์จ + ต่อไวไฟ
  • config VPN failover อยู่ พอจะไปนอนเหลือบไปเห็นกราฟว่า upload วิ่งเต็มตลอด เลยรู้ตัวว่า BGP loop อยู่
  • ชอบดูตอนดู Streaming ถ้าเป็นพวก YouTube จะเห็นว่ามันโหลดหนักแป๊บเดียวแล้วเงียบเลย แต่ถ้าเป็น Twitch มันจะเบาๆ แต่ขึ้นตลอด
  • เมื่อวานเจอ Dota 2 โหลดแพทช์อีกแล้ว คราวนี้ก็รู้แล้วว่าเน็ตใช้อยู่จริงๆ

Future Improvements

ที่คิดว่าอยากทำคือ

  • ตอนนี้โปรแกรมอ่านค่ามีบั๊ก ถ้าค่าที่อ่านได้เกิน uint max (2^32) มันจะ serialize แล้วส่งไปไม่ได้ อาจจะต้องปรับ data size หรือทำท่าอื่นๆ
  • อาจจะมีค่าอื่นๆ ที่น่าเอามาแสดงผลอีก ก็ใช้ปุ่มบนตัว M5Stack เลื่อนเปลี่ยนโปรแกรมได้
  • การวาดกราฟทับดูยาก น่าจะมีวิธีที่ดีกว่านี้ (ตอนแรกในหัวนึกว่ามันจะเท่เหมือนเครื่องพล็อตกราฟชีพจร แต่อันนั้นหัวมันจะสว่างกว่าปกติ)

ปรากฏว่าพ่อเดินมาบอกว่า Raspberry Pi ก็ซื้อจอไว้นะ เอา Model B ไปใช้แทน B+ แล้วถอด M5Stack ไปทำอย่างอื่นเถอะ T_T ลดสเปคทุกอย่างไม่พอแถมต้อง rewrite อีกด้วย

ปิดปั้มที่บ้านแบบ IoT Part 5: แจ้งเตือน และตั้งเวลา

จากตอนที่แล้วเราได้เซตให้ปิดปั้มเองเรียบร้อยแล้ว แต่ปัญหายังเหลืออยู่ว่าควรจะบอกด้วยว่าปิดปั้มแล้วเพื่อความชัวร์ (และใช้ debug เวลา VPN หลุดด้วย) และจะต้องต่อเวลาได้ในกรณีที่ซักผ้าอยู่ ในตอนสุดท้ายเราจะ ก็จะเพิ่มระบบ Notification เข้ามาครับ

โปรแกรม Home Assistant จะรองรับ Progressive Web App push ซึ่งสามารถ push ไปหา Chrome บน PC/Android ได้ และสำหรับ iOS จะต้องติดตั้งแอพ Home Assistant

สำหรับ PWA Push ทำดังนี้ครับ

  1. เข้าไปสมัคร Firebase Push Notification ที่ https://console.firebase.google.com
  2. เข้าไปที่หน้า project กดตรงประแจที่ Overview เลือก Cloud Messaging แล้วจด Server key กับ Sender ID ไว้

แล้วเพิ่ม config ว่า

ios:
notify:
  - platform: html5
    gcm_api_key: '...'
    gcm_sender_id: '...'

โดยระบุ gcm_api_key เป็น Server key ที่จดไว้ และ gcm_sender_id เป็น Sender ID ที่จดไว้

จากนั้นให้เอาอุปกรณ์เข้ามาที่หน้าเว็บของ Home assistant และ Add to home screen สำหรับ Android จากนั้นใน sidebar ให้เปิด Push Notification ซึ่งบน server จะเห็นมีไฟล์ html5_push_registrations.conf ปรากฏขึ้นมา ตรงนี้แนะนำให้ restart ทีนึง

สำหรับ iOS เมื่อเอาแอพเชื่อมต่อมาแล้วให้ restart Home Assistant ทีนึงก็ใช้ได้เลยครับ

จากนั้นเราจะเพิ่มกฎว่าถ้าไม่มีคนอยู่บ้านให้ push ทำได้ดังนี้ครับ

automation:
  - alias: Notify me when nobody's home
    trigger:
      platform: state
      entity_id: group.family
      from: home
      to: not_home
    action:
      - service: notify.html5_yui
        data:
          message: Nobody home!
      - service: notify.ios_my_love
        data:
          message: Nobody home!

โดยตรงชื่ออุปกรณ์ให้หาจากหน้า service ครับ (/dev-service)

ทดสอบแจ้งเตือนได้โดยกดที่ automation ที่เราสร้างมาใหม่แล้วกด Trigger ครับ จะได้แบบนี้

ตั้งเวลา

โจทย์ข้อสุดท้ายคือ สามารถตั้งเวลาปิดได้สำหรับเวลาซักผ้า ซึ่งเป็นปัญหาที่ผมนึกวิธีอยู่นานพอสมควร

วิธีที่ใช้คือ

  1. มี input slider component ตัวหนึ่งให้ลากตั้งเวลาได้
  2. ทุกๆ 1 นาที slider จะลดค่าลง 1
  3. เมื่อ slider เหลือ 0 นาที ให้ปิดปั้ม
  4. เมื่อ slider คือ -1 แสดงว่าไม่ใช้ เพื่อป้องกัน slider เหลือ 0 ค้าง หรือเมื่อต้องการยกเลิก timer
  5. ให้ trigger “Turn off pump when nobody’s home” ไม่ทำงานถ้า slider นี้ทำงานอยู่

config ก็ตามนี้ครับ

input_slider:
  pump_off_timer:
    name: Pump off timer (min)
    initial: -1
    min: -1
    max: 180
    step: 10
automation:
  - alias: Pump timer tick
    hide_entity: true
    trigger:
      platform: time
      seconds: 00
    condition:
      condition: and
      conditions:
        - condition: numeric_state
          entity_id: input_slider.pump_off_timer
          above: 0
    action:
      service: input_slider.select_value
      data_template:
        entity_id: input_slider.pump_off_timer
        value: '{{states("input_slider.pump_off_timer")|int - 1}}'
  - alias: Pump timer trigger
    hide_entity: true
    trigger:
      platform: numeric_state
      entity_id: input_slider.pump_off_timer
      above: 0
      below: 0
    condition:
      condition: and
      conditions:
        - condition: state
          entity_id: group.family
          state: not_home
    action:
      service: switch.turn_off
      data:
        entity_id: switch.pump

Trigger ชุดใหม่นี้จะไม่ขึ้นในเว็บครับ เนื่องจาก hide_entity: true แต่สามารถดูได้ในหน้า dev-state (ปุ่ม <> ใน sidebar ของ Home Assistant)

และให้แก้ trigger เดิมดังนี้

automation:
  - alias: Turn off pump when nobody's home
    initial_state: 'off'
    trigger:
      platform: state
      entity_id: group.family
      from: home
      to: not_home
    condition:
      condition: and
      conditions:
        - condition: numeric_state
          entity_id: input_slider.pump_off_timer
          below: -1
    action:
      service: switch.turn_off
      data:
        entity_id: switch.pump

โดยเพิ่ม condition เข้ามาว่า pump_off_timer จะต้องเป็น -1 จึงจะปิดปั้มให้อัตโนมัติ ถ้า timer ยังทำงานอยู่ก็จะไม่ปิด

ในหน้าเว็บจะปรากฏ slider ขึ้นมา ก็สามารถลากเอาได้เลยว่าจะปิดปั้มในกี่นาที

สรุป

ในบทความนี้เราก็ทำให้ปั้มในบ้านเรากลายเป็นอุปกรณ์ IoT สามารถเปิดปิดจากอินเทอร์เน็ตได้โดยคำนึงถึง security ด้วย และใช้ระบบอัตโนมัติทำให้เปิดปิดได้อัตโนมัติเพื่อประหยัดน้ำ โดยอุปกรณ์ทั้งหมดที่ใช้ต้นทุนก็ไม่ถึง 1 พันบาท (ไม่นับ server ซึ่งผมก็ใช้เปิดบล็อคที่ผู้อ่านกำลังเข้าอยู่แล้ว)

สำหรับใครที่จะลอง implement ตามก็แนะนำว่าสามารถข้ามพาร์ทที่ใช้ router ไปได้ครับ โดยใช้ Raspberry Pi ลง Home Assistant ไว้ในบ้านก็จะสามารถเซตตามได้เลยโดยไม่ต้องเซต VPN แต่ถ้าไม่มี IP จริงจะทำให้เข้าจากนอกบ้านได้ด้วยก็จะลำบากหน่อย

ก็ขอบคุณมากๆ ที่ติดตามกันมาถึงตอนสุดท้ายครับ Feedback ออกมาค่อนข้างดีมากๆ ถ้ามีคำแนะนำอะไรหรือสงสัยก็พูดคุยกันได้ในโพสต์ Facebook หรือใน Twitter ที่แชร์บล็อคนี้ครับ