# PulseAt

Input scheduler

Sends a 100 ms pulse periodically or at sunrise / sunset. Sun times are fetched from the sunrise-sunset.org web service, so the sunrise / sunset modes require internet access. When the service is unreachable, the pulse time is estimated from the last fetched sun time and corrected once fresh data is available.

PulseAt
O

# Outputs

IDAbbrevNameTypeDefaultDescription
pulseOPulseBOOLEANfalsePulse output, true for 100 ms

# Configuration

IDNameTypeDefaultUnitDescription
modeModeENUM0When the pulse is sent: every interval, at sunrise or at sunset

Details:

Values: Interval, Sunrise, Sunset
intervalIntervalNUMBER60sPulse interval in seconds (Interval mode)

Details:

Visible whenmode = Interval
≥ 1
offsetOffsetNUMBER0minShift of the pulse relative to sunrise / sunset in minutes, negative means earlier

Details:

Visible whenmode = Sunrise, Sunset
latitudeLatitudeNUMBER52.2297°Geographic latitude of the location, in decimal degrees (north positive)

Details:

Visible whenmode = Sunrise, Sunset
longitudeLongitudeNUMBER21.0122°Geographic longitude of the location, in decimal degrees (east positive)

Details:

Visible whenmode = Sunrise, Sunset

# State

IDNameTypeDefaultUnitDescription
last_activation_tsLast execution timestampNUMBER0sLast execution timestamp in seconds from epoch
next_activation_tsNext execution timestampNUMBER0sNext execution timestamp in seconds from epoch
event_tsNext pulse timestampNUMBER0sTimestamp of the next pulse in seconds from epoch, 0 if unknown
request_dateRequested dateSTRINGDate of the last sun times request (today / tomorrow)
sun_tsLast sun timeNUMBER0sLast fetched sunrise / sunset timestamp without offset, used to estimate the next pulse when the service is unreachable
event_estimatedEstimated pulse timeBOOLEANfalseTrue if the next pulse time is estimated from the last known sun time instead of fetched data
last_pulse_tsLast pulse timestampNUMBER0sTimestamp of the last pulse in seconds from epoch

# Source Code

View Volang source
// Converts "2026-11-01T05:31:12+00:00" (UTC) to a Unix timestamp
fn iso_to_ts(iso) {
    parts = str::split(iso, "T")
    date = str::split(str::split_next(parts), "-")
    time = str::split(str::split_next(str::split(str::split_next(parts), "+")), ":")

    y = str::number(str::split_next(date))
    m = str::number(str::split_next(date))
    d = str::number(str::split_next(date))
    hh = str::number(str::split_next(time))
    mi = str::number(str::split_next(time))
    ss = str::number(str::split_next(time))

    mp = m - 3
    if (m <= 2) {
        y = y - 1
        mp = m + 9
    }
    era = y / 400
    yoe = y - era * 400
    doy = (153 * mp + 2) / 5 + d - 1
    doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
    days = era * 146097 + doe - 719468
    return days * 86400 + hh * 3600 + mi * 60 + ss
}

fn request_sun_time(date) {
    state::set("request_date", date)
    url = str::fmt("https://api.sunrise-sunset.org/json?lat={}&lng={}&formatted=0&date={}", config::get("latitude"), config::get("longitude"), date)
    client = http::client()
    http::set_method(client, "GET")
    http::call(client, url)
}

// Last known sun time shifted by whole days, used while fresh data cannot be fetched
fn estimate_event(now) {
    sun = state::get("sun_ts")
    // also rejects 1970-01-01T00:00:01, returned when the sun does not rise / set that day
    if (sun < 86400) {
        return 0
    }
    t = sun + math::round(config::get("offset")) * 60
    if (t <= now) {
        t = t + ((now - t) / 86400 + 1) * 86400
    }
    return t
}

extern fn http::on_response(status, body, headers) {
    mode = config::get("mode")
    if (mode == 0 or status < 200 or status >= 300) {
        return
    }
    key = "results.sunrise"
    if (mode == 2) {
        key = "results.sunset"
    }
    sun = iso_to_ts(json::get(body, key))
    state::set("sun_ts", sun)
    ts = sun + math::round(config::get("offset")) * 60
    // an event close to the last pulse is the one already pulsed on an estimate
    if (ts > time::now() and ts - state::get("last_pulse_ts") > 43200) {
        state::set("event_ts", ts)
        state::set("event_estimated", false)
        state::set("next_activation_ts", ts)
        return
    }
    if (state::get("request_date") == "today") {
        request_sun_time("tomorrow")
    }
}

extern fn pulse_end() {
    output::set("pulse", false)
}

extern fn std::next_activation_at() {
    now = time::now()
    event = state::get("event_ts")
    if (event > now and !state::get("event_estimated")) {
        return event
    }
    if (config::get("mode") == 0) {
        event = now + math::round(config::get("interval"))
        state::set("event_ts", event)
        return event
    }
    request_sun_time("today")
    if (event <= now) {
        event = estimate_event(now)
        state::set("event_ts", event)
        state::set("event_estimated", event > 0)
    }
    // retry if no response arrives; a response overrides this activation
    retry = now + 600
    if (event > 0 and event < retry) {
        return event
    }
    return retry
}

if (input::channel() == "config_change") {
    state::set("event_ts", 0)
    state::set("event_estimated", false)
    state::set("sun_ts", 0)
    callback::clear()
    output::set("pulse", false)
    return
}

now = time::now()
event = state::get("event_ts")
if (event == 0 or event > now) {
    return
}

state::set("last_pulse_ts", now)
output::set("pulse", true)
callback::set(100, "pulse_end")
if (config::get("mode") == 0) {
    state::set("event_ts", event + math::round(config::get("interval")))
} else {
    state::set("event_ts", 0)
    state::set("event_estimated", false)
}
Learn how the PulseAt logic block works, when to use it, and how to configure it in your Voldeno smart home automation.