PicoW NTP Client with Temp sensor and serLCD via I2C

RP2040 based microcontroller boards running MicroPython.
Target audience: MicroPython users with an RP2040 boards.
This does not include conventional Linux-based Raspberry Pi boards.
Post Reply
paulsk
Posts: 11
Joined: Tue Jun 01, 2021 9:54 am
Location: Lisbon
Contact:

PicoW NTP Client with Temp sensor and serLCD via I2C

Post by paulsk » Wed Jul 20, 2022 2:49 pm

Hi, I just finished my project: using a Raspberry Pi Pico W, setting it's built-in RTC from a NTP host. The script checks if we are in a daylight saving time (DST) period for the chosen TimeZone. The timezone defaults to: "Europe/Portugal". The dst data consists of a dictionary that spans a period of ten years (in this moment: 2022-2031)). The timezone string and the dst data are maintained in a separate .json file (dst_data.json), so one can edit this file when necessary. If you want to calculate the dst start and end values for your timezone, there is a handy online calculator: https://www.epochconverter.com/

Via I2C the Pico W is conntected to: an Adafruit AHT20 temperature/humidity sensor; a SparkFun 4x20 serLCD. At start the built-in RTC is updated with NTP host datetime and next at hourly intervals. The time is updated every second using the values from the built-in RTC. The Temperature and Humidity values are updated at the same moment as the time is updated.

I discovered that the time this script displays is always 12 seconds behind the time that is displayed on another
NTP Synchronized Clock (M5Stack Core2) that I have. So I added a correction:
In function set_time() I added a variable 'NTP_ERROR_CORR = 12'.
The line 't = val - NTP_DELTA' I changed into:
't = val - NTP_DELTA - NTP_ERROR_CORR'
Now both clocks at displaying equal times.

Here is the current structure of files in my PicoW.
I copied them from another of my projects. I am sure that some files can be deleted.
Image


Here is the code of the script:

Code: Select all

#
# Idea: downloaded on 2022-07-17 23h45 utc+1
# Idea: downloaded from: https://gist.github.com/aallan/581ecf4dc92cd53e3a415b7c33a1147c
# Idea: aallan/picow_ntp_client.py
# 2022-07-19. In this version added readings from an Adafruit AHT20 temperature/humidity sensor
# Added DST timezone data for ten years 2022-2031, timezone: Europe/Portugal
# 2022-07-20: Added functionality to display data on a SparkFun 4x20 serLCD
# 2022-07-21: Added functionality to import the contents of file: dst_data.json as a dictionary.
# This file now contains the timezone in the first line, followed by ten lines with dst data.
# In this way - when necessary - the timezone and/or the dst data can be updated without altering the script.
#
import network
import socket
import time
import utime
import struct
# added to have not to show credentials in this code
from secrets import secrets
#from machine import Pin
import machine
import busio
import board
import json 

# +--------------------------+
# | Imports for LCD control  |
# +--------------------------+
from sparkfun_serlcd import Sparkfun_SerLCD_I2C

my_debug = False
use_aht20_sensor = True
time_is_set = False
show_NTP_upd_msg_on_lcd = True

"""
Note about time.ticks_diff(ticks12, ticks2)
Note: Do not pass time() values to ticks_diff(), you should use normal mathematical operations on them.
But note that time() may (and will) also overflow.
This is known as https://en.wikipedia.org/wiki/Year_2038_problem .

Note about time.time()
Returns the number of seconds, as an integer, since the Epoch, assuming that underlying RTC is set
and maintained as described above. If an RTC is not set, this function returns number of seconds since
a port-specific reference point in time (for embedded boards without a battery-backed RTC,
usually since power up or reset). If you want to develop portable MicroPython application,
ou should not rely on this function to provide higher than second precision.
See: https://docs.micropython.org/en/latest/library/time.html

"""

# Initialize I2C
# busio.I2C(SCL, SDA)
# Mod by @PaulskPt: SCL, SDA pins to GP3 and GP2 so that GP0 is free to toggle the built-in LED
i2c = busio.I2C(board.GP3, board.GP2)

# +-------------------------------------------------+
# | Definitions for 20x4 character LCD (I2C serial) |
# +-------------------------------------------------+

# --- STATIC (not to be changed!) -------
lcd_columns = 20
lcd_rows = 4
lcd_max_row_cnt = 3  # 0 - 3 = 4 lines
my_row1 = 1
my_row2 = 2
my_col = 3
# --- End-of STATIC ----------------------
lcd_max_row_cnt = 3  # 0 - 3 = 4 lines
lcd_row = 0
lcd_col = 0
lcd_data = None  # See setup()

# It happens when that SerLCD gets locked-up e.g. caused by touching with a finger the RX pin (4th pin fm left). This pin is very sensitive!
# A locked-up situation is often shown as that all the 8x5 segments of the LCD are 'filled with inverse pixels.
# see: https://github.com/KR0SIV/SerLCD_Reset for a reset tool. But this is not what I want. I want to be able to reset the LCD from within this script.
while True:
    try:
        lcd = Sparkfun_SerLCD_I2C(i2c) 
        break
    except ValueError as msg: #.
        print("The LCD is locked-up. Please connect RS with GND for a second or so.")
        time.sleep(10)  # wait a bit

"""
    Talk to the LCD at I2C address 0x27.
    The number of rows and columns defaults to 4x20, so those
    arguments could be omitted in this case.
    Note @paulsk: I experienced that one needs to use the 'num_rows =' and 'num_cols ='
    when these parameters are used.
"""

if use_aht20_sensor:
    import adafruit_ahtx0
    sensor = adafruit_ahtx0.AHTx0(i2c)

# See: https://docs.python.org/3/library/time.html#time.struct_time
tm_year = 0
tm_mon = 1 # range [1, 12]
tm_mday = 2 # range [1, 31]
tm_hour = 3 # range [0, 23]
tm_min = 4 # range [0, 59]
tm_sec = 5 # range [0, 61] in strftime() description
tm_wday = 6 # range 8[0, 6] Monday = 0
tm_yday = 7 # range [0, 366]
tm_isdst = 8 # 0, 1 or -1 
tm_tmzone = 'Europe/Lisbon' # default (set in dst_data.json) abbreviation of timezone name
timezone_set = False
#tm_tmzone_dst = "WET0WEST,M3.5.0/1,M10.5.0"

# Timezone and dst data are in external file "dst_data.json"
# See: https://www.epochconverter.com/
# The "start" and "end" values are for timezone "Europe/Portugal"
dst = None # See setup()

tm_gmtoff = 3600 # offset east of GMT in seconds
# added '- tm_gmtoff' to subtract 1 hour = 3600 seconds to get GMT + 1 for Portugal
NTP_DELTA = 2208988800 - tm_gmtoff # mod by @PaulskPt.
# modified to use Portugues ntp host
host = "pt.pool.ntp.org" # mod by @PaulskPt
dim = {1:31, 2:28, 3:31, 4:30, 5:31, 6:30, 7:31, 8:31, 9:30, 10:31, 11:30, 12:31}

weekdays = {0:"Monday", 1:"Tuesday", 2:"Wednesday", 3:"Thursday", 4:"Friday", 5:"Saturday", 6:"Sunday"}

led = machine.Pin("LED", machine.Pin.OUT)

"""
    function searches the dst dictionary for prensence of the year
    Parameter: integer: yr, the year to search
    Return: the index of the found key to the dst dictionary. If not found: -1


"""
def yr_in_dst_dict(yr):
    TAG = "yr_in_dst_dict(): "
    dst_idx = -1
    
    if my_debug:
        print(TAG+"type(dst)=", type(dst))
        print(TAG+"type(dst[\"dst\"][0])={}, contents={}".format(type(dst["dst"][0]), dst["dst"]))
    le = len(dst["dst"])
    if my_debug:
        print(TAG+"length dst =", le)
        t_dst = int(dst["dst"][1]["year"]) # the first record holds the timezone
        print(TAG+"1st item in dst[\"dst\"]= {}, type={}".format(t_dst, type(t_dst)))
    
    for i in range(1,le):
        if yr == int(dst["dst"][i]["year"]):
            if my_debug:
                print(TAG+"year {} found in dst dict, index: {}".format(dst["dst"][i]["year"], i))
            dst_idx = i
            break
    if dst_idx < 0:
        print(TAG+"year {} not found in dst dict".format(yr))

    return dst_idx

"""
  Determine if the current date and time are within the Daylight Saving Time period
  for the TimeZone: Europe/Lisbon.
  Parameters: None
  Return: Boolean
  If the Year of current date is outside of dst.keys()
  then print a message on the REPL and raise a SystemExit
"""
def is_dst():
    TAG = "is_dst(): "
    # We only can call utime.time() if the built-in RTC has been set by set_time()
    if not time_is_set:
        print("is_dst(): cannot continue: built-in RTC has not been set with time of NTP host")
        return False
    
    t = utime.time()
    yr = utime.localtime(t)[0]

    dst_idx = yr_in_dst_dict(yr)
    if dst_idx >= 0:
        t_dst = dst["dst"][dst_idx]
        if my_debug:
            print(TAG+"t_dst= ",end='')
            print(*sorted(t_dst.items()))
        start = int(t_dst["start"]) # integer
        end = int(t_dst["end"]) # integer
        start_info = t_dst["start_info"] # string
        end_info = t_dst["end_info"] # string
        if my_debug:
            print(TAG+"start={}, end={}".format(start,end))
            print(TAG+"start_info=\"{}\", end_info=\"{}\"".format(start_info,end_info))
        return False if t < start or t > end else True
    else:
        print("year: {} not in dst dictionary ({}).\nUpdate the dictionary! Exiting...".format(yr, dst.keys()))
        raise SystemExit
    
"""
  Get the date and time from a NTP host
  Set the built-in RTC
  Parameters: None
  Return: None
"""
def set_time():
    global time_is_set
    TAG = "set_time(): "
    NTP_QUERY = bytearray(48)
    NTP_QUERY[0] = 0x1B
    NTP_ERROR_CORR = 12  # deduct 12 seconds from the time received (empirecally determined while comparing with another NTP synchronized clock I use)
    addr = socket.getaddrinfo(host, 123)[0][-1]
    print(TAG+"Time zone: \"{}\"".format(tm_tmzone))
    print(TAG+"NTP host address: \"{}\"".format(addr[0]))
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    tm = None
    
    while True:
        msg = None
        res = None
        try:
            s.settimeout(1)
            res = s.sendto(NTP_QUERY, addr)
            msg = s.recv(48)
            if my_debug:
                print(TAG+"msg received:", msg)
        except OSError as exc:
            if exc.args[0] == 110: # ETIMEDOUT
                print(TAG+"ETIMEDOUT. Returning False")
                time.sleep(2)
                continue
        finally:
            s.close()
            break

    val = struct.unpack("!I", msg[40:44])[0]
    t = val - NTP_DELTA - NTP_ERROR_CORR
    tm = time.gmtime(t)

    dst_idx = yr_in_dst_dict(tm[tm_year])
    if dst_idx < 0:
        print(TAG+"year: {} not in dst dictionary ({}).\nUpdate the dictionary! Exiting...".format(tm[tm_year], dst.keys()))
        raise SystemExit
    machine.RTC().datetime((tm[tm_year], tm[tm_mon], tm[tm_mday], tm[tm_wday] + 1,
                            tm[tm_hour], tm[tm_min], tm[tm_sec], 0))
    if not time_is_set:
        time_is_set = True  # Flag, only set once
        if not my_debug:
            print(TAG+"built-in RTC has been set with time from NTP host")
    if show_NTP_upd_msg_on_lcd:
        lcd.clear()
        lcd.set_cursor(0,1)
        lcd.write(TAG)
        lcd.set_cursor(0,2)
        lcd.write("RTC set fm NTC host")
        time.sleep(2)
        
    if tm is not None:
        print(TAG+"date/time updated from: \"{}\"".format(host))

"""
   setup() function. Called by main()
   Parameters: None
   Return: None
"""
def setup():
    global dst, dst_info, tm_tmzone, timezone_set
    TAG = "setup(): "
    t = {0:"RPi PicoW",1:"NTP-Client",2:"Temp/Humidity Sensor",3:"(c) 2022 @PaulskPt"}
    fn = 'dst_data.json'
    f = None
    dst_data = None

    # lcd.backlight();
    lcd.system_messages(False)
    lcd.set_contrast(120)  # Set lcd contrast to default value
    lcd.set_backlight(0xFF8C00)  # orange backlight color
    lcd.cursor(1)  # hide cursor was (2) show cursor
    lcd.blink(0)   # don't blink. Was (1) blink cursor
    lcd.clear()
    for i in range(len(t)):
        lcd.set_cursor(lcd_col, lcd_row+i)   # ATTENTION: the SerLCD module set_cursor(col,row)
        lcd.write(t[i])
    lcd.set_cursor(19,3)
    time.sleep(3)  # <--------------- DELAY ---------------
    
    # Load dst and dst_info dictionaries
    try: 
        with open(fn) as f:
            dst_data = f.read()
            if my_debug:
                print(TAG+"Data type data1 b4 reconstruction: ", type(dst_data))
                print(TAG+"Contents dst_data b4 \"{}\"".format(dst_data))
            # reconstructing the data as a dictionary
            dst = json.loads(dst_data)
            dst_data = None # Cleanup
            if my_debug:
                print(TAG+"Data type after reconstruction: ", type(dst))
                print(TAG+"contents dst:", dst)
    except OSError as exc: 
        if exc.args[0] == 2:  # File not found (ENOENT)
            print(TAG+"ERROR: File: \"{}\" not found. Exiting...".format(fn))
            raise SystemExit
        else:
            print(TAG+"Error \"{}\" occurred. Exiting...".format(exc))
            raise
    
    # Read the timezone from file:
    if not timezone_set:
        tz = dst["dst"][0]["timezone"]
        if my_debug:
            print(TAG+"Timezone imported from file: \"{}\" = \"{}\"".format(fn, tz))
        if isinstance(tz, str) and len(tz) > 0:
            tm_tmzone = tz
            timezone_set = True

    # Load login data from different file for safety reasons
    ssid = secrets['ssid']
    password = secrets['pw']
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    wlan.connect(ssid, password)

    max_wait = 10
    while max_wait > 0:
        if wlan.status() < 0 or wlan.status() >= 3:
            break
        max_wait -= 1
        print('waiting for connection...')
        time.sleep(1)

    if wlan.status() != 3:
        raise RuntimeError('network connection failed')
    else:
        print('connected')
        status = wlan.ifconfig()
        print( 'ip = ' + status[0] )

"""
  Show on LCD and print on REPL:
  - Date,
  - HH:MM:SS
  - Day-of-the-Year
  - DST Yes/No
  - Temperature and Humidity (depending flag: use_aht20_sensor)
  Parameters: t:     time.localtime() struct;
              start: if True display all on LCD. if False display only HH:MM:SS and Temperature and Humidity (depending flag: use_ath20_sensor)
  Return: None
"""
def show_dt_t_h(t, start):
    dst = "Yes" if is_dst() else "No"
    tp = "\nLocal date/time: {} {}-{:02d}-{:02d}, {:02d}:{:02d}:{:02d}, day of the year: {}. DST: {}".format(weekdays[t[tm_wday]], t[tm_year],
        t[tm_mon], t[tm_mday], t[tm_hour], t[tm_min], t[tm_sec], t[tm_yday], dst)
    
    if start:
        t0 = "{} {}-{:02d}-{:02d}".format(weekdays[t[tm_wday]][:3],
            t[tm_year], t[tm_mon], t[tm_mday])
        t1 = "Day {}     DST {}".format(t[tm_yday], dst)
    t2 = "Hr  {:02d}:{:02d}:{:02d}".format(t[tm_hour], t[tm_min], t[tm_sec])   

    if use_aht20_sensor:
        tmp = "{:4.1f}C".format(sensor.temperature)
        hum = "{:4.1f}%".format(sensor.relative_humidity)    
        t3 = "T   {}   H {}".format(tmp, hum)
        
    print(tp) 
    if start:
        lcd.clear()
        lcd.set_cursor(0, 0)
        lcd.write(t0)
        lcd.set_cursor(0, 1)
        lcd.write(t1)

    lcd.set_cursor(0, 2)
    lcd.write(t2)
    
    if use_aht20_sensor:
        lcd.set_cursor(0, 3)
        lcd.write(t3)
        print("Temperature: {}, Humidity: {}".format(tmp, hum))
    lcd.set_cursor(19,3) # Park cursor

"""
   main() function
   Parameters: None
   Return: None
"""
def main():
    global time_is_set
    setup()
    set_time()  # call at start
    t = time.localtime()
    o_mday = t[tm_mday]
    o_hour = t[tm_hour] # Set hour for set_time() call interval
    o_sec = t[tm_sec]
    start = True
    while True:
        try:
            t = time.localtime()
            #    yr,   mo, dd, hh, mm, ss, wd, yd, dst
            if o_mday != t[tm_mday]:
                o_mday = t[tm_mday] # remember current day of the month
                start = True # Force to rebuild the whole LCD
            if o_hour != t[tm_hour]:
                o_hour = t[tm_hour] # remember current hour
                set_time()
                t = time.localtime() # reload RTC time after being synchronized
                start = True
            if o_sec != t[tm_sec]:
                o_sec = t[tm_sec] # remember current second
                led.on()
                show_dt_t_h(t, start)
                if start:
                    start = False
                led.off()

        except KeyboardInterrupt:
            print("Keyboard interrupt...exiting...")
            led.off()
            #lcd.clear()
            raise SystemExit
        except OSError as exc:
            if exc.args[0] == 110: # ETIMEDOUT
                time.sleep(2)
                pass

if __name__ == '__main__':
    main()
To control the SparkFun serLCD, the main script uses the library file: Sparkfun_SerLCD_I2C. Here is the code:

Code: Select all

# The MIT License (MIT)
#
# Copyright (c) 2019 Gaston Williams
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
"""
`sparkfun_serlcd`
================================================================================

CircuitPython driver library for the Sparkfun Serial LCD displays


* Author(s): Gaston Williams

* Based on the Arduino library for the Sparkfun SerLCD displays
  Written by Gaston Williams, August 22, 2018
* Based on sample code provided with the SparkFun Serial OpenLCD display.
  The original LiquidCrystal library was written by David A. Mellis and
  modified by Limor Fried @ Adafruit and the OpenLCD code was written by
  Nathan Seidle @ SparkFun.


Implementation Notes
--------------------

**Hardware:**

*  This is library is for the SparkFun Serial LCD displays
*  SparkFun sells these at its website: www.sparkfun.com
*  Do you like this library? Help support SparkFun. Buy a board!
*  16x2 SerLCD Black on RGB https://www.sparkfun.com/products/14072
*  16x2 SerLCD RGB on Black https://www.sparkfun.com/products/14073
*  20x4 SerLCD Black on RGB https://www.sparkfun.com/products/14074

**Software and Dependencies:**

* Adafruit CircuitPython firmware for the supported boards:
  https://github.com/adafruit/circuitpython/releases

* Adafruit's Bus Device library: https://github.com/adafruit/Adafruit_CircuitPython_BusDevice
"""

__version__ = "0.0.0-auto.0"
__repo__ = "https://github.com/fourstix/Sparkfun_CircuitPython_SerLCD.git"

# imports
# // from abc import ABC, abstractmethod  // Please no abstractmethods, CircuitPython is not Python 3 
from time import sleep
from micropython import const

# public constants
DEFAULT_I2C_ADDR = const(0x72)
"""Default I2C address for SerLCD"""


# private constants
_MAX_ROWS = const(4)
_MAX_COLS = const(20)

# Character to reset display Splash Screen to default
_DEFAULT_SPLASH_SCREEN = const(0xFF)

# OpenLCD command characters
_SPECIAL_COMMAND = const(254)
_SETTING_COMMAND = const(0x7C)

# OpenLCD commands
# 45, -, the dash character: command to clear and home the display
_CLEAR_COMMAND = const(0x2D)
# Command to change the contrast setting
_CONTRAST_COMMAND = const(0x18)
# Command to change the i2c address
_ADDRESS_COMMAND = const(0x19)
# 43, +, the plus character: command to set backlight RGB value
_SET_RGB_COMMAND = const(0x2B)
# 46, ., command to enable system messages being displayed
_ENABLE_SYSTEM_MESSAGE_DISPLAY = const(0x2E)
# 47, /, command to disable system messages being displayed
_DISABLE_SYSTEM_MESSAGE_DISPLAY = const(0x2F)
# 48, 0, command to enable splash screen at power on
_ENABLE_SPLASH_DISPLAY = const(0x30)
# 49, 1, command to disable splash screen at power on
_DISABLE_SPLASH_DISPLAY = const(0x31)
# 10, Ctrl+j, command to save current text on display as splash
_SAVE_CURRENT_DISPLAY_AS_SPLASH = const(0x0A)
# Show firmware version
_SHOW_VERSION_COMMAND = const(0x2C)
# Software reset of the system
_RESET_COMMAND = const(0x08)

# special commands
_LCD_RETURNHOME = const(0x02)
_LCD_ENTRYMODESET = const(0x04)
_LCD_DISPLAYCONTROL = const(0x08)
_LCD_CURSORSHIFT = const(0x10)
_LCD_SETDDRAMADDR = const(0x80)

# flags for display entry mode
_LCD_ENTRYRIGHT = const(0x00)
_LCD_ENTRYLEFT = const(0x02)
_LCD_ENTRYSHIFTINCREMENT = const(0x01)
_LCD_ENTRYSHIFTDECREMENT = const(0x00)

# flags for display on/off control
_LCD_DISPLAYON = const(0x04)
_LCD_DISPLAYOFF = const(0x00)
_LCD_CURSORON = const(0x02)
_LCD_CURSOROFF = const(0x00)
_LCD_BLINKON = const(0x01)
_LCD_BLINKOFF = const(0x00)

# flags for display/cursor shift
_LCD_DISPLAYMOVE = const(0x08)
_LCD_CURSORMOVE = const(0x00)
_LCD_MOVERIGHT = const(0x04)
_LCD_MOVELEFT = const(0x00)

# private functions

def _map_range(value, in_min, in_max, out_min, out_max):
    """Map an integer value from a range into a value in another range."""
    result = (value - in_min) * (out_max - out_min) / (in_max - in_min) + out_min

    return int(result)

# base class
class Sparkfun_SerLCD:
    """Abstract base class for Sparkfun AVR-Based Serial LCD display.
    Use the appropriate driver communcation subclass Sprarkfun_SerLCD_I2C()
    for I2C, Sparkfun_SerLCD_SPI() for SPI or Sparkfun_SerLCD_UART for UART.
    """
    # pylint: disable=too-many-instance-attributes
    # pylint: disable=too-many-public-methods

    def __init__(self):
        self._display_control = _LCD_DISPLAYON | _LCD_CURSOROFF | _LCD_BLINKOFF
        self._display_mode = _LCD_ENTRYLEFT | _LCD_ENTRYSHIFTDECREMENT
        self._begin()

    def command(self, command):
        # pylint: disable=line-too-long
        """
        Send a command to the display.

        **Command cheat sheet:**

        * ASCII  / DEC / HEX
        * '|'    / 124 / 0x7C - Put into setting mode
        * Ctrl+c / 3 / 0x03 - Change width to 20
        * Ctrl+d / 4 / 0x04 - Change width to 16
        * Ctrl+e / 5 / 0x05 - Change lines to 4
        * Ctrl+f / 6 / 0x06 - Change lines to 2
        * Ctrl+g / 7 / 0x07 - Change lines to 1
        * Ctrl+h / 8 / 0x08 - Software reset of the system
        * Ctrl+i / 9 / 0x09 - Enable/disable splash screen
        * Ctrl+j / 10 / 0x0A - Save currently displayed text as splash
        * Ctrl+k / 11 / 0x0B - Change baud to 2400bps
        * Ctrl+l / 12 / 0x0C - Change baud to 4800bps
        * Ctrl+m / 13 / 0x0D - Change baud to 9600bps
        *   Ctrl+n / 14 / 0x0E - Change baud to 14400bps
        * Ctrl+o / 15 / 0x0F - Change baud to 19200bps
        * Ctrl+p / 16 / 0x10 - Change baud to 38400bps
        * Ctrl+q / 17 / 0x11 - Change baud to 57600bps
        * Ctrl+r / 18 / 0x12 - Change baud to 115200bps
        * Ctrl+s / 19 / 0x13 - Change baud to 230400bps
        * Ctrl+t / 20 / 0x14 - Change baud to 460800bps
        * Ctrl+u / 21 / 0x15 - Change baud to 921600bps
        * Ctrl+v / 22 / 0x16 - Change baud to 1000000bps
        * Ctrl+w / 23 / 0x17 - Change baud to 1200bps
        * Ctrl+x / 24 / 0x18 - Change the contrast. Follow Ctrl+x with number 0 to 255. 120 is default.
        * Ctrl+y / 25 / 0x19 - Change the TWI address. Follow Ctrl+x with number 0 to 255. 114 (0x72) is default.
        * Ctrl+z / 26 / 0x1A - Enable/disable ignore RX pin on startup (ignore emergency reset)
        * '+'    / 43 / 0x2B - Set RGB backlight with three following bytes, 0-255
        * ','    / 44 / 0x2C - Display current firmware version
        * '-'    / 45 / 0x2D - Clear display. Move cursor to home position.
        * '.'    / 46 / 0x2E - Enable system messages (ie, display 'Contrast: 5' when changed)
        * '/'    / 47 / 0x2F - Disable system messages (ie, don't display 'Contrast: 5' when changed)
        * '0'    / 48 / 0x30 - Enable splash screen
        * '1'    / 49 / 0x31 - Disable splash screen
        *        / 128-157 / 0x80-0x9D - Set the primary backlight brightness. 128 = Off, 157 = 100%.
        *        / 158-187 / 0x9E-0xBB - Set the green backlight brightness. 158 = Off, 187 = 100%.
        *        / 188-217 / 0xBC-0xD9 - Set the blue backlight brightness. 188 = Off, 217 = 100%.
        * For example, to change the baud rate to 115200 send 124 followed by 18.
        """
        data = bytearray()
        data.append(_SETTING_COMMAND)
        data.append(command & 0xFF)
        self._write_bytes(data)

        # Wait a bit longer for special display commands
        sleep(0.010)


    def clear(self):
        """Clear the display"""
        self.command(_CLEAR_COMMAND)

    def home(self):
        """Send the cursor home"""
        self._special_command(_LCD_RETURNHOME)

    def write(self, message):
        """Write a character string to the display."""
        # Value -> String -> Bytes
        text = str(message).encode()
        self._write_bytes(text)

    def set_cursor(self, col, row):
        """Set the cursor position."""
        row_offsets = [0x00, 0x40, 0x14, 0x54]

        # keep variables in bounds
        # row cannot be less than 0
        row = max(0, row)
        # row cannot be greater than max rows
        row = min(row, _MAX_ROWS - 1)

        # send the command
        self._special_command(_LCD_SETDDRAMADDR | (col + row_offsets[row]))

    def create_character(self, location, charmap):
        """Create a customer character
        location - character number 0 to 7
        charmap  - bytes for character as 8 x 5 bit map"""

        # There are only 8 locations 0-7
        location &= 0x07
        data = bytearray()
        # Send request to create a customer character
        data.append(_SETTING_COMMAND)
        data.append(27 + location)
        for i in range(8):
            # Only the lowest 5 bits are used
            data.append(charmap[i] & 0x1F)
        self._write_bytes(data)
        # This takes a bit longer
        sleep(0.050)

    def write_character(self, location):
        """Write a customer character to the display
        location - character number 0 to 7"""

        # There are only locations 0-7
        location &= 0x07

        self.command(35 + location)

    def set_backlight(self, rgb):
        """Set the backlight with 24-bit RGB value."""
        red = (rgb >> 16) & 0x0000FF
        green = (rgb >> 8) & 0x0000FF
        blue = rgb & 0x0000FF
        self.set_backlight_rgb(red, green, blue)

    def set_backlight_rgb(self, red, green, blue):
        """Set the backlight with byte values for r, g, b"""
        # map the byte value range to backlight command range
        r_value = 128 + _map_range(red, 0, 255, 0, 29)
        g_value = 158 + _map_range(green, 0, 255, 0, 29)
        b_value = 188 + _map_range(blue, 0, 255, 0, 29)

        # send commands to the display to set backlights
        data = bytearray()
        # Turn display off to hide confirmation messages
        self._display_control &= ~_LCD_DISPLAYON
        data.append(_SPECIAL_COMMAND)
        data.append(_LCD_DISPLAYCONTROL | self._display_control)

        # Set the red, green and blue values
        data.append(_SETTING_COMMAND)
        data.append(r_value)
        data.append(_SETTING_COMMAND)
        data.append(g_value)
        data.append(_SETTING_COMMAND)
        data.append(b_value)

        # Turn display back on and end
        self._display_control |= _LCD_DISPLAYON
        data.append(_SPECIAL_COMMAND)
        data.append(_LCD_DISPLAYCONTROL | self._display_control)
        # Send data
        self._write_bytes(data)
        # This one is a bit slow
        sleep(0.050)

    def set_fast_backlight(self, rgb):
        """Set the backlight color by a 24-bit value in one pass."""
        # Convert from hex triplet to byte values
        red = (rgb >> 16) & 0x0000FF
        green = (rgb >> 8) & 0x0000FF
        blue = rgb & 0x0000FF
        self.set_fast_backlight_rgb(red, green, blue)

    def set_fast_backlight_rgb(self, red, green, blue):
        """Set the backlight color in one pass."""
        # Mask values into 0-255 range
        red &= 0x00FF
        green &= 0x00FF
        blue &= 0x00FF

        # Send commands to the display to set backlights
        data = bytearray()
        data.append(_SETTING_COMMAND)
        # Send the set RGB character '+' or plus
        data.append(_SET_RGB_COMMAND)
        data.append(red)
        data.append(green)
        data.append(blue)
        self._write_bytes(data)
        sleep(0.010)

    def display(self, value):
        """Turn the display on and off quickly."""
        if bool(value):
            self._display_control |= _LCD_DISPLAYON
            self._special_command(_LCD_DISPLAYCONTROL | self._display_control)
        else:
            self._display_control &= ~_LCD_DISPLAYON
            self._special_command(_LCD_DISPLAYCONTROL | self._display_control)


    def cursor(self, value):
        """Turn the underline cursor on and off."""
        if bool(value):
            self._display_control |= _LCD_CURSORON
            self._special_command(_LCD_DISPLAYCONTROL | self._display_control)
        else:
            self._display_control &= ~_LCD_CURSORON
            self._special_command(_LCD_DISPLAYCONTROL | self._display_control)

    def blink(self, value):
        """Turn the blink cursor on and off."""
        if bool(value):
            self._display_control |= _LCD_BLINKON
            self._special_command(_LCD_DISPLAYCONTROL | self._display_control)
        else:
            self._display_control &= ~_LCD_BLINKON
            self._special_command(_LCD_DISPLAYCONTROL | self._display_control)

    def system_messages(self, enable):
        """Enable or disable the printint of messages like 'UART: 57600' or 'Contrast: 5'"""
        if bool(enable):
            # Send the set '.' character
            self.command(_ENABLE_SYSTEM_MESSAGE_DISPLAY)
        else:
            # Send the set '/' character
            self.command(_DISABLE_SYSTEM_MESSAGE_DISPLAY)
        sleep(0.010)

    def autoscroll(self, enable):
        """Turn autoscrolling on and off."""
        if bool(enable):
            self._display_mode |= _LCD_ENTRYSHIFTINCREMENT
            self._special_command(_LCD_ENTRYMODESET | self._display_mode)
        else:
            self._display_mode &= ~_LCD_ENTRYSHIFTINCREMENT
            self._special_command(_LCD_ENTRYMODESET | self._display_mode)
        sleep(0.010)

    def set_contrast(self, value):
        """Set the display contrast."""
        data = bytearray()
        data.append(_SETTING_COMMAND)
        data.append(_CONTRAST_COMMAND)
        data.append(value & 0x00FF)
        self._write_bytes(data)
        sleep(0.010)

    def scroll_display_left(self, count=1):
        """Scroll the display to the left"""
        self._special_command(_LCD_CURSORSHIFT | _LCD_DISPLAYMOVE | _LCD_MOVELEFT, count)

    def scroll_display_right(self, count=1):
        """Scroll the display to the right"""
        self._special_command(_LCD_CURSORSHIFT | _LCD_DISPLAYMOVE | _LCD_MOVERIGHT, count)

    def move_cursor_left(self, count=1):
        """Move the cursor to the left"""
        self._special_command(_LCD_CURSORSHIFT | _LCD_CURSORMOVE | _LCD_MOVELEFT, count)

    def move_cursor_right(self, count=1):
        """Scroll the display to the right"""
        self._special_command(_LCD_CURSORSHIFT | _LCD_CURSORMOVE | _LCD_MOVERIGHT, count)

    def splash_screen(self, enable):
        """Enable or disable the splash screem."""
        if bool(enable):
            self.command(_ENABLE_SPLASH_DISPLAY)
        else:
            self.command(_DISABLE_SPLASH_DISPLAY)
        sleep(0.010)

    def save_splash_screen(self):
        """Save the current display as the splash screem."""
        self.command(_SAVE_CURRENT_DISPLAY_AS_SPLASH)
        sleep(0.010)

    def left_to_right(self):
        """Set the text to flow from left to right.  This is the direction
        that is common to most Western languages."""
        self._display_mode |= _LCD_ENTRYLEFT
        self._special_command(_LCD_ENTRYMODESET | self._display_mode)

    def right_to_left(self):
        """Set the text to flow from right to left."""
        self._display_mode &= ~_LCD_ENTRYLEFT
        self._special_command(_LCD_ENTRYMODESET | self._display_mode)

    def show_version(self):
        """Show the firmware version on the display."""
        self.command(_SHOW_VERSION_COMMAND)

    def reset(self):
        """Perform a software reset on the dislay."""
        self.command(_RESET_COMMAND)

    def default_splash_screen(self):
        """ Result to the default splash screen"""
        # Clear the display
        self.clear()
        # put the default charater
        self._put_char(_DEFAULT_SPLASH_SCREEN)
        # Wait a bit
        sleep(0.200)
        self.save_splash_screen()

    # abstract methods

    # @abstractmethod
    def _write_bytes(self, data):
        pass

    # @abstractmethod
    def _change_i2c_address(self, addr):
        pass

    # private functions

    def _begin(self):
        """Initialize the display"""
        data = bytearray()
        # Send special command character
        data.append(_SPECIAL_COMMAND)
        # Send the display command
        data.append(_LCD_DISPLAYCONTROL | self._display_control)
        # Send special command character
        data.append(_SPECIAL_COMMAND)
        # Send the entry mode command
        data.append(_LCD_ENTRYMODESET | self._display_mode)
        # Put LCD into setting mode
        data.append(_SETTING_COMMAND)
        # Send clear display command
        data.append(_CLEAR_COMMAND)
        self._write_bytes(data)
        sleep(0.050)

    def _special_command(self, command, count=1):
        """Send a special command to the display.  Used by other functions."""
        data = bytearray()
        data.append(_SPECIAL_COMMAND)
        for _ in range(count):
            data.append(command & 0xFF)
        self._write_bytes(data)

        # Wait a bit longer for special display commands
        sleep(0.050)


    def _put_char(self, char):
        """Send a character byte directly to display, no encoding"""
        data = bytearray()
        data.append(char & 0xFF)
        self._write_bytes(data)


# concrete subclass for I2C
class Sparkfun_SerLCD_I2C(Sparkfun_SerLCD):
    """Driver subclass for Sparkfun Serial Displays over I2C communication"""
    def __init__(self, i2c, address=DEFAULT_I2C_ADDR):
        import adafruit_bus_device.i2c_device as i2c_device
        self._i2c_device = i2c_device.I2CDevice(i2c, address)
        self._i2c = i2c
        super().__init__()


    def _write_bytes(self, data):
        with self._i2c_device as device:
            device.write(data)

    def _change_i2c_address(self, addr):
        import adafruit_bus_device.i2c_device as i2c_device
        self._i2c_device = i2c_device.I2CDevice(self._i2c, addr)

# concrete subclass for SPI
class Sparkfun_SerLCD_SPI(Sparkfun_SerLCD):
    """Driver subclass for Sparkfun Serial LCD display over SPI communication"""
    def __init__(self, spi, cs):
        import adafruit_bus_device.spi_device as spi_device
        self._spi_device = spi_device.SPIDevice(spi, cs)
        super().__init__()


    def _write_bytes(self, data):
        with self._spi_device as device:
            #pylint: disable=no-member
            device.write(data)

    def _change_i2c_address(self, addr):
        # No i2c address change for SPI
        pass

# concrete subclass for UART
class Sparkfun_SerLCD_UART(Sparkfun_SerLCD):
    """Driver subclass for Sparkfun Serial LCD display over Serial communication"""
    def __init__(self, uart):
        self._uart = uart
        super().__init__()

    def _write_bytes(self, data):
        self._uart.write(data)

    def _change_i2c_address(self, addr):
        # No i2c address change for UART
        pass

To connect to a local WiFi access point one has to fill in the WiFi SSID and PASSWORD in the file: secrets.py.
Here is the default secrets.py:

Code: Select all

secrets = {
    'ssid': 'your SSID here',
    'pw': 'your PASSWORD here'
    }
Image:
Image
Last edited by paulsk on Thu Jul 21, 2022 7:55 pm, edited 11 times in total.

tepalia02
Posts: 99
Joined: Mon Mar 21, 2022 5:13 am

Re: PicoW NTP Client with Temp sensor and serLCD via I2C

Post by tepalia02 » Thu Jul 21, 2022 3:49 am

Thanks for sharing. How is the in-built RTC working? Is it showing accurate time?

paulsk
Posts: 11
Joined: Tue Jun 01, 2021 9:54 am
Location: Lisbon
Contact:

Re: PicoW NTP Client with Temp sensor and serLCD via I2C

Post by paulsk » Thu Jul 21, 2022 5:52 pm

tepalia02 wrote:
Thu Jul 21, 2022 3:49 am
Thanks for sharing. How is the in-built RTC working? Is it showing accurate time?
Hi @tepalia02,

The built-in RTC forgets about date and time when powered off.
See: "Attaching a PCF8523 Real Time Clock via I2C": https://datasheets.raspberrypi.com/pico ... -c-sdk.pdf
Where an external Real Time Clock (with battery) is used to keep the date and time.
The built-in RTC of the RPi Pico and the RPi Pico W has to be set at startup. Since the Pico W exists, we can do without an external Real Time Clock device because now we can connect to a NTP host on internet and 'feed' the built-in RTC with a reliable date and time.
The only negative aspect on not having an external Real Time Clock is: when the Pico W is not able to establish connection with internet its built-in Real Time Clock cannot automatically be set. So, in my view, the most reliable way is connecting an external RTC and updating it from a NTP host at intervals. Then, when you power-off the Pico the connected external RTC will maintain the date, time and eventually other (alarm) settings.

My experience with different types of microcontrollers in the past year is that their built-in RTC's tend to run a bit fast and hence run out of sync (with the date and time of the NTP host). That is why it is adviseable to regularly sync the built-in RTC with date and time from a NTP host. I have chosen to sync every hour.

By the way: today I added functionality to have the timezone and the timezone data (in this script over a span of ten years) to be saved in a separate file: dst_data.json, instead of being 'hard-coded' into the script. At startup (in setup() function) the timezone and the dst data will be read from the external .json file. This has the advantage that one can adjust the timezone and the dst data in the external .json file and update the timezone when needed and update the dst data in that external .json file when the period, that the data covers, has surpassed. I'll upload the latest version of the script.

Image

User avatar
karfas
Posts: 193
Joined: Sat Jan 16, 2021 12:53 pm
Location: Vienna, Austria

Re: PicoW NTP Client with Temp sensor and serLCD via I2C

Post by karfas » Fri Jul 22, 2022 6:42 am

Out of curiosity: Why do you need the DST change times of the last or future N years on a microcontroller?
In the far distant past, the TZ environment in Unix/POSIX "MEZ-1MES,M3.5.0,M10.5.0" (search Google for "TZ environment") had all relevant information to switch between UTC and local time for current dates.
When e.g. the EU decides to eliminate DST all together, any approach isn't correct anymore, anyway.
A few hours of debugging might save you from minutes of reading the documentation! :D
My repositories: https://github.com/karfas

paulsk
Posts: 11
Joined: Tue Jun 01, 2021 9:54 am
Location: Lisbon
Contact:

Re: PicoW NTP Client with Temp sensor and serLCD via I2C

Post by paulsk » Sat Jul 23, 2022 11:09 am

@karfas. Thank you for your reply. I know that what I created is not perfect.
In another project, running in a Linux environment, I used the tz environment variable. I started this project using it: (tm_tmzone_dst = "WET0WEST,M3.5.0/1,M10.5.0"). I created the is_dst() function to compare the NTP datetimestamp with tz environment variable using questions as:

Code: Select all

  def is_dst(t):
  	# Are we in the Lisbon timezone?
  	tm_tmzone.find("Lisbon") >= 0:
  	
    	    # Get the week-of-the-month
            wk_nr = week_nr(yr, mo, dom, dow)  # week_nr() another function I had to create
        
            if mo > start_dict["mo"] and mo < end_dict["mo"]:
                ret = True
            elif mo == start_dict["mo"]:  # We are in the month of change to dst
                if wk_nr == start_dict["wk"]:
                    if dow == start_dict["wd"]:
                        if hr >= start_dict["hr"]:
                            ret = True  # we are in dst
        [...]
        # same for month 10 etcetera...
I didn't like the is_dst() function, partly shown above.
Too many lines; too many nested if-statements; prone to error.

I googled for a better solution. I didn't succeed finding a real answer in the plentyfull info available. Then I encountered, in this forum, an - in my eyes - nice solution to compare the received NTP datestamp with the epoch value of the start and end dates of the dst-period for the actual timezone. It resulted in a small function: few lines of code. Easy to understand. Less prone to error.

About your: 'When e.g. the EU decides to eliminate DST altogether...', my answer is: If I would have to worry about things that can happen in the future, I better stop right now with what I am doing here. For me this is a hobby project. I am a retired person. Getting fun out of creating algorithms, solutions to change ideas that pass my brain into a working reality. I am reading a lot about solutions that other people realised. I follow video streams of people that share their knowledge. I am very greatful that open source exists; that communities exist that help eachother in their efforts to make programs better; to make things work. I am 76. I am not revealing this as an excuse for my limited efforts. I learn every day new things regarding programming, making (maker) things. I like your slogoan/signature. That is very right indeed. Many times I immediately find online a solution, be it in: manuals, datasheets, Stackoverflow posts, Discord discussion groups, Github, you name it. Other moments I don't find them or solutions just not fit 100% to the project or O.S. environment I am working with in that moment. My slogan: "programming keeps the mind going". That slogan is the first motive for what I am doing every day Heaven is giving me.
If you, karfas, have a working answer to evaluate if a received NTP datestamp is within the dst period or not, using the Unix/POSIX tz environment variable, please share it here. Thank you!

paulsk
Posts: 11
Joined: Tue Jun 01, 2021 9:54 am
Location: Lisbon
Contact:

Re: PicoW NTP Client with Temp sensor and serLCD via I2C

Post by paulsk » Sat Jul 23, 2022 2:24 pm

This is a 'work-in-progress'.
I found a better way to define the .json file. Resulting in a more simple dictionary after importing the data from the .json file

Code: Select all

[["timezone","Europe/Lisbon"],
["2022","1648342800","1667095200","2022-03-29 01:00:00","2022-10-30 02:00:00"],
["2023","1679792400","1698544800","2023-03-26 01:00:00","2023-10-29 02:00:00"],
["2024","1711846800","1729994400","2024-03-31 01:00:00","2024-10-27 02:00:00"],
["2025","1743296400","1761444000","2025 03-30 01:00:00","2025-10-28 02:00:00"],
["2026","1774746000","1792893600","2026-03-29 01:00:00","2026-10-25 02:00:00"],
["2027","1806195600","1824948000","2027-03-28 01:00:00","2027-10-31 02:00:00"],
["2028","1837645200","1856397600","2028-03-26 01:00:00","2028-10-29 02:00:00"],
["2029","1869094800","1887847200","2029-03-25 01:00:00","2029-10-28 02:00:00"],
["2030","1901149200","1919296800","2030-03-31 01:00:00","2030-10-27 02:00:00"],
["2031","1932598800","1950746400","2031-03-30 01:00:00","2031-10-26 02:00:00"]]
After reading-in the dst dictionary looks as follows:

Image

The current script is as follows:

Code: Select all

#
# Idea: downloaded on 2022-07-17 23h45 utc+1
# Idea: downloaded from: https://gist.github.com/aallan/581ecf4dc92cd53e3a415b7c33a1147c
# Idea: aallan/picow_ntp_client.py
# 2022-07-19. In this version added readings from an Adafruit AHT20 temperature/humidity sensor
# Added DST timezone data for ten years 2022-2031, timezone: Europe/Portugal
# 2022-07-20: Added functionality to display data on a SparkFun 4x20 serLCD
# 2022-07-21: Added functionality to import the contents of file: dst_data.json as a dictionary.
# This file now contains the timezone in the first line, followed by ten lines with dst data.
# In this way - when necessary - the timezone and/or the dst data can be updated without altering the script.
#
import network
import socket
import time
import utime
import struct
# added to have not to show credentials in this code
from secrets import secrets
#from machine import Pin
import machine
import busio
import board
import ujson
from collections import OrderedDict

# +--------------------------+
# | Imports for LCD control  |
# +--------------------------+
from sparkfun_serlcd import Sparkfun_SerLCD_I2C

my_debug = False
use_aht20_sensor = True
time_is_set = False
show_NTP_upd_msg_on_lcd = True

"""
Note about time.ticks_diff(ticks12, ticks2)
Note: Do not pass time() values to ticks_diff(), you should use normal mathematical operations on them.
But note that time() may (and will) also overflow.
This is known as https://en.wikipedia.org/wiki/Year_2038_problem .

Note about time.time()
Returns the number of seconds, as an integer, since the Epoch, assuming that underlying RTC is set
and maintained as described above. If an RTC is not set, this function returns number of seconds since
a port-specific reference point in time (for embedded boards without a battery-backed RTC,
usually since power up or reset). If you want to develop portable MicroPython application,
ou should not rely on this function to provide higher than second precision.
See: https://docs.micropython.org/en/latest/library/time.html

"""

# Initialize I2C
# busio.I2C(SCL, SDA)
# Mod by @PaulskPt: SCL, SDA pins to GP3 and GP2 so that GP0 is free to toggle the built-in LED
i2c = busio.I2C(board.GP3, board.GP2)

# +-------------------------------------------------+
# | Definitions for 20x4 character LCD (I2C serial) |
# +-------------------------------------------------+

# --- STATIC (not to be changed!) -------
lcd_columns = 20
lcd_rows = 4
lcd_max_row_cnt = 3  # 0 - 3 = 4 lines
my_row1 = 1
my_row2 = 2
my_col = 3
# --- End-of STATIC ----------------------
lcd_max_row_cnt = 3  # 0 - 3 = 4 lines
lcd_row = 0
lcd_col = 0
lcd_data = None  # See setup()

# It happens when that SerLCD gets locked-up e.g. caused by touching with a finger the RX pin (4th pin fm left). This pin is very sensitive!
# A locked-up situation is often shown as that all the 8x5 segments of the LCD are 'filled with inverse pixels.
# see: https://github.com/KR0SIV/SerLCD_Reset for a reset tool. But this is not what I want. I want to be able to reset the LCD from within this script.
while True:
    try:
        lcd = Sparkfun_SerLCD_I2C(i2c) 
        break
    except ValueError as msg: #.
        print("The LCD is locked-up. Please connect RS with GND for a second or so.")
        time.sleep(10)  # wait a bit

"""
    Talk to the LCD at I2C address 0x27.
    The number of rows and columns defaults to 4x20, so those
    arguments could be omitted in this case.
    Note @paulsk: I experienced that one needs to use the 'num_rows =' and 'num_cols ='
    when these parameters are used.
"""

if use_aht20_sensor:
    import adafruit_ahtx0
    sensor = adafruit_ahtx0.AHTx0(i2c)

# See: https://docs.python.org/3/library/time.html#time.struct_time
tm_year = 0
tm_mon = 1 # range [1, 12]
tm_mday = 2 # range [1, 31]
tm_hour = 3 # range [0, 23]
tm_min = 4 # range [0, 59]
tm_sec = 5 # range [0, 61] in strftime() description
tm_wday = 6 # range 8[0, 6] Monday = 0
tm_yday = 7 # range [0, 366]
tm_isdst = 8 # 0, 1 or -1 
tm_tmzone = 'Europe/Lisbon' # default (set in dst_data.json) abbreviation of timezone name
timezone_set = False
#tm_tmzone_dst = "WET0WEST,M3.5.0/1,M10.5.0"

# Timezone and dst data are in external file "dst_data.json"
# See: https://www.epochconverter.com/
# The "start" and "end" values are for timezone "Europe/Portugal"
dst = None # See setup()

tm_gmtoff = 3600 # offset east of GMT in seconds
# added '- tm_gmtoff' to subtract 1 hour = 3600 seconds to get GMT + 1 for Portugal
NTP_DELTA = 2208988800 - tm_gmtoff # mod by @PaulskPt.
# modified to use Portugues ntp host
host = "pt.pool.ntp.org" # mod by @PaulskPt
dim = {1:31, 2:28, 3:31, 4:30, 5:31, 6:30, 7:31, 8:31, 9:30, 10:31, 11:30, 12:31}

weekdays = {0:"Monday", 1:"Tuesday", 2:"Wednesday", 3:"Thursday", 4:"Friday", 5:"Saturday", 6:"Sunday"}

led = machine.Pin("LED", machine.Pin.OUT)

"""
  Determine if the current date and time are within the Daylight Saving Time period
  for the TimeZone: Europe/Lisbon.
  Parameters: None
  Return: Boolean
  If the Year of current date is outside of dst.keys()
  then print a message on the REPL and raise a SystemExit
"""
def is_dst():
    TAG = "is_dst(): "
    # We only can call utime.time() if the built-in RTC has been set by set_time()
    if not time_is_set:
        print("is_dst(): cannot continue: built-in RTC has not been set with time of NTP host")
        return False
    
    t = utime.time()
    yr = utime.localtime(t)[0]

    if str(yr) in dst:
        t_dst = dst[str(yr)]
        if my_debug:
            print(TAG+"t_dst= ",end='')
            print(*sorted(t_dst.items()))
        start = t_dst["start"] # integer
        end = t_dst["end"] # integer
        start_info = t_dst["start_info"] # string
        end_info = t_dst["end_info"] # string
        if my_debug:
            print(TAG+"start={}, end={}".format(start,end))
            print(TAG+"start_info=\"{}\", end_info=\"{}\"".format(start_info,end_info))
        return False if t < start or t > end else True
    else:
        print("year: {} not in dst dictionary ({}).\nUpdate the dictionary! Exiting...".format(yr, dst.keys()))
        raise SystemExit
    
"""
  Get the date and time from a NTP host
  Set the built-in RTC
  Parameters: None
  Return: None
"""
def set_time():
    global time_is_set
    TAG = "set_time(): "
    NTP_QUERY = bytearray(48)
    NTP_QUERY[0] = 0x1B
    NTP_ERROR_CORR = 13  # deduct 13 seconds from the time received (empirecally determined while comparing with another NTP synchronized clock I use)
    addr = socket.getaddrinfo(host, 123)[0][-1]
    print(TAG+"Time zone: \"{}\"".format(tm_tmzone))
    print(TAG+"NTP host address: \"{}\"".format(addr[0]))
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    tm = None
    
    while True:
        msg = None
        res = None
        try:
            s.settimeout(1)
            res = s.sendto(NTP_QUERY, addr)
            msg = s.recv(48)
            if my_debug:
                print(TAG+"msg received:", msg)
        except OSError as exc:
            if exc.args[0] == 110: # ETIMEDOUT
                print(TAG+"ETIMEDOUT. Returning False")
                time.sleep(2)
                continue
        finally:
            s.close()
            break

    val = struct.unpack("!I", msg[40:44])[0]
    t = val - NTP_DELTA - NTP_ERROR_CORR
    tm = time.gmtime(t)

    if not str(tm[tm_year]) in dst:
        print(TAG+"year: {} not in dst dictionary ({}).\nUpdate the dictionary! Exiting...".format(tm[tm_year], dst.keys()))
        raise SystemExit
    machine.RTC().datetime((tm[tm_year], tm[tm_mon], tm[tm_mday], tm[tm_wday] + 1,
                            tm[tm_hour], tm[tm_min], tm[tm_sec], 0))
    if not time_is_set:
        time_is_set = True  # Flag, only set once
        if not my_debug:
            print(TAG+"built-in RTC has been set with time from NTP host")
    if show_NTP_upd_msg_on_lcd:
        lcd.clear()
        lcd.set_cursor(0,1)
        lcd.write(TAG)
        lcd.set_cursor(0,2)
        lcd.write("RTC set fm NTC host")
        time.sleep(2)
        
    if tm is not None:
        print(TAG+"date/time updated from: \"{}\"".format(host))

"""
   setup() function. Called by main()
   Parameters: None
   Return: None
"""
def setup():
    global dst, dst_info, tm_tmzone, timezone_set
    TAG = "setup(): "
    t = {0:"RPi PicoW",1:"NTP-Client",2:"Temp/Humidity Sensor",3:"(c) 2022 @PaulskPt"}
    fn = 'dst_data.json'
    f = None
    dst_data = None

    # lcd.backlight();
    lcd.system_messages(False)
    lcd.set_contrast(120)  # Set lcd contrast to default value
    lcd.set_backlight(0xFF8C00)  # orange backlight color
    lcd.cursor(1)  # hide cursor was (2) show cursor
    lcd.blink(0)   # don't blink. Was (1) blink cursor
    lcd.clear()
    for i in range(len(t)):
        lcd.set_cursor(lcd_col, lcd_row+i)   # ATTENTION: the SerLCD module set_cursor(col,row)
        lcd.write(t[i])
    lcd.set_cursor(19,3)
    time.sleep(3)  # <--------------- DELAY ---------------
    
    # Load dst and dst_info dictionaries
    dst_data = []
    line_count = 0
    try: 
        with open(fn, 'rb') as f: # fn = 'dst_data.json'
            json_out = ujson.load(f)
        #print(TAG+"json_out=", json_out)
        print(TAG+"type(json_out)=", type(json_out))

        dst = {}
        for i in range(len(json_out)):
            #print(TAG+"i={}, json_out[{}]= {}".format(i, i, json_out[i]))
            if i == 0:
                # timezone
                dst[json_out[i][0]] = json_out[i][1]
            else:
                # dst_data
                dst[json_out[i][0]] = {"start":int(json_out[i][1]), "end":int(json_out[i][2]), "start_info":json_out[i][3], "end_info":json_out[i][4]}
        if my_debug:
            s_lst = ['start', 'end', 'start_info', 'end_info']
            par = "\""
            print(TAG+"dst = {")
            o_dst = OrderedDict(sorted(dst.items()))
            for k,v in o_dst.items():
                if isinstance(v, str):
                    print("key: \"{}\", value: \"{}\"".format(k,v))
                elif isinstance(v, dict):
                    # print dictionary items in order defined in s_lst
                    vs = "{"
                    for val in s_lst:
                        if val in v:
                            itm = v[val]
                            if isinstance(itm, int):
                                vs += val+": "+str(itm)+", "
                            elif isinstance(itm, str):
                                vs += val+": \""+itm+par+", "
                    vs += "}"
                    print("key: \"{}\", value: {}".format(k, vs))
            print("}")


    except OSError as exc:
        if exc.args[0] == 2:  # File not found (ENOENT)
            print(TAG+"ERROR: File: \"{}\" not found. Exiting...".format(fn))
            raise SystemExit
        else:
            print(TAG+"Error \"{}\" occurred. Exiting...".format(exc))
            raise
    
    # Read the timezone from file:
    if not timezone_set:
        tz = dst["timezone"]  # was: dst["dst"][0]["timezone"]
        if my_debug:
            print(TAG+"Timezone imported from file: \"{}\" = \"{}\"".format(fn, tz))
        if isinstance(tz, str) and len(tz) > 0:
            tm_tmzone = tz
            timezone_set = True

    # Load login data from different file for safety reasons
    ssid = secrets['ssid']
    password = secrets['pw']
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    wlan.connect(ssid, password)

    max_wait = 10
    while max_wait > 0:
        if wlan.status() < 0 or wlan.status() >= 3:
            break
        max_wait -= 1
        print('waiting for connection...')
        time.sleep(1)

    if wlan.status() != 3:
        raise RuntimeError('network connection failed')
    else:
        print('connected')
        status = wlan.ifconfig()
        print( 'ip = ' + status[0] )

"""
  Show on LCD and print on REPL:
  - Date,
  - HH:MM:SS
  - Day-of-the-Year
  - DST Yes/No
  - Temperature and Humidity (depending flag: use_aht20_sensor)
  Parameters: t:     time.localtime() struct;
              start: if True display all on LCD. if False display only HH:MM:SS and Temperature and Humidity (depending flag: use_ath20_sensor)
  Return: None
"""
def show_dt_t_h(t, start):
    dst = "Yes" if is_dst() else "No"
    tp = "\nLocal date/time: {} {}-{:02d}-{:02d}, {:02d}:{:02d}:{:02d}, day of the year: {}. DST: {}".format(weekdays[t[tm_wday]], t[tm_year],
        t[tm_mon], t[tm_mday], t[tm_hour], t[tm_min], t[tm_sec], t[tm_yday], dst)
    
    if start:
        t0 = "{} {}-{:02d}-{:02d}".format(weekdays[t[tm_wday]][:3],
            t[tm_year], t[tm_mon], t[tm_mday])
        t1 = "Day {}     DST {}".format(t[tm_yday], dst)
    t2 = "Hr  {:02d}:{:02d}:{:02d}".format(t[tm_hour], t[tm_min], t[tm_sec])   

    if use_aht20_sensor:
        tmp = "{:4.1f}C".format(sensor.temperature)
        hum = "{:4.1f}%".format(sensor.relative_humidity)    
        t3 = "T   {}   H {}".format(tmp, hum)
        
    print(tp) 
    if start:
        lcd.clear()
        lcd.set_cursor(0, 0)
        lcd.write(t0)
        lcd.set_cursor(0, 1)
        lcd.write(t1)

    lcd.set_cursor(0, 2)
    lcd.write(t2)
    
    if use_aht20_sensor:
        lcd.set_cursor(0, 3)
        lcd.write(t3)
        print("Temperature: {}, Humidity: {}".format(tmp, hum))
    lcd.set_cursor(19,3) # Park cursor

"""
   main() function
   Parameters: None
   Return: None
"""
def main():
    global time_is_set
    setup()
    set_time()  # call at start
    t = time.localtime()
    o_mday = t[tm_mday]
    o_hour = t[tm_hour] # Set hour for set_time() call interval
    o_sec = t[tm_sec]
    start = True
    while True:
        try:
            t = time.localtime()
            #    yr,   mo, dd, hh, mm, ss, wd, yd, dst
            if o_mday != t[tm_mday]:
                o_mday = t[tm_mday] # remember current day of the month
                start = True # Force to rebuild the whole LCD
            if o_hour != t[tm_hour]:
                o_hour = t[tm_hour] # remember current hour
                set_time()
                t = time.localtime() # reload RTC time after being synchronized
                start = True
            if o_sec != t[tm_sec]:
                o_sec = t[tm_sec] # remember current second
                led.on()
                show_dt_t_h(t, start)
                if start:
                    start = False
                led.off()

        except KeyboardInterrupt:
            print("Keyboard interrupt...exiting...")
            led.off()
            #lcd.clear()
            raise SystemExit
        except OSError as exc:
            if exc.args[0] == 110: # ETIMEDOUT
                time.sleep(2)
                pass

if __name__ == '__main__':
    main()
The output in REPL is as follows:
Image

User avatar
karfas
Posts: 193
Joined: Sat Jan 16, 2021 12:53 pm
Location: Vienna, Austria

Re: PicoW NTP Client with Temp sensor and serLCD via I2C

Post by karfas » Tue Jul 26, 2022 11:56 am

paulsk wrote:
Sat Jul 23, 2022 11:09 am
@karfas. Thank you for your reply. I know that what I created is not perfect.
...
If you, karfas, have a working answer to evaluate if a received NTP datestamp is within the dst period or not, using the Unix/POSIX tz environment variable, please share it here. Thank you!
This wasn't meant as "critique" whatsoever. Just wondered if you are building your own zoneinfo database.
I see that interpreting TZ is quite complicated, so your JSON solution is fine.

Regarding time, I like the Unix approach:
- system time and RTC clock use UTC and never get set to local time.
- Log/protocol-entries and the like also get a UTC timestamp.
These UTC timestamps get converted to local time at display time (which happens most likely on larger systems with full zoneinfo support).
For displaying the current time on the device, I use a constant offset (configuration, defined by the user).
A few hours of debugging might save you from minutes of reading the documentation! :D
My repositories: https://github.com/karfas

paulsk
Posts: 11
Joined: Tue Jun 01, 2021 9:54 am
Location: Lisbon
Contact:

Re: PicoW NTP Client with Temp sensor and serLCD via I2C

Post by paulsk » Thu Jul 28, 2022 4:13 pm

@karfas. Thank you!

Post Reply