Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
c7c645b
Add scheduler hardening patch script
zekageri Jul 14, 2026
26ac9dc
Run scheduler hardening patch
zekageri Jul 14, 2026
6013533
Harden scheduler lifecycle and executor shutdown
github-actions[bot] Jul 14, 2026
2f55099
Add date hardening patch script
zekageri Jul 14, 2026
f333feb
Run date hardening patch
zekageri Jul 14, 2026
77233f6
Diagnose date hardening patch failures
zekageri Jul 14, 2026
2e031de
Record date hardening patch failure
github-actions[bot] Jul 14, 2026
ebe2d9c
Fix date parser patch application
zekageri Jul 14, 2026
ce8cef6
Fix DST parsing and sun-cycle date handling
github-actions[bot] Jul 14, 2026
b6effdb
Add schedule hardening patch script
zekageri Jul 14, 2026
a075770
Run schedule hardening patch
zekageri Jul 14, 2026
4a0eae1
Validate and bound schedule calculation
github-actions[bot] Jul 14, 2026
78a753a
Add host Arduino compatibility stub
zekageri Jul 14, 2026
1e65c6f
Add deterministic date and schedule regression tests
zekageri Jul 14, 2026
e1cd919
Add host regression test runner
zekageri Jul 14, 2026
482e354
Run deterministic host regression tests
zekageri Jul 14, 2026
9240771
Set v0.1.0 library metadata
zekageri Jul 14, 2026
34324d3
Set Arduino library version to 0.1.0
zekageri Jul 14, 2026
5604129
Document v0.1.0 release behavior
zekageri Jul 14, 2026
1d85bf5
Add release version consistency check
zekageri Jul 14, 2026
f1781a0
Make metadata lint mandatory
zekageri Jul 14, 2026
d570607
Gate releases on host tests and version checks
zekageri Jul 14, 2026
bc443de
Add schedule refinement correction
zekageri Jul 14, 2026
492adfc
Apply schedule refinement correction
zekageri Jul 14, 2026
4de038c
Fix sun and moon schedule refinement
github-actions[bot] Jul 14, 2026
17eeeb7
Ignore host test build output
zekageri Jul 14, 2026
28f59b8
Add scheduler shutdown queue correction
zekageri Jul 14, 2026
1d998c5
Apply scheduler shutdown queue correction
zekageri Jul 14, 2026
fcc4c00
Add inert Tempo configuration cleanup
zekageri Jul 14, 2026
2fd5a03
Apply inert Tempo configuration cleanup
zekageri Jul 14, 2026
f84a54e
Document validated v0.1.0 configuration
zekageri Jul 14, 2026
fb0b605
Update v0.1.0 API behavior documentation
zekageri Jul 14, 2026
9bd7ebe
Cancel superseded host test runs
zekageri Jul 14, 2026
8799e0d
Cancel superseded lint runs
zekageri Jul 14, 2026
48a9c58
Cancel superseded CI runs
zekageri Jul 14, 2026
16e54cf
Correct manual shutdown ordering patch
zekageri Jul 14, 2026
6a41816
Cancel superseded shutdown patch runs
zekageri Jul 14, 2026
c4f52c1
Cancel superseded config cleanup runs
zekageri Jul 14, 2026
2837067
Include executor task creation race fixes
zekageri Jul 14, 2026
54abc17
Avoid impossible post-create mismatch cleanup race
zekageri Jul 14, 2026
6f309a6
Scope all local operations to the Tempo instance timezone
zekageri Jul 14, 2026
9cd7df8
Test independent Tempo instance timezones
zekageri Jul 14, 2026
35c468f
Deduplicate branch and PR host tests
zekageri Jul 14, 2026
26f9d2a
Deduplicate branch and PR lint runs
zekageri Jul 14, 2026
801f676
Deduplicate branch and PR CI runs
zekageri Jul 14, 2026
8415b59
Prevent late completion posts during shutdown
github-actions[bot] Jul 14, 2026
cb3c1b5
Serialize mutable sun-cycle cache access
zekageri Jul 14, 2026
c8ea76a
Remove inert Tempo task configuration
github-actions[bot] Jul 14, 2026
bbe58ab
Add completion queue shutdown synchronization patch
zekageri Jul 14, 2026
9b682c1
Apply completion queue shutdown synchronization
zekageri Jul 14, 2026
8896d7d
Preserve Tempo value semantics with a static sun cache mutex
zekageri Jul 14, 2026
8216673
Avoid copying Tempo in host regression tests
zekageri Jul 14, 2026
682ca0d
Remove temporary completion shutdown patch script
zekageri Jul 14, 2026
a9acf1d
Remove temporary completion shutdown workflow
zekageri Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ on:
pull_request:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true

env:
ARDUINO_BOARD_MANAGER_ADDITIONAL_URLS: https://espressif.github.io/arduino-esp32/package_esp32_index.json
ARDUINO_CLI_VERSION: 1.5.0
Expand All @@ -30,6 +34,34 @@ jobs:
exit 1
fi

version-check:
runs-on: ubuntu-latest
needs: source-audit
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Verify package and tag versions
shell: bash
run: |
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
python3 scripts/check_version.py --tag "${GITHUB_REF_NAME}"
else
python3 scripts/check_version.py
fi

host-tests:
runs-on: ubuntu-latest
needs: source-audit
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Compile and run host regressions
run: bash tests/host/run.sh

build-examples:
runs-on: ubuntu-latest
needs: source-audit
Expand Down Expand Up @@ -78,7 +110,7 @@ jobs:

release:
if: startsWith(github.ref, 'refs/tags/v')
needs: [source-audit, build-examples, arduino-cli]
needs: [source-audit, version-check, host-tests, build-examples, arduino-cli]
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down
19 changes: 19 additions & 0 deletions .github/workflows/host-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: Host Tests

on:
push:
branches: ['**']
pull_request:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true

jobs:
date-and-schedule:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Compile and run host tests
run: bash tests/host/run.sh
13 changes: 8 additions & 5 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
name: Lint (Advisory)
name: Lint

on:
pull_request:
push:
branches:
- '**'

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true

permissions:
contents: read

jobs:
metadata_lint:
name: Metadata Lint (advisory)
name: Metadata Lint
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Verify library metadata
run: grep -qx 'name=Tempo' library.properties
- name: Verify release metadata
run: python3 scripts/check_version.py

- name: Arduino Lint
uses: arduino/arduino-lint-action@v1
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.pio/
.vscode/
.venv
.host-test-build/
build/
build_prev_runner/
release-changelog.md
Expand Down
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Tempo is a time, calendar, sun, moon, and scheduling toolkit for ESP32.
Tempo helps you keep UTC-first time logic explicit in Arduino ESP32 projects while still providing timezone-aware local conversion, DST-aware calendar helpers, cached sun cycle data, moon phase data, and scheduled job execution.

[![CI](https://github.com/ZekStack/tempo/actions/workflows/ci.yml/badge.svg)](https://github.com/ZekStack/tempo/actions/workflows/ci.yml)
[![Host Tests](https://github.com/ZekStack/tempo/actions/workflows/host-tests.yml/badge.svg)](https://github.com/ZekStack/tempo/actions/workflows/host-tests.yml)
[![Release](https://img.shields.io/github/v/release/ZekStack/tempo?sort=semver)](https://github.com/ZekStack/tempo/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.md)

Expand All @@ -14,7 +15,7 @@ Tempo helps you keep UTC-first time logic explicit in Arduino ESP32 projects whi
* **DST-aware** - POSIX timezone strings are used for local time and recurring schedules.
* **ESP32-friendly** - FreeRTOS service tasks, queue-based scheduling, and result-based errors.
* **Sun and moon data** - sunrise, sunset, solar noon, daylight checks, moon angle, and illumination.
* **Production-minded** - no exceptions, bindable callbacks, and C++20 with embedded constraints.
* **Production-minded** - no explicit exception-based control flow, bindable callbacks, and C++20 with embedded constraints.

## Install

Expand Down Expand Up @@ -84,9 +85,11 @@ void loop() {
> [!IMPORTANT]
> Recurring scheduler jobs wait for valid wall-clock time. Set `minValidUnixSeconds` for your product so jobs do not run against an unset clock.

* Tempo uses POSIX timezone strings for DST-aware local conversion.
* Sunrise and sunset calls use cached daily data through `sunCycleToday()`.
* Tempo uses POSIX timezone strings for DST-aware local conversion. Local timezone operations are serialized because the C runtime timezone is process-global.
* Latitude and longitude are optional, but both must be configured together before using stored-location sun APIs.
* `sunCycleToday()` lazily calculates and caches the current local date. Date-taking daylight and sunrise/sunset match APIs calculate against the supplied date.
* One-shot UTC schedules remain exact; recurring schedules evaluate in local time.
* Scheduler control calls made from an inline scheduler callback return `SchedulerError::Busy` rather than waiting on the scheduler service task itself.

## Examples

Expand Down Expand Up @@ -145,8 +148,8 @@ scheduler.schedule(TempoSchedule::dailyAt(8, 30), options, []() {});
| Filesystem | none |
| PSRAM | Used for selected internal buffers when available |
| Dependencies | none |
| Exceptions | Not used |
| Status | Early-stage `0.0.1` |
| Exceptions | Not used for public error handling |
| Status | `0.1.0` release candidate |

## License

Expand Down
30 changes: 20 additions & 10 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This page summarizes the public API declared by `src/Tempo.h`.

## Result model

Tempo does not throw exceptions. Core operations return `TempoResult`; scheduler operations return `SchedulerResult<T>`.
Tempo uses result objects rather than exception-based public control flow. Core operations return `TempoResult`; scheduler operations return `SchedulerResult<T>`.

```cpp
TempoResult result = tempo.init(config);
Expand All @@ -17,24 +17,28 @@ if (!result) {

| Method | Purpose |
| --- | --- |
| `init(config)` | Initialize timezone, location, and NTP settings. |
| `init(config)` | Validate and initialize timezone, optional location, and NTP settings. |
| `nowUtc()` | Return current system time as UTC `DateTime`. |
| `nowLocal()` | Return current local calendar time. |
| `toLocal(DateTime)` | Convert UTC to local using Tempo timezone. |
| `toUtc(LocalDateTime)` | Convert local calendar time to UTC. |
| `parseUtc(text)` | Parse `YYYY-MM-DDTHH:MM:SSZ`. |
| `parseLocal(text)` | Parse `YYYY-MM-DD HH:MM:SS`. |
| `toLocal(DateTime)` | Convert UTC to local and retain the exact source UTC instant and offset. |
| `toUtc(LocalDateTime)` | Return the retained UTC instant for resolved values; otherwise resolve local calendar fields. |
| `parseUtc(text)` | Parse `YYYY-MM-DDTHH:MM:SSZ`; invalid input returns `DateTime{}`. |
| `parseLocal(text)` | Parse `YYYY-MM-DD HH:MM:SS`; invalid or nonexistent DST-local input returns `LocalDateTime{}` with `ok == false`. |

Local calendar helpers such as `startOfDayLocal()`, `endOfDayLocal()`, `nextDailyAtLocal()`, and `nextWeekdayAtLocal()` use calendar arithmetic. A local day may therefore contain 23, 24, or 25 hours across DST transitions.

## Sun and moon

| Method | Purpose |
| --- | --- |
| `sunCycleToday()` | Return cached daily sun cycle data. |
| `sunRiseTodayUtc()` / `sunSetTodayUtc()` | Return cached UTC event times. |
| `isSunRise()` / `isSunSet()` | Match the cached event inside the configured window. |
| `isDay()` | Check daylight using sunrise and sunset. |
| `sunCycleToday()` | Lazily calculate and cache sun-cycle data for the current local date. |
| `sunRiseTodayUtc()` / `sunSetTodayUtc()` | Return cached UTC event times for the current local date. |
| `isSunRise(date)` / `isSunSet(date)` | Match the event calculated for the supplied date inside the configured window. |
| `isDay(date)` | Check daylight using sunrise and sunset for the supplied date. |
| `moonPhase()` | Return lunar phase angle and illumination. |

Stored-location sun APIs return an invalid result until both latitude and longitude have been explicitly configured.

## Scheduler

```cpp
Expand All @@ -46,3 +50,9 @@ scheduler.schedule(TempoSchedule::dailyAt(8, 30), options, []() {});
```

Convenience methods include `everyMinutes`, `everyHours`, `dailyAt`, `cron`, `everySunRise`, `everySunSet`, `atDay`, and `atDays`.

Schedule validation rejects invalid intervals, empty weekday masks, out-of-range custom field bits, and cron expressions that do not contain exactly five fields. `everyMinutes()` accepts `1..60`; `everyHours()` accepts `1..24`.

Background scheduler commands have explicit cancellation ownership. A control command that is still pending when its timeout expires is canceled and cannot execute later. If execution already started, the call waits for the result rather than reporting an ambiguous timeout.

`JobInfo::name` is an owned fixed-size snapshot. `nameTruncated` reports when a stored job name exceeded `kSchedulerJobNameCapacity - 1` characters.
41 changes: 36 additions & 5 deletions docs/configuration.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Configuration

`TempoConfig` controls timezone, location, NTP, valid-time threshold, sun-cycle cache behavior, and task defaults.
`TempoConfig` controls timezone, optional location, NTP, the valid-time threshold, sun-event matching, and Tempo-owned buffer placement.

```cpp
TempoConfig config;
Expand All @@ -9,20 +9,51 @@ config.latitude = 47.4979f;
config.longitude = 19.0402f;
config.ntpServers = {"pool.ntp.org", "time.google.com"};
config.ntpSyncIntervalMs = 3600000;
config.sunCycleCalculationHour = 4;
config.sunCycleCalculationMinute = 0;
config.sunCycleMatchWindowSeconds = 60;
config.minValidUnixSeconds = 1577836800;
config.usePSRAMBuffers = false;
```

## Location

Latitude and longitude are optional. Leave both unset when sun calculations are not needed. When a stored location is configured, both values must be finite and within these ranges:

- latitude: `-90` through `90`;
- longitude: `-180` through `180`.

Supplying only one coordinate, or an out-of-range coordinate, causes `Tempo::init()` to return `TempoStatus::InvalidArgument`. The coordinate `(0, 0)` remains valid when explicitly configured.

## Timezone

Use a POSIX timezone string such as:

```cpp
config.timezone = "CET-1CEST,M3.5.0/2,M10.5.0/3";
```

The underlying C runtime timezone is process-global. Tempo serializes local-time conversion and scoped timezone switching so conversions from different tasks cannot observe a temporary timezone used by another Tempo operation.

## Sun cycle

Sun cycles are calculated lazily and cached by local calendar date. `sunCycleToday()` refreshes the cache when the local date changes. APIs that receive a `DateTime`, such as `isDay(date)` and `isSunRise(date)`, calculate against the supplied date rather than reusing an unrelated daily cache entry.

`sunCycleMatchWindowSeconds` controls the tolerance used by `isSunRise()` and `isSunSet()`.

## Scheduler

`TempoSchedulerConfig` is an alias of the scheduler configuration type.

```cpp
TempoSchedulerConfig config;
config.service.taskStackSize = 6848;
config.service.commandQueueDepth = 16;
config.service.eventQueueDepth = 16;
config.service.taskStackSize = 4096;
config.service.controlTimeoutMs = 2000;
config.defaultWorkerPool.workerCount = 2;
config.defaultWorkerPool.queueDepth = 8;
config.defaultWorkerPool.stackSize = 6144;
```

Worker pool execution is the default job mode. Use `SchedulerJobMode::Inline` only for very small callbacks, and `SchedulerJobMode::DedicatedTask` for isolated long-running jobs.
Queue depths, stack sizes, worker count, and the control timeout must be nonzero. Invalid scheduler configuration returns `SchedulerError::InvalidConfiguration` before tasks or queues are created.

Worker-pool execution is the default job mode. Use `SchedulerJobMode::Inline` only for very small callbacks, and `SchedulerJobMode::DedicatedTask` for isolated long-running jobs. Scheduler control operations invoked from an inline callback return `SchedulerError::Busy` instead of waiting on the scheduler service task itself.
2 changes: 1 addition & 1 deletion library.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "Tempo",
"version": "0.0.1",
"version": "0.1.0",
"description": "Time, calendar, sun, moon, and scheduling toolkit for ESP32.",
"keywords": [
"esp32",
Expand Down
2 changes: 1 addition & 1 deletion library.properties
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name=Tempo
version=0.0.1
version=0.1.0
author=zekageri
maintainer=zekageri
sentence=Time, calendar, sun, moon, and scheduling toolkit for ESP32.
Expand Down
44 changes: 44 additions & 0 deletions scripts/check_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
import argparse
import json
import re
from pathlib import Path


def properties_version(path: Path) -> str:
for line in path.read_text(encoding="utf-8").splitlines():
if line.startswith("version="):
return line.split("=", 1)[1].strip()
raise SystemExit(f"{path}: missing version property")


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--tag", default="", help="Optional release tag, such as v0.1.0")
args = parser.parse_args()

root = Path(__file__).resolve().parents[1]
manifest_version = json.loads((root / "library.json").read_text(encoding="utf-8"))["version"]
arduino_version = properties_version(root / "library.properties")
readme = (root / "README.md").read_text(encoding="utf-8")

errors: list[str] = []
if manifest_version != arduino_version:
errors.append(
f"library.json is {manifest_version}, but library.properties is {arduino_version}"
)
if not re.search(rf"\b{re.escape(manifest_version)}\b", readme):
errors.append(f"README.md does not mention version {manifest_version}")

if args.tag:
expected_tag = f"v{manifest_version}"
if args.tag != expected_tag:
errors.append(f"release tag is {args.tag}, expected {expected_tag}")

if errors:
raise SystemExit("\n".join(errors))
print(f"Tempo version metadata is consistent: {manifest_version}")


if __name__ == "__main__":
main()
Loading
Loading