Close

Let's Do the Time Zone Again (it's just a [5 hour] jump to the left)

A project log for The Internet of Nixie Clocks

Wherein the tubes of Nix do bridge betwix the worlds of form and ether. I see no reason one should bridge, but that won't stop me, either.

ziggurat29ziggurat29 01/29/2019 at 21:520 Comments

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