Summary
I added timezone support, so the clock can display the local time.
Deets
NodeMCU doesn't have any timezone support -- what is there is all UTC. So I'll have to write that myself. Plus, I will have to deal with summer time/standard time issues.
Configuration
For starters, I need to specify in the configuration what timezone we are operating in. Rather than making somthing up, I decided to use a semi-standard form of stating this information that is one of the POSIX forms for the TZ file. Details can be found here:
http://www.gnu.org/software/libc/manual/html_node/TZ-Variable.html
This will provide the names of the timezone (unneeded), the offsets from UTC, and the rules for when to switch back and forth. I am using what is called 'format 2' in that document. Actually, I am simplifying a little bit by not supporting some of the optional bits.
To begin with, I add another section in kosbo.cfg file; e.g.:
-- the timezone timezone { TZ = "EST+5EDT,M3.2.0,M11.1.0" }
This will necessitate another config handler function timezone():
-- config function; set the timezone function timezone ( tuple ) print('in timezone...') if ( tuple.TZ ) then TZ = parseTimezone ( tuple.TZ ) end end
The timezone is just a string, and that needs to be parsed into useful parts. This is not too hard in Lua, because we have something akin to regular expressions (called 'patterns'), with capture groups. As can be seen, I broke that out into a utility function parseTimezone() which returns a struct of the parsed elements:
function parseTimezone ( tz ) if ( not tz ) then return nil end --XXX add optional start end hour? 0-24 local pattern = "^(%a%a%a)([%+%-]?%d+)(%a%a%a),M(%d+)%.(%d+)%.(%d+),M(%d+)%.(%d+)%.(%d+)$" local f, l, c1, c2, c3, c4, c5, c6, c7, c8, c9 = tz:find ( pattern ) if ( f ) then --XXX sanity checking return { stdName = c1, stdOffset = tonumber(c2), dayName = c3, startM = tonumber(c4), startW = tonumber(c5), startD = tonumber(c6), startH = 2, endM = tonumber(c7), endW = tonumber(c8), endD = tonumber(c9), endH = 2, } else return nil end end
Lua patterns do not allow for optional capture groups, so this is why I chose to omit some of the optional parts. Those can be accommodated, but it will require more code, so it didn't seem worth it at the moment. The missing optional components allow the summer time offset be something other that one hour ahead of standard time, and also changing the hour when the summer time/standard time switch is made, which is by default 2 am.
The timezone information is simply stored in a global 'TZ'.
Adding and Subtracting Time
Adjusting for the offset is less straightforward than you might like, because you have to consider potentially changing the date, month, and year. Additionally, you have to consider leap years.
First, we'll need a function to to determine the number of days in a month, which will be needed if we have to increment our time to the next day (and thus might have to increment the month, and possibly year), or decrement our time to the previous day (and thus might have to decrement the month, and possibly year).
-- days in the month for a given year function daysinmonth ( month, year ) if ( 2 == month ) then if ( 0 == year % 100 ) then if ( 0 == year % 400 ) then return 29 else return 28 end elseif ( 0 == year % 4 ) then return 29 else return 28 end elseif ( 4 == month ) then return 30 elseif ( 6 == month ) then return 30 elseif ( 9 == month ) then return 30 elseif ( 11 == month ) then return 30 else return 31 end end
"Thirty days hath September..." and all that stuff. Now we are ready to convert UTC time to local time (well, almost).
-- convert UTC tm to an equivalent local time given the timezone function localtime ( tm, tz ) local tmLocal = tm local offset = tz.stdOffset --offset is defined as hours to ADD to LOCAL time if ( isdst ( tm, tz ) ) then --if it's DST offset = offset - 1 end --XXX generalize this offset function so we can also use in in isdst tmLocal.hour = tmLocal.hour - offset if ( tmLocal.hour < 0 ) then tmLocal.day = tmLocal.day - 1 if ( tmLocal.day < 1 ) then tmLocal.month = tmLocal.month - 1 if ( tmLocal.month < 1 ) then tmLocal.year = tmLocal.year - 1 tmLocal.month = 12 end tmLocal.day = daysinmonth ( tmLocal.month, tmLocal.year ) end tmLocal.hour = tmLocal.hour + 24 elseif ( tmLocal.hour > 23 ) then tmLocal.hour = tmLocal.hour - 24 tmLocal.day = tmLocal.day + 1 if ( tmLocal.day > daysinmonth ( tmLocal.month, tmLocal.year ) ) then tmLocal.day = 1 tmLocal.month = tmLocal.month + 1 if ( tmLocal.month > 12 ) then tmLocal.month = 1 tmLocal.year = tmLocal.year + 1 end end end return tmLocal end
A bit more messy than one might like! Additionally, we need to consider whether we are in standard time or daylight time. This is another can of worms.
Summer Time / Standard Time
The rules for when to change between standard time and summer time are locally-defined. This is handled by way of the configuration file. However, they are also expressed in terms of a day of the week (typically Sunday), and a week number within a month. Obviously the specific date moves around year-to-year, so we need to be able to calculate that. First, we're going to need a way to determine what is the day of the week that a given month starts on. Here is a function using well-known formula for determining the day-of-the-week given a date:
-- day-of-week for year (4 digit), month (1-12), day (1-31) function dow ( year, month, day ) local M = ( month + 9 ) % 12 + 1 local C = math.floor ( year / 100 ) local Y = year % 100 if ( month < 3 ) then Y = Y - 1 end local weekday = ( day + math.floor ( 2.6 * M - 0.2 ) - 2 * C + Y + math.floor ( Y / 4 ) + math.floor ( C / 4 ) ) % 7 -- 0 = sun, 1, = mon, 2 = tue, 3 = wed, 4 = thu, 5 = fri, 6 = sat return weekday end
Then we can determine what is the date of the nth week containing a certain day for a certain month and year:
-- the date of the nth (week, 1-5) day of week (day, 0-6) for a given month and year function nthdow ( year, month, week, day ) local firstdow = dow ( year, month, 1 ) local date = ( day - firstdow + 1 ) + ( week - 1 ) * 7 if ( day < firstdow ) then date = date + 7 end return date end
(I had to cook that one up myself, and it took a bit longer than I would have liked!)
Now we should be able to compute the dates on which the switches occur. For convenience, I decided to put these dates in the TZ structure. I made a helper function that, given a year, will compute and update those dates in the TZ structure, then they can be used with ease for other computations.
-- Adorn the TZ structure with the dates when DST starts and ends for a given -- year. Compute this only if needed. function prepDSTdates ( tz, year ) if ( not tz ) then return end if ( not tz.dstYear or tz.dstYear ~= year ) then tz.dstYear = year tz.dstStartDate = nthdow ( tz.dstYear, tz.startM, tz.startW, tz.startD ) tz.dstEndDate = nthdow ( tz.dstYear, tz.endM, tz.endW, tz.endD ) end end
Finally, we can determine if a given time (UTC) is in the local standard time or daylight time:
--given a UTC tm, and tz, determine if tm is in the DST of tz function isdst ( tm, tz ) local adjustedTZ = tz prepDSTdates ( adjustedTZ, tm.year ) --this adjustment needs to also tweak the dates adjustedTZ.startH = adjustedTZ.startH + adjustedTZ.stdOffset adjustedTZ.endH = adjustedTZ.endH + adjustedTZ.stdOffset if ( ( ( tm.mon > adjustedTZ.startM ) or ( ( tm.mon == adjustedTZ.startM ) and ( ( tm.day > adjustedTZ.dstStartDate ) or ( ( tm.day == adjustedTZ.dstStartDate ) and ( tm.hour >= adjustedTZ.startH ) ) ) ) ) and ( ( tm.mon < adjustedTZ.endM ) or ( ( tm.mon == adjustedTZ.endM ) and ( ( tm.day < adjustedTZ.dstEndDate ) or ( ( tm.day == adjustedTZ.dstEndDate ) and ( tm.hour < adjustedTZ.endH ) ) ) ) ) ) then return true else return false end end
I need to do some exhaustive testing on this, especially for boundary conditions, but a spot check seemed to be good, so I'll motor on for now. I found it interesting while implementing this code is that the spots in the year when one changes zones results in a 'hole' of forbidden times (when you 'spring forward'), and duplicated times (when you 'fall back'). Don't use local time for logging if care about them being unambiguous during the hour of the switchover!
OK, now I have the tools in place to make the clock show local time. I alter the clock_set_now() function to translate the UTC 'now' to a local 'now':
function clock_set_now() --get current date and time local sec, usec, rate = rtctime.get() local tm = rtctime.epoch2cal(sec) prepDSTdates ( TZ, tm.year ) local localTM = localtime ( tm, TZ ) --update the clock sequence = { function () clock_send_time(localTM) end, clock_show_time, clock_update, function () clock_send_date(localTM) end, clock_show_date, clock_update, clock_show_time } run_sequence ( sequence, 250 ) end
So, just adding the prepDSTdates() (to ensure the specific dates for the current year are set up correctly, and the localtime() function to translate the UTC time to the local time and set from that -- the rest is the same.
The clock could now be considered complete from a utilitarian standpoint. I'm going to do a few improvements, though. I don't really want to re-set the clock every 16 2/3 minutes, and I need to improve my run_sequence() function to be safe to call from multiple points in the code. Then I want to add some 'server' of sorts, so I can change the display remotely.
Next
Improvements.
Discussions
Become a Hackaday.io Member
Create an account to leave a comment. Already have an account? Log In.