ESP32 โครงการ #2 ส่งข้อมูลระดับน้ำ (Tide Station) ขึ้นคลาวด์ ThingSpeak ฟรี

โครงการที่สองยังเป็นโครงการใช้ไมโครคอนโทรลเลอร์เหมือนเดิม ผมเลือกใช้ ESP32 แทนที่จะเป็น Pico W เหมือนโครงการแรก และโปรแกรมด้วยภาษาซี บอร์ด ESP32 ผลิตโดยบริษัท Espressif จากจีนแผ่นดินใหญ่ และในวงการไมโครคอนโทรลเลอร์เองก็ยอมรับว่าไมโครคอนโทรลเลอร์ ESP32 ได้รับการออกแบบมาอย่างดี ซีพียูเร็วแกนคู่ กินพลังงานต่ำ มีแรมที่เพียงพอสำหรับการรันโปรแกรมสำงาน iOT ต่างๆได้และที่สำคัญคือมันมีราคาถูกมากๆ (หลักร้อยบาท)

ความเป็นมาของโครงการ

สถานีวัดระดับน้ำ (Tide Station) ใช้ของยี่ห้อ Valeport รุ่น Tide Master ตัวเครื่องวัดระดับน้ำไม่มีอะไรมาก มีเซ็นเซอร์วัดความดันของน้ำแล้วแปลงเป็นระดับน้ำ อุปกรณ์จะพ่วงกับอุปกรณ์อีกตัวคือ Scannex ตัวนี้รับข้อมูลจากเครื่องวัดระดับน้ำมาส่งเข้าคลาวด์ ตัวมันเองใส่ซิมโทรศัพท์ที่เช่าไว้แล้วค่าบริการประมาณเดือนละพันบาท

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

เลือกบอร์ดไมโครคอนโทรลเลอร์

สุดท้ายมองลงมาที่ไมโครคอนโทรลเลอร์ที่สามารถต่ออินเทอร์เน็ตได้ และรับข้อมูลระดับน้ำที่เป็นสายข้อมูลสตริงแบบอนุกรม UART ซึ่งเป็นคุณสมบัติพื้นฐานของไมโครคอนโทรลเลอร์อยู่แล้ว แล้วส่งข้อมูลขึ้นคลาวด์ ตอนแรกมองที่ตัว Pico W แต่ประสบการณ์ส่วนตัวที่เจอคือต่อไวไฟติดยาก ทั้งที่เอาไปใกล้ตัวเราเตอร์แล้วก็ยังไม่ดี เลยมองไปอีกตัวคือ Arduino Mega2560 Wireless R3 กับ ESP32 เมื่อลองโปรแกรมทดสอบแล้วชอบใจ ESP32 มากกว่า

คุณสมบัติของบอร์ด ESP32

  • ESP32 เป็นชิปที่ใช้พลังงานต่ำมากเมื่อเทียบกับไมโครคอนโทรลเลอร์อื่นๆ นอกจากนี้ ชิปนี้ยังรองรับโหมดพลังงานต่ำต่างๆ เช่น โหมดหลับลึก โหมดจำศีล
  • ESP32 รองรับทั้งบลูทูธพลังงานต่ำ (BLE) และบลูทูธแบบคลาสสิกดั้งเดิม ซึ่งเหมาะอย่างยิ่งสำหรับแอปพลิเคชัน IoT ต่างๆ
  • ESP32 ซีพียูส่วนใหญ่เป็นแกนคู่ โดยปกติจะมีไมโครโปรเซสเซอร์ Xtensa LX6 32 บิต 2 ตัว
  • ความเข้ากันได้ของสภาวะแวดล้อมในการพัฒนาโปรแกรมด้วย Arduino IDE ซึ่งเป็นที่ทราบกันดีว่าเป็นเครื่องมือยอดนิยมในการเขียนโปรแกรมสำหรับอุปกรณ์ไมโครคอนโทรลเลอร์ ซึ่งใช้ภาษาซีและซีพลัสพลัส
  • ESP32 สามารถเชื่อมต่อ ESP32 กับไวไฟเพื่อเชื่อมต่อกับ Access point อื่นๆได้อย่างง่ายดาย หรืออีกวิธีหนึ่งสามารถเป็น Access Point สำหรับให้อุปกรณ์อื่นๆเชื่อมต่อ
  • สามารถเขียนโปรแกรมด้วยภาษาไมโครไพทอน เป็นภาษาไพทอนรุ่นไมโครสำหรับอุปกรณ์ฝังตัวที่เน้นเขียนง่าย เร็ว มีประสิทธิภาพ
  • ESP32 รุ่นที่มีสวิตช์เสาอากาศในตัว มีสัญญาณรบกวนต่ำ
  • P32 รองรับอุปกรณ์ต่อพ่วงทั้งอินพุตและเอาต์พุตต่างๆ เช่น PWM รองรับหน้าจอสัมผัส ได้แก่ 2 × I2S, 2 × I2C, 3 × UART, 4 × SPI, 16 × PWM

ปัญหาด่านแรกที่แก้ไม่ตก

เมื่อเลือกบอร์ดแล้วก็ซื้อของอื่นๆเช่นหน้าจอ LCD, ตัวอ่านเขียน SD Card, หัวต่อ DB9 แบบ RS232 to TTL ตัวนี้ทำหน้าที่แปลงสัญญานระดับน้ำที่รับจาก Tide station แปลงสัญญานเป็น TTL ที่บอร์ดไมโครคอนโทรลเลอร์สามารถอ่านได้ ของทั้งหมดนำมาต่อกันแล้ว เขียนโปรแกรมด้วยภาษาซีเพื่อควบคุมบอร์ดให้ทำงาน ด่านแรกปัญหาที่เจอคือบอร์ดคอนโทรลเลอร์ไม่สามารถอ่านข้อมูลที่เป็นสายอักขระแบบ $PVTMA ได้ เมื่อนำชุดวงจรบอร์ดไมโครคอนโทรลเลอร์ไปต่อที่สถานีวัดระดับน้ำโดยตรง ทั้งๆที่เมื่อจำลองข้อมูลแบบเดียวกันต่อจากคอมพิวเตอร์ไปยังบอร์ดคอนโทรลเลอร์ก็ยังอ่านได้ดี เมื่อนำคอมพิวเตอร์ไปต่อกับสถานีน้ำผ่านโปรแกรมจำพวก Terminal ก็ยังอ่านข้อมูลได้

ไปค้นหาในฟอรั่มต่างๆพบว่าเป็นปัญหาจาก ground ที่ไม่เท่ากันของบอร์ดคอนโทรลเลอร์กับสถานีวัดระดับน้ำ ทำให้สัญญานเกิดการสะท้อน ประมาณนี้ แต่ก็ยังแก้ไม่ได้ เนื่องจากสถานีวัดระดับน้ำอยู่นอกสถานที่ อุปกรณ์ชุดนี้มีอยู่ชุดเดียว ไม่สามารถย้ายมายังสถานที่สะดวกเพื่อค้นหาปัญหาจริงๆได้ ก็เลยจนปัญญาที่จะแก้ไข

วิทยุลอร่า (Lora sx1276) ผู้มาช่วยคั่นกลาง

ผ่านเวลามาประมาณสองเดือนกว่า การใช้ข้อมูลจากสถานีวัดระดับน้ำเมื่อมีการทำ Bathymetric survey ครั้งใด ก็จะวิ่งไปที่สถานีวัดระดับน้ำเพื่อดาวน์โหลดข้อมูลระดับน้ำทุกครั้ง ทำให้เกิดความไม่สะดวกเป็นอย่างยิ่ง อยู่มาวันหนึ่งได้อ่านสเป็คของวิทยุลอร่าอย่างจริงจัง ที่ไม่ใช่เรื่องใหม่เลยมีการนำมาใช้กับอุปกรณ์หลายอย่าง โดยเฉพาะแวดวงงานสำรวจมีการนำมาใช้กับ RTK มานานแล้ว พบว่าระบบวิทยุนี้กินพลังงานต่ำส่งได้ไกล

ก็เลยสั่งซื้อเป็นของจีนแผ่นดินใหญ่มาสองชุด ยี่ห้อ Waveshare รุ่น sx1276 DTU HF เมื่อเห็นของต้องตกใจเพราะมันเล็กมาก ยาวไม่ถึงฝ่ามือ และเสาอากาศมีขนาดเล็กยาวไม่ถึงคืบ ก็สงสัยว่าจะรับส่งกันได้จริงหรือ ระยะทางจากสถานีวัดระดับน้ำมายังสำนักงานประมาณ 1.5 กม. มีสิ่งกีดขวางพอสมควรทั้งอาคาร เนื่องจากอยู่ในเขตนิคมอุตสาหกรรม ตามสเป็คแล้ววิทยุรุ่นนี้สามารถส่งได้ไกลถึง 5 กม.

ทดสอบการรับข้อมูลในเบื้องต้นปรากฎว่ารับส่งข้อมูลกันได้ จากนั้นนำวิทยุลอร่าไปต่อกับ Serial port ของเครื่องวัดระดับน้ำ และที่สำนักงานนำอุปกรณ์ชุดวงจรไมโครคอนโทรลเลอร์ที่เตรียมไว้อยู่แล้วต่อกับวิทยุลอร่า ปรากฏว่าข้อมูลสายอักขระระดับน้ำก็ไหลเข้าวงจรเป็นอย่างดี

อีกอย่างความสะดวกเมื่อชุดวงจรบอร์ดไมโครคอนโทรลเลอร์มาอยู่สำนักงานทำให้สามารถแก้ไขโปรแกรมแล้วส่งขึ้นบอร์ดได้ทันที

แผนผังวงจร (Wiring Diagram)

แผนผังวงจรเมื่อชุดวงจรบอร์ดไมโครคอนโทรลเลอร์ต่อกับวิทยุ Lora ผมใช้ Fritzing ในการวาดไดอะแกรม แต่เสียดายไม่มี Extension board ในไลบรารีของ Fritzing ก็เลยเอา breadboard มาแทน เพื่อให้เห็นการต่อสายไฟ 5V และ ground ได้ชัดเจน

สภาพแวดล้อมสำหรับการพัฒนาแบบเบ็ดเสร็จ (IDE)

เครื่องมือในการช่วยพัฒนาโปรแกรมใช้ Arduino IDE พัฒนาด้วยภาษาซี นำไลบรารีติดต่อกับจอ LCD, SD Card, WiFi และ Thingspeak ที่ท่านอื่นพัฒนาไว้ด้วยมาใช้งาน เนื่องจาก Arduino IDE สนับสนุนบอร์ดนานาชนิด ต้องเลือกบอร์ดให้ตรง ในที่นี้ผมเลือก “ESP32 Dev Module” บนตัวบอร์ดพวกนี้จะมีชิปแปลง USB เป็น Serial port เมื่อเสียบคอมพิวเตอร์แล้วเปิดใน device manager ของวินโดส์ก็จะเห็นหมายเลขพอร์ต ตั้งหมายเลขพอร์ตใน Arduino IDE ให้ตรง ก็จะมองเห็นกันพร้อมที่จะคอมไพล์โปรแกรมและ upload โปรแกรมเป็นไบนารีเข้าไปเก็บบนตัวบอร์ด เวลาทำงานกันจริง แค่เสียบสาย USB หรือสายไฟ DC บอร์ดก็จะเริ่มทำงานโดยที่บอร์ดจะอ่านไบนารีนั้นมา execute

ส่วนการดักจับข้อมูลสายอักขระจากวิทยุลอร่า ทำได้ง่ายๆจับทีละตัวอักษรแล้วมารวมเป็นคำ (word) ถ้าตรงกับรูปแบบประโยค NMEA ก็จะดำเนินการต่อจนจบประโยค พร้อมทั้ง checksum ว่าที่คำนวณกับที่ประโยคส่งมาตรงกันหรือไม่ ถ้าตรงกันก็หมายว่าอักขระอักษรประโยคนี้สมบูรณ์

$PVTMA,01,20240912,150400,2.270,0.009,m,6.045,13.485,0,3.410,,*16
$PVTMA,01,20240912,150500,2.272,0.008,m,6.045,13.485,0,3.412,,*16
$PVTMA,01,20240912,150600,2.276,0.009,m,6.045,13.492,0,3.415,,*11
$PVTMA,01,20240912,150700,2.279,0.011,m,6.045,13.485,0,3.419,,*1c
$PVTMA,01,20240912,150800,2.283,0.009,m,6.049,13.477,0,3.422,,*16
$PVTMA,01,20240912,150900,2.282,0.009,m,6.049,13.477,0,3.421,,*15
$PVTMA,01,20240912,151000,2.282,0.011,m,6.049,13.485,0,3.421,,*19
$PVTMA,01,20240912,151100,2.277,0.011,m,6.049,13.485,0,3.416,,*16
$PVTMA,01,20240912,151200,2.274,0.011,m,6.052,13.485,0,3.413,,*19
$PVTMA,01,20240912,151300,2.271,0.010,m,6.052,13.485,0,3.411,,*1e
$PVTMA,01,20240912,151400,2.270,0.012,m,6.052,13.485,0,3.409,,*13
$PVTMA,01,20240912,151500,2.266,0.012,m,6.052,13.485,0,3.406,,*1a
$PVTMA,01,20240912,151600,2.267,0.012,m,6.052,13.485,0,3.407,,*19
$PVTMA,01,20240912,151700,2.265,0.009,m,6.056,13.477,0,3.405,,*1b
$PVTMA,01,20240912,152000,2.271,0.013,m,6.056,13.492,0,3.411,,*1f
$PVTMA,01,20240912,152100,2.274,0.010,m,6.056,13.485,0,3.413,,*1c
$PVTMA,01,20240912,152200,2.279,0.010,m,6.056,13.499,0,3.419,,*15
$PVTMA,01,20240912,152300,2.284,0.011,m,6.056,13.485,0,3.424,,*14
$PVTMA,01,20240912,152400,2.292,0.010,m,6.056,13.485,0,3.431,,*11
$PVTMA,01,20240912,152500,2.297,0.010,m,6.056,13.485,0,3.437,,*13

การประมวลผลสายอักขระ NMEA ผมได้ดัดแปลงไลบรารี TingGPSPlus ที่คุณ Mikal Hart เขียนไว้ โดยเพิ่มประโยค $PVTMA, $SDDBT, $SDDPT เข้าไป สุดท้ายเปลี่ยนชื่อไลบรารีเป็น TinyNMEA

/*
TinyNMEA - a small GPS library for Arduino providing basic NMEA parsing
Based on work by and "distance_to" and "course_to" courtesy of Maarten Lamers.
Suggestion to add satellites(), course_to(), and cardinal(), by Matt Monson.
Precision improvements suggested by Wayne Holder.
Copyright (C) 2008-2013 Mikal Hart
All rights reserved.

This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.

This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
Lesser General Public License for more details.

You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
*/

#include "TinyNMEA.h"
//#include "Arduino.h"

#define _GPRMC_TERM   "GPRMC"
#define _GNRMC_TERM   "GNRMC"
#define _GPGGA_TERM   "GPGGA"
#define _GNGGA_TERM   "GNGGA"
#define _PVTMA_TERM	  "PVTMA"
#define _SDDBT_TERM   "SDDBT"

TinyNMEA::TinyNMEA()
  :  _time(GPS_INVALID_TIME)
  ,  _date(GPS_INVALID_DATE)
  ,  _latitude(GPS_INVALID_ANGLE)
  ,  _longitude(GPS_INVALID_ANGLE)
  ,  _altitude(GPS_INVALID_ALTITUDE)
  ,  _speed(GPS_INVALID_SPEED)
  ,  _course(GPS_INVALID_ANGLE)
  ,  _hdop(GPS_INVALID_HDOP)
  ,  _numsats(GPS_INVALID_SATELLITES)
  ,  _last_time_fix(GPS_INVALID_FIX_TIME)
  ,  _last_position_fix(GPS_INVALID_FIX_TIME)
  ,  _tid_id("000")
  ,  _tid_date("19700101")
  ,  _tid_time("000000")
  ,  _tid_height("-9999.99")
  ,  _tid_height_units("m")
  ,  _tid_height_stdev("0.0")
  ,  _tid_int_voltage("0.000")
  ,  _tid_ext_voltage("0.000")  
  ,  _es_depth_f(ES_INVALID_DEPTH_FEET)
  ,  _es_depth_m(ES_INVALID_DEPTH_METER)
  ,  _es_depth_F(ES_INVALID_DEPTH_FATHOM)  
  ,  _parity(0)
  ,  _is_checksum_term(false)
  ,  _sentence_type(_GPS_SENTENCE_OTHER)
  ,  _term_number(0)
  ,  _term_offset(0)
  ,  _gps_data_good(false)
#ifndef _GPS_NO_STATS
  ,  _encoded_characters(0)
  ,  _good_sentences(0)
  ,  _failed_checksum(0)
  ,  _passed_sentence("")
#endif
{
  _term[0] = '\0';
}

//
// public methods
//

bool TinyNMEA::encode(char c)
{
  bool valid_sentence = false;

#ifndef _GPS_NO_STATS
  ++_encoded_characters;
#endif

  switch(c)
  {
  case ',': // term terminators
    _parity ^= c;
    strcat_c(_passed_sentence, c);	
	//Serial.print("comma parity = ");
	//Serial.println(_parity);	
  case '\r':
  case '\n':
  case '*':
    if (_term_offset < sizeof(_term))
    {
      _term[_term_offset] = 0;
      valid_sentence = term_complete();
    }
    ++_term_number;
    _term_offset = 0;
    _is_checksum_term = c == '*';
	if (c == '*')
	  strcat_c(_passed_sentence, c);	
    return valid_sentence;

  case '$': // sentence begin
    _term_number = _term_offset = 0;
	_passed_sentence[0] = '$';
	_passed_sentence[1] = '\0';	
    _parity = 0;
    _sentence_type = _GPS_SENTENCE_OTHER;
    _is_checksum_term = false;
    _gps_data_good = false;
    return valid_sentence;
  }

  // ordinary characters
  strcat_c(_passed_sentence, c);	
  if (_term_offset < sizeof(_term) - 1)
    _term[_term_offset++] = c;
  if (!_is_checksum_term){
    _parity ^= c;
	/*Serial.print("char = ");
	Serial.print(c);
	Serial.print(" parity = ");
	Serial.println(_parity);*/
  }
  return valid_sentence;
}

#ifndef _GPS_NO_STATS
void TinyNMEA::stats(unsigned long *chars, unsigned short *sentences, unsigned short *failed_cs)
{
  if (chars) *chars = _encoded_characters;
  if (sentences) *sentences = _good_sentences;
  if (failed_cs) *failed_cs = _failed_checksum;
}
#endif

//
// internal utilities
//
int TinyNMEA::from_hex(char a) 
{
  if (a >= 'A' && a <= 'F')
    return a - 'A' + 10;
  else if (a >= 'a' && a <= 'f')
    return a - 'a' + 10;
  else
    return a - '0';
}

unsigned long TinyNMEA::parse_decimal()
{
  char *p = _term;
  bool isneg = *p == '-';
  if (isneg) ++p;
  unsigned long ret = 100UL * gpsatol(p); //edited by pbr
  while (gpsisdigit(*p)) ++p;
  if (*p == '.')
  {
    if (gpsisdigit(p[1]))
    {
      ret += 10 * (p[1] - '0');
      if (gpsisdigit(p[2])){
        ret += p[2] - '0';
	  }
    }
  }
  return isneg ? -ret : ret;
}

// Parse a string in the form ddmm.mmmmmmm...
unsigned long TinyNMEA::parse_degrees()
{
  char *p;
  unsigned long left_of_decimal = gpsatol(_term);
  unsigned long hundred1000ths_of_minute = (left_of_decimal % 100UL) * 100000UL;
  for (p=_term; gpsisdigit(*p); ++p);
  if (*p == '.')
  {
    unsigned long mult = 10000;
    while (gpsisdigit(*++p))
    {
      hundred1000ths_of_minute += mult * (*p - '0');
      mult /= 10;
    }
  }
  return (left_of_decimal / 100) * 1000000 + (hundred1000ths_of_minute + 3) / 6;
}

#define COMBINE(sentence_type, term_number) (((unsigned)(sentence_type) << 5) | term_number)

// Processes a just-completed term
// Returns true if new sentence has just passed checksum test and is validated
bool TinyNMEA::term_complete()
{
  if (_is_checksum_term)
  {
    byte checksum = 16 * from_hex(_term[0]) + from_hex(_term[1]);
	/*Serial.print("calculated parity = ");
	Serial.println(_parity);
	Serial.print("sentence checksum = ");
	Serial.println(checksum);*/
    if (checksum == _parity)
    {
      if (_gps_data_good)
      {
		//Serial.print("passed sentence = ");
	    //Serial.println(_passed_sentence);	  
#ifndef _GPS_NO_STATS
        ++_good_sentences;
#endif
        _last_time_fix = _new_time_fix;
        _last_position_fix = _new_position_fix;
		//Serial.print("sentence = ");
		//Serial.println(_sentence_type);
        switch(_sentence_type)
        {
        case _GPS_SENTENCE_GPRMC:
          _time      = _new_time;
          _date      = _new_date;
          _latitude  = _new_latitude;
          _longitude = _new_longitude;
          _speed     = _new_speed;
          _course    = _new_course;
          break;
        case _GPS_SENTENCE_GPGGA:
          _altitude  = _new_altitude;
          _time      = _new_time;
          _latitude  = _new_latitude;
          _longitude = _new_longitude;
          _numsats   = _new_numsats;
          _hdop      = _new_hdop;
          break;
        case _TIDE_SENTENCE_PVTMA:
		  *_tid_id = '\0';
		  strncat(_tid_id, _new_tid, sizeof(_new_tid));
		  *_tid_date = '\0';
		  strncat(_tid_date, _new_tdate, sizeof(_new_tdate));
		  *_tid_time = '\0';
		  strncat(_tid_time, _new_ttime, sizeof(_new_ttime));
		  *_tid_height = '\0';
		  strncat(_tid_height, _new_theight, sizeof(_new_theight));
		  *_tid_height_units = '\0';
		  strncat(_tid_height_units, _new_theight_units, sizeof(_new_theight_units));
		  *_tid_height_stdev = '\0';
		  strncat(_tid_height_stdev, _new_theight_stdev, sizeof(_new_theight_stdev));
		  *_tid_int_voltage = '\0';
		  strncat(_tid_int_voltage, _new_tint_voltage, sizeof(_new_tint_voltage));
		  *_tid_ext_voltage = '\0';
		  strncat(_tid_ext_voltage, _new_text_voltage, sizeof(_new_text_voltage));
          break;
		case _ES_SENTENCE_SDDBT:
		  _es_depth_f = _new_es_depth_f;
		  _es_depth_m = _new_es_depth_m;
		  _es_depth_F = _new_es_depth_F;
		  break;
        }

        return true;
      }
    }

#ifndef _GPS_NO_STATS
    else
      ++_failed_checksum;
#endif
    return false;
  }

  // the first term determines the sentence type
  if (_term_number == 0)
  {
    if (!gpsstrcmp(_term, _GPRMC_TERM) || !gpsstrcmp(_term, _GNRMC_TERM))
      _sentence_type = _GPS_SENTENCE_GPRMC;
    else if (!gpsstrcmp(_term, _GPGGA_TERM) || !gpsstrcmp(_term, _GNGGA_TERM))
      _sentence_type = _GPS_SENTENCE_GPGGA;
    else if (!gpsstrcmp(_term, _PVTMA_TERM))
      _sentence_type = _TIDE_SENTENCE_PVTMA;  
    else if (!gpsstrcmp(_term, _SDDBT_TERM))
      _sentence_type = _ES_SENTENCE_SDDBT;    
    else
      _sentence_type = _GPS_SENTENCE_OTHER;
    return false;
  }

  if (_sentence_type != _GPS_SENTENCE_OTHER && _term[0])
    switch(COMBINE(_sentence_type, _term_number))
  {
    case COMBINE(_GPS_SENTENCE_GPRMC, 1): // Time in both sentences
    case COMBINE(_GPS_SENTENCE_GPGGA, 1):
      _new_time = parse_decimal();
      _new_time_fix = millis();
      break;
    case COMBINE(_GPS_SENTENCE_GPRMC, 2): // GPRMC validity
      _gps_data_good = _term[0] == 'A';
      break;
    case COMBINE(_GPS_SENTENCE_GPRMC, 3): // Latitude
    case COMBINE(_GPS_SENTENCE_GPGGA, 2):
      _new_latitude = parse_degrees();
      _new_position_fix = millis();
      break;
    case COMBINE(_GPS_SENTENCE_GPRMC, 4): // N/S
    case COMBINE(_GPS_SENTENCE_GPGGA, 3):
      if (_term[0] == 'S')
        _new_latitude = -_new_latitude;
      break;
    case COMBINE(_GPS_SENTENCE_GPRMC, 5): // Longitude
    case COMBINE(_GPS_SENTENCE_GPGGA, 4):
      _new_longitude = parse_degrees();
      break;
    case COMBINE(_GPS_SENTENCE_GPRMC, 6): // E/W
    case COMBINE(_GPS_SENTENCE_GPGGA, 5):
      if (_term[0] == 'W')
        _new_longitude = -_new_longitude;
      break;
    case COMBINE(_GPS_SENTENCE_GPRMC, 7): // Speed (GPRMC)
      _new_speed = parse_decimal();
      break;
    case COMBINE(_GPS_SENTENCE_GPRMC, 8): // Course (GPRMC)
      _new_course = parse_decimal();
      break;
    case COMBINE(_GPS_SENTENCE_GPRMC, 9): // Date (GPRMC)
      _new_date = gpsatol(_term);
      break;
    case COMBINE(_GPS_SENTENCE_GPGGA, 6): // Fix data (GPGGA)
      _gps_data_good = _term[0] > '0';
      break;
    case COMBINE(_GPS_SENTENCE_GPGGA, 7): // Satellites used (GPGGA)
      _new_numsats = (unsigned char)atoi(_term);
      break;
    case COMBINE(_GPS_SENTENCE_GPGGA, 8): // HDOP
      _new_hdop = parse_decimal();
      break;
    case COMBINE(_GPS_SENTENCE_GPGGA, 9): // Altitude (GPGGA)
      _new_altitude = parse_decimal();
      break;
    case COMBINE(_TIDE_SENTENCE_PVTMA, 1): // Tide ID
	  *_new_tid = '\0';
	  strncat(_new_tid, _term, sizeof(_new_tid));	  
      break;
    case COMBINE(_TIDE_SENTENCE_PVTMA, 2): // Tide date
	  *_new_tdate = '\0';
	  strncat(_new_tdate, _term, sizeof(_new_tdate));		    
      break;
    case COMBINE(_TIDE_SENTENCE_PVTMA, 3): // Tide time
	  *_new_ttime = '\0';
	  strncat(_new_ttime, _term, sizeof(_new_ttime));
      break;
    case COMBINE(_TIDE_SENTENCE_PVTMA, 4): // Tide height
	  *_new_theight = '\0';
	  strncat(_new_theight, _term, sizeof(_new_theight));
      break;
    case COMBINE(_TIDE_SENTENCE_PVTMA, 5): // Tide height stdev
      *_new_theight_stdev = '\0';
	  strncat(_new_theight_stdev, _term, sizeof(_new_theight_stdev));
      break;
    case COMBINE(_TIDE_SENTENCE_PVTMA, 6): // Tide height units
      *_new_theight_units = '\0';
	  strncat(_new_theight_units, _term, sizeof(_new_theight_units));
      break;	  
    case COMBINE(_TIDE_SENTENCE_PVTMA, 7): // Tide internal voltate
      *_new_tint_voltage = '\0';
	  strncat(_new_tint_voltage, _term, sizeof(_new_tint_voltage));
      break;
    case COMBINE(_TIDE_SENTENCE_PVTMA, 8): // Tide external voltate
      *_new_text_voltage = '\0';
	  strncat(_new_text_voltage, _term, sizeof(_new_text_voltage));
	  _gps_data_good = true;	  
      break;
    case COMBINE(_ES_SENTENCE_SDDBT, 1): //Depth in feet
	  //Serial.print(" _term = ");
	  //Serial.println(_term);
	  _new_es_depth_f = strtod(_term, NULL);
	  //Serial.print(" _new = ");
	  //Serial.println(_new_es_depth_f);
	  break;
    case COMBINE(_ES_SENTENCE_SDDBT, 3): //Depth in meter
	  _new_es_depth_m = strtod(_term, NULL);
	  _gps_data_good = true;	
	  break;
	case COMBINE(_ES_SENTENCE_SDDBT, 5): //Depth in fathom
	  _new_es_depth_F = strtod(_term, NULL);
	  break;
  }

  return false;
}

long TinyNMEA::gpsatol(const char *str)
{
  long ret = 0;
  while (gpsisdigit(*str))
    ret = 10 * ret + *str++ - '0';
  return ret;
}

int TinyNMEA::gpsstrcmp(const char *str1, const char *str2)
{
  while (*str1 && *str1 == *str2)
    ++str1, ++str2;
  return *str1;
}

/* static */
float TinyNMEA::distance_between (float lat1, float long1, float lat2, float long2) 
{
  // returns distance in meters between two positions, both specified 
  // as signed decimal-degrees latitude and longitude. Uses great-circle 
  // distance computation for hypothetical sphere of radius 6372795 meters.
  // Because Earth is no exact sphere, rounding errors may be up to 0.5%.
  // Courtesy of Maarten Lamers
  float delta = radians(long1-long2);
  float sdlong = sin(delta);
  float cdlong = cos(delta);
  lat1 = radians(lat1);
  lat2 = radians(lat2);
  float slat1 = sin(lat1);
  float clat1 = cos(lat1);
  float slat2 = sin(lat2);
  float clat2 = cos(lat2);
  delta = (clat1 * slat2) - (slat1 * clat2 * cdlong); 
  delta = sq(delta); 
  delta += sq(clat2 * sdlong); 
  delta = sqrt(delta); 
  float denom = (slat1 * slat2) + (clat1 * clat2 * cdlong); 
  delta = atan2(delta, denom); 
  return delta * 6372795; 
}

float TinyNMEA::course_to (float lat1, float long1, float lat2, float long2) 
{
  // returns course in degrees (North=0, West=270) from position 1 to position 2,
  // both specified as signed decimal-degrees latitude and longitude.
  // Because Earth is no exact sphere, calculated course may be off by a tiny fraction.
  // Courtesy of Maarten Lamers
  float dlon = radians(long2-long1);
  lat1 = radians(lat1);
  lat2 = radians(lat2);
  float a1 = sin(dlon) * cos(lat2);
  float a2 = sin(lat1) * cos(lat2) * cos(dlon);
  a2 = cos(lat1) * sin(lat2) - a2;
  a2 = atan2(a1, a2);
  if (a2 < 0.0)
  {
    a2 += TWO_PI;
  }
  return degrees(a2);
}

const char *TinyNMEA::cardinal (float course)
{
  static const char* directions[] = {"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"};

  int direction = (int)((course + 11.25f) / 22.5f);
  return directions[direction % 16];
}

// lat/long in MILLIONTHs of a degree and age of fix in milliseconds
// (note: versions 12 and earlier gave this value in 100,000ths of a degree.
void TinyNMEA::get_position(long *latitude, long *longitude, unsigned long *fix_age)
{
  if (latitude) *latitude = _latitude;
  if (longitude) *longitude = _longitude;
  if (fix_age) *fix_age = _last_position_fix == GPS_INVALID_FIX_TIME ? 
   GPS_INVALID_AGE : millis() - _last_position_fix;
}

// date as ddmmyy, time as hhmmsscc, and age in milliseconds
void TinyNMEA::get_datetime(unsigned long *date, unsigned long *time, unsigned long *age)
{
  if (date) *date = _date;
  if (time) *time = _time;
  if (age) *age = _last_time_fix == GPS_INVALID_FIX_TIME ? 
   GPS_INVALID_AGE : millis() - _last_time_fix;
}

void TinyNMEA::f_get_position(float *latitude, float *longitude, unsigned long *fix_age)
{
  long lat, lon;
  get_position(&lat, &lon, fix_age);
  *latitude = lat == GPS_INVALID_ANGLE ? GPS_INVALID_F_ANGLE : (lat / 1000000.0);
  *longitude = lat == GPS_INVALID_ANGLE ? GPS_INVALID_F_ANGLE : (lon / 1000000.0);
}

void TinyNMEA::crack_datetime(int *year, byte *month, byte *day, 
  byte *hour, byte *minute, byte *second, byte *hundredths, unsigned long *age)
{
  unsigned long date, time;
  get_datetime(&date, &time, age);
  if (year) 
  {
    *year = date % 100;
    *year += *year > 80 ? 1900 : 2000;
  }
  if (month) *month = (date / 100) % 100;
  if (day) *day = date / 10000;
  if (hour) *hour = time / 1000000;
  if (minute) *minute = (time / 10000) % 100;
  if (second) *second = (time / 100) % 100;
  if (hundredths) *hundredths = time % 100;
}

float TinyNMEA::f_altitude()    
{
  return _altitude == GPS_INVALID_ALTITUDE ? GPS_INVALID_F_ALTITUDE : _altitude / 100.0;
}

float TinyNMEA::f_course()
{
  return _course == GPS_INVALID_ANGLE ? GPS_INVALID_F_ANGLE : _course / 100.0;
}

float TinyNMEA::f_speed_knots() 
{
  return _speed == GPS_INVALID_SPEED ? GPS_INVALID_F_SPEED : _speed / 100.0;
}

float TinyNMEA::f_speed_mph()   
{ 
  float sk = f_speed_knots();
  return sk == GPS_INVALID_F_SPEED ? GPS_INVALID_F_SPEED : _GPS_MPH_PER_KNOT * sk; 
}

float TinyNMEA::f_speed_mps()   
{ 
  float sk = f_speed_knots();
  return sk == GPS_INVALID_F_SPEED ? GPS_INVALID_F_SPEED : _GPS_MPS_PER_KNOT * sk; 
}

float TinyNMEA::f_speed_kmph()  
{ 
  float sk = f_speed_knots();
  return sk == GPS_INVALID_F_SPEED ? GPS_INVALID_F_SPEED : _GPS_KMPH_PER_KNOT * sk; 
}

void TinyNMEA::get_passed_sentence(char *sentence)
{
  *sentence = '\0';
  strncat(sentence, _passed_sentence, sizeof(_passed_sentence));
}

//Tide NMEA

void TinyNMEA::get_tide_id(char *tid)
{
  *tid = '\0';
  strncat(tid, _tid_id, sizeof(_tid_id));
}

void TinyNMEA::get_tide_date(char *date)
{
  //strncat safer than strncpy
  *date = '\0';
  strncat(date, _tid_date, sizeof(_tid_date)); 
}

void TinyNMEA::get_tide_time(char *time)
{
  *time = '\0';
  strncat(time, _tid_time, sizeof(_tid_time));  
}

void TinyNMEA::get_tide_height(char *height)
{
  *height = '\0';
  strncat(height, _tid_height, sizeof(_tid_height));   
}

void TinyNMEA::get_tide_height_units(char *units)
{
  *units = '\0';
  strncat(units, _tid_height_units, sizeof(_tid_height_units));     
}

void TinyNMEA::get_tide_height_stdev(char *stdev)
{
  *stdev = '\0';
  strncat(stdev, _tid_height_stdev, sizeof(_tid_height_stdev));  
}

void TinyNMEA::get_tide_int_voltage(char *intvolt)
{
  *intvolt = '\0';
  strncat(intvolt, _tid_int_voltage, sizeof(_tid_int_voltage));   
}

void TinyNMEA::get_tide_ext_voltage(char *extvolt)
{
  *extvolt = '\0';
  strncat(extvolt, _tid_ext_voltage, sizeof(_tid_ext_voltage));     
}

float TinyNMEA::es_depth_f(){
  return _es_depth_f;
}

float TinyNMEA::es_depth_m(){
  return _es_depth_m;
}

float TinyNMEA::es_depth_F() {
  return _es_depth_F;
}

const float TinyNMEA::GPS_INVALID_F_ANGLE = 1000.0;
const float TinyNMEA::GPS_INVALID_F_ALTITUDE = 1000000.0;
const float TinyNMEA::GPS_INVALID_F_SPEED = -1.0;

//by pbr, copied from stackoverflow.com
void TinyNMEA::strcat_c (char *str, char c)
{
  for (;*str;str++); // note the terminating semicolon here. 
  *str++ = c; 
  *str++ = 0;
}

โครงการคลาวด์ ThingSpeak

ThingSpeak เป็นเว็บแอปพลิเคชันที่ให้บริการเก็บข้อมูลบนคลาวด์จากข้อมูลเซ็นเซอร์ผ่านบอร์ดไมโครคอนโทรลเลอร์แบบต่อเนื่องตามเวลา ที่อนุญาตให้เราสามารถส่งค่าต่างๆขึ้นเพื่อไปเก็บบนพื้นที่ ที่เปิดให้เราใช้บริการ และยังเปิดให้เราสามารถเข้าถึงข้อมูลเหล่านี้ได้จากเว็บบราวเซอร์ทั่วๆไปจากที่ไหนก็ได้ที่มี Internet หรือจากแอพบนโทรศัพท์มือถือก็ได้ จึงนับเป็นการเข้าถึงข้อมูลในรูปแบบ IOT (Internet of Things) นั่นเอง

ก่อนอื่น เราต้องสมัครใช้งาน ThingSpeak โดยเข้าไปที่ https://thingspeak.com/ เสียก่อน แล้วกดที่ปุ่ม “Get Started For Free” เพื่อสมัครใช้งาน กรอกข้อมูลเพื่อลงทะเบียนเปิดใช้งาน เมื่อทำการสมัครสมาชิกเสร็จสิ้น ทำการ Sign In เพื่อทำการสร้าง Channel โดยให้เรากดไปที่ My Channel แล้วเลือกสร้าง Channel ใหม่ และจดหมายเลข User ID, Channel ID ตลอดจน API Key เพื่อนำมาป้อนในโปรแกรม การสร้าง Channel ค้นอ่านได้ในอินเทอร์เน็ตมีคนเขียนไว้ละเอียดทั้งภาษาไทยและภาษาอังกฤษ

ThingSpeak มีให้บริการทั้งในรูปแบบมีค่าใช้จ่าย และไม่มีค่าใช้จ่าย ซึ่งสำหรับการบริการแบบไม่มีค่าใช้จ่ายนั้น ผู้ให้บริการได้จำกัดการบันทึกข้อมูลไว้ด้วยอัตราสูงสุด 1 ครั้ง ในช่วงเวลา 15 วินาที

สำหรับโครงการนี้จะส่งค่าระดับน้ำจากสถานีน้ำขึ้นคลาวด์ของ Thingspeak ทุกๆนาที จำนวน 8 ฟิลด์ได้แก่ ไอดีสถานีระดับน้ำ, วันที่, เวลา, ระดับน้ำ, หน่วย, ค่าเบี่ยงเบนมาตรฐาน, แรงดันไฟฟ้าภายใน, แรงดันไฟฟ้าภายนอก เนื่องจากสถานีวัดระดับน้ำส่งข้อมูลออกทางสาย Serial 1 นาทีต่อครั้ง จึงเข้ากับข้อกำหนดใช้ฟรี

โค้ดโปรแกรมหลัก

โค้ดโปรแกรมหลัก ก็ตามสไตล์ของบอร์ด Arduino คือมีฟังก์ชันสองฟังก์ชันคือ setup() กับ loop() ไม่มีอะไรยาก เพียงแต่ภาษาซีมันเป็นยาขมไปหน่อย สำหรับผมแล้วไม่เก่งภาษานี้แต่พอเอาตัวรอดได้

#include "FS.h"
#include "SD.h"
#include "SPI.h"
#include <LiquidCrystal_I2C.h>
#include <WiFi.h>
#include "time.h"
#include <ThingSpeak.h>
#include <TinyNMEA.h>
//#include <NTPClient.h>
//#include <WiFiUdp.h>
//Serial UART connect to Arduino Mega 2560
#define RXD2 16
#define TXD2 17

//Connect to SD Card
//MicroSD card module	ESP32
//5V	    5V
//CS	    GPIO 5
//MOSI	  GPIO 23
//CLK	    GPIO 18
//MISO	  GPIO 19
//GND	    GND

//connect to LCD LiquidCrystal I2C
//I2C LCD	  ESP32
//GND	      GND
//VCC	      5V
//SDA	      GPIO 21
//SCL	      GPIO 22

char filename[13] = "00000000.txt";
File dataFile;

// Define NTP Client to get time
//WiFiUDP ntpUDP;
//NTPClient timeClient(ntpUDP);

// Variables to save date and time
String formattedDate;
String dayStamp;
String timeStamp;

const char *ssid = "TP-Link_14E0";
const char *password = "CBA87654321bca";

WiFiClient client;

const char *ntpServer = "pool.ntp.org";
const long gmtOffset_sec = 0;
const int daylightOffset_sec = 3600;

// set the LCD number of columns and rows
int lcdColumns = 20;
int lcdRows = 4;

//ThingSpeak
unsigned long myChannelNumber = 1111111; //use your own user id
const char *myWriteAPIKey = "*******************"; // use your own API key

// Timer variables
unsigned long lastTime = 0;
unsigned long timerDelay = 30000;

// set LCD address, number of columns and rows
// if you don't know your display address, run an I2C scanner sketch
LiquidCrystal_I2C lcd(0x27, lcdColumns, lcdRows);
String messageStatic = "ESP32 Tide Master";
String messageToScroll = "This is a ESP32 Microcontroller for Tide Station.";

//TinyNMEA by prajuab riabroy, original by Mikal Heart
TinyNMEA tide;

void setup() {
  Serial.begin(115200);
  Serial2.begin(9600, SERIAL_8N1, RXD2, TXD2);  // Serial2.begin(38400) => not work
  lcd.init();
  lcd.backlight();
  lcd.setCursor(0, 0);
  lcd.print(messageStatic);
  //scrollText(1, messageToScroll, 250, lcdColumns);
  // Connect to Wi-Fi
  Serial.print("Connecting to ");
  Serial.println(ssid);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("");
  Serial.println("WiFi connected.");
  lcd.setCursor(0, 1);
  lcd.print("Wifi Connected.    ");

  // Init and get the time
  configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
  printLocalTime();
  initSDCard();
  ThingSpeak.begin(client);  // Initialize ThingSpeak
}

void loop() {
  bool newData = false;
  // Connect or reconnect to WiFi
  if (WiFi.status() != WL_CONNECTED) {
    Serial.print("Attempting to connect");
    lcd.setCursor(0, 1);
    lcd.print("Wifi Not Connected.");
    while (WiFi.status() != WL_CONNECTED) {
      WiFi.begin(ssid, password);
      delay(5000);
    }
    Serial.println("\nConnected.");
    lcd.setCursor(0, 1);
    lcd.print("Wifi Connected.    ");
  }

  // set cursor to first column, first row
  //lcd.setCursor(0, 0);
  //lcd.print(messageStatic);
  //scrollText(1, messageToScroll, 250, lcdColumns);
  //getfilename();
  //createfilename();
  while (Serial2.available()) {
    char c = Serial2.read();
    //Serial.print("char =");
    //Serial.println(c);
    if (tide.encode(c)) {
      newData = true;
    }
    if (newData) {
      float flat, flon;
      char tid[9];
      char date[11], time[9], height[7], units[2], stdev[7], intvolt[7], extvolt[7];
      long age;
      //myFile = SD.open("gnss.txt", FILE_WRITE);  // เปิดไฟล์ที่ชื่อ test.txt เพื่อเขียนข้อมูล โหมด FILE_WRITE
      tide.get_tide_id(tid);
      Serial.print(" Tide ID=");
      Serial.println(tid);
      tide.get_tide_date(date);
      Serial.print(" Tide Date=");
      Serial.println(date);
      tide.get_tide_time(time);
      Serial.print(" Tide Time=");
      Serial.println(time);
      tide.get_tide_height(height);
      Serial.print(" Tide Height=");
      Serial.println(height);
      tide.get_tide_height_units(units);
      Serial.print(" Tide Height Units=");
      Serial.println(units);
      tide.get_tide_height_stdev(stdev);
      Serial.print(" Tide Height Stdev=");
      Serial.println(stdev);
      tide.get_tide_int_voltage(intvolt);
      Serial.print(" Tide Internal Voltage=");
      Serial.println(intvolt);
      tide.get_tide_ext_voltage(extvolt);
      Serial.print(" Tide External Voltage=");
      Serial.println(extvolt);
      lcdPrintTide(date, time, height);

      // Write to ThingSpeak. There are up to 8 fields in a channel, allowing you to store up to 8 different
      // pieces of information in a channel.  Here, we write to field 1.

      ThingSpeak.setField(1, tid);
      ThingSpeak.setField(2, date);
      ThingSpeak.setField(3, time);
      float h = strtod(height, NULL);
      char str[16];
      snprintf(str, sizeof(str), "%.3f", h);      
      ThingSpeak.setField(4, str);  //display "NaN" if uncomment.
      ThingSpeak.setField(5, units);
      ThingSpeak.setField(6, stdev);
      ThingSpeak.setField(7, intvolt);
      ThingSpeak.setField(8, extvolt);
      if (WiFi.status() == WL_CONNECTED) {
        // ปัญหาเมื่อส่งเป็น string ไปเว็บดันต่อท้ายต่อ m ต่อท้ายสตริงให้ ทำให้แสดงผล widget "NaN"
        // ต้องบังคับส่งเป็น float ถึงแก้ปัญหาได้
        /*int i = ThingSpeak.writeField(myChannelNumber, 1, tid, myWriteAPIKey);        
        i = ThingSpeak.writeField(myChannelNumber, 2, date, myWriteAPIKey);        
        i = ThingSpeak.writeField(myChannelNumber, 3, time, myWriteAPIKey); 
        i = ThingSpeak.writeField(myChannelNumber, 4, h, myWriteAPIKey);           
        i = ThingSpeak.writeField(myChannelNumber, 5, units, myWriteAPIKey);        
        i = ThingSpeak.writeField(myChannelNumber, 6, stdev, myWriteAPIKey);
        i = ThingSpeak.writeField(myChannelNumber, 7, intvolt, myWriteAPIKey);        
        i = ThingSpeak.writeField(myChannelNumber, 8, extvolt, myWriteAPIKey); */
        int x = ThingSpeak.writeFields(myChannelNumber, myWriteAPIKey);
        if (x == 200) {
          Serial.println("Channel update successful.");
        } else {
          Serial.println("Problem updating channel. HTTP error code " + String(x));
        }
        //}
        //myFile.close();
      }
      newData = false;
    }
  }
}

void initSDCard() {
  if (!SD.begin(5)) {
    Serial.println("Card Mount Failed");
    return;
  }
  uint8_t cardType = SD.cardType();

  if (cardType == CARD_NONE) {
    Serial.println("No SD card attached");
    return;
  }

  Serial.print("SD Card Type: ");
  if (cardType == CARD_MMC) {
    Serial.println("MMC");
  } else if (cardType == CARD_SD) {
    Serial.println("SDSC");
  } else if (cardType == CARD_SDHC) {
    Serial.println("SDHC");
  } else {
    Serial.println("UNKNOWN");
  }

  uint64_t cardSize = SD.cardSize() / (1024 * 1024);
  Serial.printf("SD Card Size: %lluMB\n", cardSize);

  listDir(SD, "/", 0);
  createDir(SD, "/mydir");
  listDir(SD, "/", 0);
  removeDir(SD, "/mydir");
  listDir(SD, "/", 2);
  writeFile(SD, "/hello.txt", "Hello ");
  appendFile(SD, "/hello.txt", "World!\n");
  readFile(SD, "/hello.txt");
  deleteFile(SD, "/foo.txt");
  renameFile(SD, "/hello.txt", "/foo.txt");
  readFile(SD, "/foo.txt");
  testFileIO(SD, "/test.txt");
  Serial.printf("Total space: %lluMB\n", SD.totalBytes() / (1024 * 1024));
  Serial.printf("Used space: %lluMB\n", SD.usedBytes() / (1024 * 1024));
}

void printLocalTime() {
  struct tm timeinfo;
  if (!getLocalTime(&timeinfo)) {
    Serial.println("Failed to obtain time");
    return;
  }
  Serial.println(&timeinfo, "%A, %B %d %Y %H:%M:%S");
  Serial.print("Day of week: ");
  Serial.println(&timeinfo, "%A");
  Serial.print("Month: ");
  Serial.println(&timeinfo, "%B");
  Serial.print("Day of Month: ");
  Serial.println(&timeinfo, "%d");
  Serial.print("Year: ");
  Serial.println(&timeinfo, "%Y");
  Serial.print("Hour: ");
  Serial.println(&timeinfo, "%H");
  Serial.print("Hour (12 hour format): ");
  Serial.println(&timeinfo, "%I");
  Serial.print("Minute: ");
  Serial.println(&timeinfo, "%M");
  Serial.print("Second: ");
  Serial.println(&timeinfo, "%S");

  Serial.println("Time variables");
  char timeHour[3];
  strftime(timeHour, 3, "%H", &timeinfo);
  Serial.println(timeHour);
  char timeWeekDay[10];
  strftime(timeWeekDay, 10, "%A", &timeinfo);
  Serial.println(timeWeekDay);
  Serial.println();
}

void listDir(fs::FS &fs, const char *dirname, uint8_t levels) {
  Serial.printf("Listing directory: %s\n", dirname);

  File root = fs.open(dirname);
  if (!root) {
    Serial.println("Failed to open directory");
    return;
  }
  if (!root.isDirectory()) {
    Serial.println("Not a directory");
    return;
  }

  File file = root.openNextFile();
  while (file) {
    if (file.isDirectory()) {
      Serial.print("  DIR : ");
      Serial.println(file.name());
      if (levels) {
        listDir(fs, file.name(), levels - 1);
      }
    } else {
      Serial.print("  FILE: ");
      Serial.print(file.name());
      Serial.print("  SIZE: ");
      Serial.println(file.size());
    }
    file = root.openNextFile();
  }
}

void createDir(fs::FS &fs, const char *path) {
  Serial.printf("Creating Dir: %s\n", path);
  if (fs.mkdir(path)) {
    Serial.println("Dir created");
  } else {
    Serial.println("mkdir failed");
  }
}

void removeDir(fs::FS &fs, const char *path) {
  Serial.printf("Removing Dir: %s\n", path);
  if (fs.rmdir(path)) {
    Serial.println("Dir removed");
  } else {
    Serial.println("rmdir failed");
  }
}

void readFile(fs::FS &fs, const char *path) {
  Serial.printf("Reading file: %s\n", path);

  File file = fs.open(path);
  if (!file) {
    Serial.println("Failed to open file for reading");
    return;
  }

  Serial.print("Read from file: ");
  while (file.available()) {
    Serial.write(file.read());
  }
  file.close();
}

void writeFile(fs::FS &fs, const char *path, const char *message) {
  Serial.printf("Writing file: %s\n", path);

  File file = fs.open(path, FILE_WRITE);
  if (!file) {
    Serial.println("Failed to open file for writing");
    return;
  }
  if (file.print(message)) {
    Serial.println("File written");
  } else {
    Serial.println("Write failed");
  }
  file.close();
}

void appendFile(fs::FS &fs, const char *path, const char *message) {
  Serial.printf("Appending to file: %s\n", path);

  File file = fs.open(path, FILE_APPEND);
  if (!file) {
    Serial.println("Failed to open file for appending");
    return;
  }
  if (file.print(message)) {
    Serial.println("Message appended");
  } else {
    Serial.println("Append failed");
  }
  file.close();
}

void renameFile(fs::FS &fs, const char *path1, const char *path2) {
  Serial.printf("Renaming file %s to %s\n", path1, path2);
  if (fs.rename(path1, path2)) {
    Serial.println("File renamed");
  } else {
    Serial.println("Rename failed");
  }
}

void deleteFile(fs::FS &fs, const char *path) {
  Serial.printf("Deleting file: %s\n", path);
  if (fs.remove(path)) {
    Serial.println("File deleted");
  } else {
    Serial.println("Delete failed");
  }
}

void testFileIO(fs::FS &fs, const char *path) {
  File file = fs.open(path);
  static uint8_t buf[512];
  size_t len = 0;
  uint32_t start = millis();
  uint32_t end = start;
  if (file) {
    len = file.size();
    size_t flen = len;
    start = millis();
    while (len) {
      size_t toRead = len;
      if (toRead > 512) {
        toRead = 512;
      }
      file.read(buf, toRead);
      len -= toRead;
    }
    end = millis() - start;
    Serial.printf("%u bytes read for %u ms\n", flen, end);
    file.close();
  } else {
    Serial.println("Failed to open file for reading");
  }


  file = fs.open(path, FILE_WRITE);
  if (!file) {
    Serial.println("Failed to open file for writing");
    return;
  }

  size_t i;
  start = millis();
  for (i = 0; i < 2048; i++) {
    file.write(buf, 512);
  }
  end = millis() - start;
  Serial.printf("%u bytes written for %u ms\n", 2048 * 512, end);
  file.close();
}

// Function to scroll text
// The function acepts the following arguments:
// row: row number where the text will be displayed
// message: message to scroll
// delayTime: delay between each character shifting
// lcdColumns: number of columns of your LCD
void scrollText(int row, String message, int delayTime, int lcdColumns) {
  for (int i = 0; i < lcdColumns; i++) {
    message = " " + message;
  }
  message = message + " ";
  for (int pos = 0; pos < message.length(); pos++) {
    lcd.setCursor(0, row);
    lcd.print(message.substring(pos, pos + lcdColumns));
    delay(delayTime);
  }
}

//Modified from Nick Pyder's code at Arduino forum.
void getfilename() {
  time_t t = time(nullptr);
  struct tm *tm = localtime(&t);
  if (tm->tm_year >= 120) {  // check if we have a valid time
    int second = tm->tm_sec;
    int minute = tm->tm_min;
    int hour = tm->tm_hour;
    int year = 1900 + tm->tm_year;
    int month = tm->tm_mon + 1;
    int day = tm->tm_mday;
    /*Serial.print(" year = ");
    Serial.println(year);  //based on year 1900
    Serial.print(" month = ");
    Serial.println(month);  //based count from zero : 0-11
    Serial.print(" day = ");
    Serial.println(day);  //based on one (1) : 1-31*/

    filename[0] = (year / 1000) % 10 + '0';  //To get 1st digit from year()
    filename[1] = (year / 100) % 10 + '0';   //To get 2nd digit from year()
    filename[2] = (year / 10) % 10 + '0';    //To get 3rd digit from year()
    filename[3] = year % 10 + '0';           //To get 4th digit from year()
    filename[4] = month / 10 + '0';          //To get 1st digit from month()
    filename[5] = month % 10 + '0';          //To get 2nd digit from month()
    filename[6] = day / 10 + '0';            //To get 1st digit from day()
    filename[7] = day % 10 + '0';            //To get 2nd digit from day()
    Serial.print("Using file name: ");
    Serial.println(filename);
  }
}

void serialPrintTide(char *tid, char *date, char *time, char *height, char *stdev, char *units, char *intvolt, char *extvolt) {
  Serial.print(" Tide ID=");
  Serial.println(tid);
  Serial.print(" Tide Date=");
  Serial.println(date);
  Serial.print(" Tide Time=");
  Serial.println(time);
  Serial.print(" Tide Height=");
  Serial.println(height);
  Serial.print(" Tide Height Stdev=");
  Serial.println(stdev);
  Serial.print(" Tide Height Units=");
  Serial.println(units);
  Serial.print(" Tide Internal Voltage=");
  Serial.println(intvolt);
  Serial.print(" Tide External Voltage=");
  Serial.println(extvolt);
}

void lcdPrintTide(char *date, char *time, char *height) {
  //lcd.backlight();
  lcd.setCursor(0, 2);
  insertChar(date, "/", 4);
  insertChar(date, "/", 7);
  lcd.print(date);
  lcd.setCursor(0, 3);
  insertChar(time, ":", 2);
  insertChar(time, ":", 5);
  lcd.print(time);
  lcd.setCursor(9, 3);
  strncat(height, "m", 1);
  lcd.print(height);
  delay(5000);
  //lcd.noBacklight();
}

void serialPrintStats(unsigned long chars, unsigned short sentences, unsigned short failed) {
  Serial.print(" CHARS=");
  Serial.print(chars);
  Serial.print(" SENTENCES=");
  Serial.print(sentences);
  Serial.print(" CHECKSUM ERR=");
  Serial.println(failed);
}

// creates a new file name each day if it doesn't already exist
void createfilename() {
  //Check if file exists?
  /*if (!SD.exists(filename)) {
    Serial.println("exists");
    Serial.println("appending to existing file");
    } else {
    Serial.println("doesn't exist");
    Serial.println("Creating new file.");
    Serial.println(filename);*/
  dataFile = SD.open(filename, FILE_WRITE);
  dataFile.close();
}


/*void readRTCDateTime() {
  DateTime now = RTC.now();
  Serial.print(now.day(), DEC);
  Serial.print('/');
  Serial.print(now.month(), DEC);
  Serial.print('/');
  Serial.print(now.year(), DEC);
  Serial.print(' ');
  Serial.print(now.hour(), DEC);
  Serial.print(':');
  Serial.print(now.minute(), DEC);
  Serial.print(':');
  Serial.print(now.second(), DEC);
  Serial.println();
}*/

void print2digits(int number) {
  if (number >= 0 && number < 10) {
    Serial.write('0');
  }
  Serial.print(number);
}

// inserts into subject[] at position pos
void insertChar(char subject[], const char insert[], int pos) {
  char buf[50];
  strncpy(buf, subject, pos);
  //printf("%s\n", buf);
  buf[pos] = '\0';
  strcat(buf, insert);
  //printf("%s\n", buf);
  strcat(buf, subject + pos);
  strcpy(subject, buf);
  //printf("%s\n", buf);
}

ในตัวโค้ดจะนำข้อมูลระดับน้ำที่ได้จากสถานีวัดระดับน้ำผ่านวิทยุลอร่า แสดงผลวันที่ เวลาและระดับน้ำที่จอ LCD จากนั้นส่งข้อมูลนี้ขึ้นคลาวด์ของ Thingspeak ทุกๆนาทีแบบฟรีไม่มีค่าใช้จ่าย

ตรวจสอบข้อมูลบนคลาวด์ Thingspeak

ใช้เว็บบราวเซอร์หรือแอพบนมือก็มีหลายแอพ ตัวอย่างผมใช้โทรศัพท์มือถือแอนดรอยด์ ผมติดตั้งแอพ ThingView Free และ ThingShow ใช้ง่ายแค่ป้อน User ID ก็จะแสดงผลตามรูปแบบที่ผมตั้งไว้ในคลาวด์ Thingspeak แอพมีโฆษณาแต่ไม่กวนใจแต่อย่างใด

สำหรับผู้ใช้งาน ThingSpeak ในฐานะแอดมินสามารถไปดาวน์โหลดข้อมูลปัจจุบันและย้อนหลังได้ตลอดเวลาในรูปแบบ CSV มีการเคลมว่าข้อมูลฟรีสามารถเก็บได้นานถึงสามปี

ผมรวบรวมโค้ดในโครงการนี้และไปปล่อยใน Github สามารถไปดูได้ที่นี่ https://github.com/pbroboto/Tide-Station-to-ESP32-ThingSpeak

สรุปแล้วโครงการ DIY ไมโครคอนโทรลเลอร์ส่งค่าระดับน้ำขึ้นคลาวด์ฟรีมีค่าทำของประมาณ 500 บาท ค่าวิทยุลอร่าประมาณ 2000 บาท และค่าเสารับสัญญานวิทยุตัวใหม่เพื่อให้รับส่งดีขึ้นประมาณ 1500 บาท รวมแล้ว 4000 บาท ส่วนค่าคลาวด์ไม่มีค่าใช้จ่าย เทียบกับที่ต้องเช่าคลาวด์เสียเงินปีละแปดหมื่นกว่าบาท ก็ช่วยประหยัดเงินได้มากโขอยู่ โปรดติดตามบทความตอนต่อไปครับ

Leave a Reply

Your email address will not be published. Required fields are marked *