wwvb 4.1.0a0__py3-none-any.whl → 5.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '4.1.0a0'
16
- __version_tuple__ = version_tuple = (4, 1, 0)
15
+ __version__ = version = '5.0.1'
16
+ __version_tuple__ = version_tuple = (5, 0, 1)
wwvb/decode.py ADDED
@@ -0,0 +1,93 @@
1
+ # SPDX-FileCopyrightText: 2021 Jeff Epler
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-only
4
+ """A stateful decoder of WWVB signals"""
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from typing import TYPE_CHECKING
10
+
11
+ import wwvb
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Generator
15
+
16
+ # State 1: Unsync'd
17
+ # Marker: State 2
18
+ # Other: State 1
19
+ # State 2: One marker
20
+ # Marker: State 3
21
+ # Other: State 1
22
+ # State 3: Two markers
23
+ # Marker: State 3
24
+ # Other: State 4
25
+ # State 4: Decoding a minute, starting in second 1
26
+ # Second
27
+
28
+ always_zero = {4, 10, 11, 14, 20, 21, 34, 35, 44, 54}
29
+
30
+
31
+ def wwvbreceive() -> Generator[wwvb.WWVBTimecode | None, wwvb.AmplitudeModulation, None]:
32
+ """Decode WWVB signals statefully."""
33
+ minute: list[wwvb.AmplitudeModulation] = []
34
+ state = 1
35
+
36
+ value = yield None
37
+ while True:
38
+ # print(state, value, len(minute), "".join(str(int(i)) for i in minute))
39
+ if state == 1:
40
+ minute = []
41
+ if value == wwvb.AmplitudeModulation.MARK:
42
+ state = 2
43
+ value = yield None
44
+
45
+ elif state == 2:
46
+ state = 3 if value == wwvb.AmplitudeModulation.MARK else 1
47
+ value = yield None
48
+
49
+ elif state == 3:
50
+ if value != wwvb.AmplitudeModulation.MARK:
51
+ state = 4
52
+ minute = [wwvb.AmplitudeModulation.MARK, value]
53
+ value = yield None
54
+
55
+ else: # state == 4:
56
+ minute.append(value)
57
+ if len(minute) % 10 == 0 and value != wwvb.AmplitudeModulation.MARK:
58
+ # print("MISSING MARK", len(minute), "".join(str(int(i)) for i in minute))
59
+ state = 1
60
+ elif len(minute) % 10 and value == wwvb.AmplitudeModulation.MARK:
61
+ # print("UNEXPECTED MARK")
62
+ state = 1
63
+ elif len(minute) - 1 in always_zero and value != wwvb.AmplitudeModulation.ZERO:
64
+ # print("UNEXPECTED NONZERO")
65
+ state = 1
66
+ elif len(minute) == 60:
67
+ # print("FULL MINUTE")
68
+ tc = wwvb.WWVBTimecode(60)
69
+ tc.am[:] = minute
70
+ minute = []
71
+ state = 2
72
+ value = yield tc
73
+ else:
74
+ value = yield None
75
+
76
+
77
+ def main() -> None:
78
+ """Read symbols on stdin and print any successfully-decoded minutes"""
79
+ decoder = wwvbreceive()
80
+ next(decoder)
81
+ decoder.send(wwvb.AmplitudeModulation.MARK)
82
+ for s in sys.argv[1:]:
83
+ for c in s:
84
+ decoded = decoder.send(wwvb.AmplitudeModulation(int(c)))
85
+ if decoded:
86
+ print(decoded)
87
+ w = wwvb.WWVBMinute.from_timecode_am(decoded)
88
+ if w:
89
+ print(w)
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
wwvb/dut1table.py ADDED
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # SPDX-FileCopyrightText: 2021-2024 Jeff Epler
4
+ #
5
+ # SPDX-License-Identifier: GPL-3.0-only
6
+
7
+ """Print the table of historical DUT1 values"""
8
+
9
+ from datetime import timedelta
10
+ from itertools import groupby
11
+
12
+ import wwvb
13
+
14
+ from .iersdata import DUT1_DATA_START, DUT1_OFFSETS
15
+
16
+
17
+ def main() -> None:
18
+ """Print the table of historical DUT1 values"""
19
+ date = DUT1_DATA_START
20
+ for key, it in groupby(DUT1_OFFSETS):
21
+ dut1_ms = (ord(key) - ord("k")) / 10.0
22
+ count = len(list(it))
23
+ end = date + timedelta(days=count - 1)
24
+ dut1_next = wwvb.get_dut1(date + timedelta(days=count), warn_outdated=False)
25
+ ls = f" LS on {end:%F} 23:59:60 UTC" if dut1_ms * dut1_next < 0 else ""
26
+ print(f"{date:%F} {dut1_ms: 3.1f} {count:4d}{ls}")
27
+ date += timedelta(days=count)
28
+ print(date)
29
+
30
+
31
+ if __name__ == "__main__":
32
+ main()
wwvb/gen.py ADDED
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/python3
2
+ """A command-line program for generating wwvb timecodes"""
3
+
4
+ # SPDX-FileCopyrightText: 2011-2024 Jeff Epler
5
+ #
6
+ # SPDX-License-Identifier: GPL-3.0-only
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime
11
+ import sys
12
+ from typing import Any
13
+
14
+ import click
15
+ import dateutil.parser
16
+
17
+ from . import WWVBMinute, WWVBMinuteIERS, print_timecodes, print_timecodes_json, styles
18
+
19
+
20
+ def parse_timespec(ctx: Any, param: Any, value: list[str]) -> datetime.datetime: # noqa: ARG001
21
+ """Parse a time specifier from the commandline"""
22
+ try:
23
+ if len(value) == 5:
24
+ year, month, day, hour, minute = map(int, value)
25
+ return datetime.datetime(year, month, day, hour, minute, tzinfo=datetime.timezone.utc)
26
+ if len(value) == 4:
27
+ year, yday, hour, minute = map(int, value)
28
+ return datetime.datetime(year, 1, 1, hour, minute, tzinfo=datetime.timezone.utc) + datetime.timedelta(
29
+ days=yday - 1,
30
+ )
31
+ if len(value) == 1:
32
+ return dateutil.parser.parse(value[0])
33
+ if len(value) == 0:
34
+ return datetime.datetime.now(datetime.timezone.utc)
35
+ raise ValueError("Unexpected number of arguments")
36
+ except ValueError as e:
37
+ raise click.UsageError(f"Could not parse timespec: {e}") from e
38
+
39
+
40
+ @click.command()
41
+ @click.option(
42
+ "--iers/--no-iers",
43
+ "-i/-I",
44
+ default=True,
45
+ help="Whether to use IESR data for DUT1 and LS. (Default: --iers)",
46
+ )
47
+ @click.option(
48
+ "--leap-second",
49
+ "-s",
50
+ "leap_second",
51
+ flag_value=1,
52
+ default=None,
53
+ help="Force a positive leap second at the end of the GMT month (Implies --no-iers)",
54
+ )
55
+ @click.option(
56
+ "--negative-leap-second",
57
+ "-n",
58
+ "leap_second",
59
+ flag_value=-1,
60
+ help="Force a negative leap second at the end of the GMT month (Implies --no-iers)",
61
+ )
62
+ @click.option(
63
+ "--no-leap-second",
64
+ "-S",
65
+ "leap_second",
66
+ flag_value=0,
67
+ help="Force no leap second at the end of the month (Implies --no-iers)",
68
+ )
69
+ @click.option("--dut1", "-d", type=int, help="Force the DUT1 value (Implies --no-iers)")
70
+ @click.option("--minutes", "-m", default=10, help="Number of minutes to show (default: 10)")
71
+ @click.option(
72
+ "--style",
73
+ default="default",
74
+ type=click.Choice(sorted(["json", *list(styles.keys())])),
75
+ help="Style of output",
76
+ )
77
+ @click.option(
78
+ "--all-timecodes/--no-all-timecodes",
79
+ "-t/-T",
80
+ default=False,
81
+ type=bool,
82
+ help="Show the 'WWVB timecode' line before each minute",
83
+ )
84
+ @click.option(
85
+ "--channel",
86
+ type=click.Choice(["amplitude", "phase", "both"]),
87
+ default="amplitude",
88
+ help="Modulation to show (default: amplitude)",
89
+ )
90
+ @click.argument("timespec", type=str, nargs=-1, callback=parse_timespec)
91
+ def main(
92
+ *,
93
+ iers: bool,
94
+ leap_second: bool,
95
+ dut1: int,
96
+ minutes: int,
97
+ style: str,
98
+ channel: str,
99
+ all_timecodes: bool,
100
+ timespec: datetime.datetime,
101
+ ) -> None:
102
+ """Generate WWVB timecodes
103
+
104
+ TIMESPEC: one of "year yday hour minute" or "year month day hour minute", or else the current minute
105
+ """
106
+ if (leap_second is not None) or (dut1 is not None):
107
+ iers = False
108
+
109
+ newut1 = None
110
+ newls = None
111
+
112
+ if iers:
113
+ constructor: type[WWVBMinute] = WWVBMinuteIERS
114
+ else:
115
+ constructor = WWVBMinute
116
+ newut1 = -500 * (leap_second or 0) if dut1 is None else dut1
117
+ newls = bool(leap_second)
118
+
119
+ w = constructor.from_datetime(timespec, newls=newls, newut1=newut1)
120
+ if style == "json":
121
+ print_timecodes_json(w, minutes, channel, file=sys.stdout)
122
+ else:
123
+ print_timecodes(w, minutes, channel, style, all_timecodes=all_timecodes, file=sys.stdout)
124
+
125
+
126
+ if __name__ == "__main__":
127
+ main()
wwvb/iersdata.json ADDED
@@ -0,0 +1 @@
1
+ {"START": "1972-01-01", "OFFSETS_GZ": "H4sIAMNkdmcC/+2aa3LDMAiEL5uHLTuxnN5/pn/aTmfSSiAWhGy+E2SWZQE58zwiH/1YivB/96vMXiIX2Io8CTyIrDSWGqlMRdrpDa6aJFnr0m4wYZkCE2UmSF0V+13vBveStK6JTfQyW3O86HLJf0RvDgy5u4FCI+WVKTsVoUdHzsrRoWRfYHIItZ5EEgu0Beu58EgEpMpO9zf4/s3iNO4y7/hqEwOZIPu3+PuO2T7Ic5E8GxsnZHvUYOtELxW1WP+0yx/caFxpyAooq6lq06UEr+UkLeXOIDPZ6EBrqb5K8Tvu6/B9CdnZqFQL05s2KauWy/IeF/tJGAisjK9MgGyDuUkRq4G1gRE+VjA30uZNPsdantkgMq58QO4fw+sqzj+A2/16mmvnyy9UzDvMktDgKYlnkFeB2rx+wNANG40aA4OgsY03AWoDCVs/XMmkyQ0+0jWaUqPdwA0m/MRuccGjCwirHToWzbcs8P7U1nZZLSYdHapWu5HqVg1YjK2fPEwvPZPzLPUF848tyid2u7dh8B7h+wVQ923Q+kqxZe3JclSSB+YTM3nnHrjgFth/vzgZzw6cbOMYa4bHFPU/DR3mp/ubKM4cgwMnHZW4GFxFprOVcevAKGva6oExn1MOmyGDJQPm0rpU8bjqdOo993O6Xz9ofToZela5vwrWoTn4l4o5CIIaKejCEgSnJv784V8zOZ+rHS1DD00AAA=="}
@@ -0,0 +1,2 @@
1
+ SPDX-FileCopyrightText: Public domain
2
+ SPDX-License-Identifier: CC0-1.0
wwvb/iersdata.py ADDED
@@ -0,0 +1,37 @@
1
+ # -*- python3 -*-
2
+ """Retrieve iers data, possibly from user or site data or from the wwvbpy distribution"""
3
+
4
+ # SPDX-FileCopyrightText: 2011-2024 Jeff Epler
5
+ #
6
+ # SPDX-License-Identifier: GPL-3.0-only
7
+
8
+ import binascii
9
+ import datetime
10
+ import gzip
11
+ import importlib.resources
12
+ import json
13
+
14
+ import platformdirs
15
+
16
+ __all__ = ["DUT1_DATA_START", "DUT1_OFFSETS", "end", "span", "start"]
17
+
18
+ content: dict[str, str] = {"START": "1970-01-01", "OFFSETS_GZ": "H4sIAFNx1mYC/wMAAAAAAAAAAAA="}
19
+
20
+ path = importlib.resources.files("wwvb") / "iersdata.json"
21
+ content = json.loads(path.read_text(encoding="utf-8"))
22
+
23
+ for location in [ # pragma no cover
24
+ platformdirs.user_data_path("wwvbpy", "unpythonic.net"),
25
+ platformdirs.site_data_path("wwvbpy", "unpythonic.net"),
26
+ ]:
27
+ path = location / "iersdata.json"
28
+ if path.exists():
29
+ content = json.loads(path.read_text(encoding="utf-8"))
30
+ break
31
+
32
+ DUT1_DATA_START = datetime.date.fromisoformat(content["START"])
33
+ DUT1_OFFSETS = gzip.decompress(binascii.a2b_base64(content["OFFSETS_GZ"])).decode("ascii")
34
+
35
+ start = datetime.datetime.combine(DUT1_DATA_START, datetime.time(), tzinfo=datetime.timezone.utc)
36
+ span = datetime.timedelta(days=len(DUT1_OFFSETS))
37
+ end = start + span
wwvb/tz.py ADDED
@@ -0,0 +1,12 @@
1
+ # -*- python -*-
2
+ """A library for WWVB timecodes"""
3
+
4
+ # SPDX-FileCopyrightText: 2021-2024 Jeff Epler
5
+ #
6
+ # SPDX-License-Identifier: GPL-3.0-only
7
+
8
+ from zoneinfo import ZoneInfo
9
+
10
+ Mountain = ZoneInfo("America/Denver")
11
+
12
+ __all__ = ["Mountain", "ZoneInfo"]
wwvb/updateiers.py ADDED
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/python3
2
+
3
+ # SPDX-FileCopyrightText: 2021-2024 Jeff Epler
4
+ #
5
+ # SPDX-License-Identifier: GPL-3.0-only
6
+
7
+ """Update the DUT1 and LS data based on online sources"""
8
+
9
+ from __future__ import annotations
10
+
11
+ import binascii
12
+ import csv
13
+ import datetime
14
+ import gzip
15
+ import io
16
+ import json
17
+ import pathlib
18
+ from typing import Callable
19
+
20
+ import bs4
21
+ import click
22
+ import platformdirs
23
+ import requests
24
+
25
+ DIST_PATH = pathlib.Path(__file__).parent / "iersdata.json"
26
+
27
+ IERS_URL = "https://datacenter.iers.org/data/csv/finals2000A.all.csv"
28
+ IERS_PATH = pathlib.Path("finals2000A.all.csv")
29
+ if IERS_PATH.exists():
30
+ IERS_URL = str(IERS_PATH)
31
+ print("using local", IERS_URL)
32
+ NIST_URL = "https://www.nist.gov/pml/time-and-frequency-division/atomic-standards/leap-second-and-ut1-utc-information"
33
+
34
+
35
+ def _get_text(url: str) -> str:
36
+ """Get a local file or a http/https URL"""
37
+ if url.startswith("http"):
38
+ with requests.get(url, timeout=30) as response:
39
+ return response.text
40
+ else:
41
+ return pathlib.Path(url).read_text(encoding="utf-8")
42
+
43
+
44
+ def update_iersdata( # noqa: PLR0915
45
+ target_path: pathlib.Path,
46
+ ) -> None:
47
+ """Update iersdata.py"""
48
+ offsets: list[int] = []
49
+ iersdata_text = _get_text(IERS_URL)
50
+ for r in csv.DictReader(io.StringIO(iersdata_text), delimiter=";"):
51
+ jd = float(r["MJD"])
52
+ offs_str = r["UT1-UTC"]
53
+ if not offs_str:
54
+ break
55
+ offs = int(round(float(offs_str) * 10))
56
+ if not offsets:
57
+ table_start = datetime.date(1858, 11, 17) + datetime.timedelta(jd)
58
+
59
+ when = min(datetime.date(1972, 1, 1), table_start)
60
+ # iers bulletin A doesn't cover 1972, so fake data for those
61
+ # leap seconds
62
+ while when < datetime.date(1972, 7, 1):
63
+ offsets.append(-2)
64
+ when = when + datetime.timedelta(days=1)
65
+ while when < datetime.date(1972, 11, 1):
66
+ offsets.append(8)
67
+ when = when + datetime.timedelta(days=1)
68
+ while when < datetime.date(1972, 12, 1):
69
+ offsets.append(0)
70
+ when = when + datetime.timedelta(days=1)
71
+ while when < datetime.date(1973, 1, 1):
72
+ offsets.append(-2)
73
+ when = when + datetime.timedelta(days=1)
74
+ while when < table_start:
75
+ offsets.append(8)
76
+ when = when + datetime.timedelta(days=1)
77
+
78
+ table_start = min(datetime.date(1972, 1, 1), table_start)
79
+
80
+ offsets.append(offs)
81
+
82
+ wwvb_text = _get_text(NIST_URL)
83
+ wwvb_data = bs4.BeautifulSoup(wwvb_text, features="html.parser")
84
+ wwvb_dut1_table = wwvb_data.findAll("table")[2]
85
+ assert wwvb_dut1_table
86
+ meta = wwvb_data.find("meta", property="article:modified_time")
87
+ assert isinstance(meta, bs4.Tag)
88
+ wwvb_data_stamp = datetime.datetime.fromisoformat(meta.attrs["content"]).replace(tzinfo=None).date()
89
+
90
+ def patch(patch_start: datetime.date, patch_end: datetime.date, val: int) -> None:
91
+ off_start = (patch_start - table_start).days
92
+ off_end = (patch_end - table_start).days
93
+ offsets[off_start:off_end] = [val] * (off_end - off_start)
94
+
95
+ wwvb_dut1: int | None = None
96
+ wwvb_start: datetime.date | None = None
97
+ for row in wwvb_dut1_table.findAll("tr")[1:][::-1]:
98
+ cells = row.findAll("td")
99
+ when = datetime.datetime.strptime(cells[0].text + "+0000", "%Y-%m-%d%z").date()
100
+ dut1 = cells[2].text.replace("s", "").replace(" ", "")
101
+ dut1 = int(round(float(dut1) * 10))
102
+ if wwvb_dut1 is not None:
103
+ assert wwvb_start is not None
104
+ patch(wwvb_start, when, wwvb_dut1)
105
+ wwvb_dut1 = dut1
106
+ wwvb_start = when
107
+
108
+ # As of 2021-06-14, NIST website incorrectly indicates the offset of -600ms
109
+ # persisted through 2009-03-12, causing an incorrect leap second inference.
110
+ # Assume instead that NIST started broadcasting +400ms on January 1, 2009,
111
+ # causing the leap second to occur on 2008-12-31.
112
+ patch(datetime.date(2009, 1, 1), datetime.date(2009, 3, 12), 4)
113
+
114
+ # this is the final (most recent) wwvb DUT1 value broadcast. We want to
115
+ # extend it some distance into the future, but how far? We will use the
116
+ # modified timestamp of the NIST data.
117
+ assert wwvb_dut1 is not None
118
+ assert wwvb_start is not None
119
+ patch(wwvb_start, wwvb_data_stamp + datetime.timedelta(days=1), wwvb_dut1)
120
+
121
+ table_end = table_start + datetime.timedelta(len(offsets) - 1)
122
+ base = ord("a") + 10
123
+ offsets_bin = bytes(base + ch for ch in offsets)
124
+
125
+ target_path.write_text(
126
+ json.dumps(
127
+ {
128
+ "START": table_start.isoformat(),
129
+ "OFFSETS_GZ": binascii.b2a_base64(gzip.compress(offsets_bin)).decode("ascii").strip(),
130
+ },
131
+ ),
132
+ )
133
+
134
+ print(f"iersdata covers {table_start} .. {table_end}")
135
+
136
+
137
+ def iersdata_path(callback: Callable[[str, str], pathlib.Path]) -> pathlib.Path:
138
+ """Find out the path for this directory"""
139
+ return callback("wwvbpy", "unpythonic.net") / "iersdata.json"
140
+
141
+
142
+ @click.command()
143
+ @click.option(
144
+ "--user",
145
+ "location",
146
+ flag_value=iersdata_path(platformdirs.user_data_path),
147
+ default=iersdata_path(platformdirs.user_data_path),
148
+ type=pathlib.Path,
149
+ )
150
+ @click.option("--dist", "location", flag_value=DIST_PATH)
151
+ @click.option("--site", "location", flag_value=iersdata_path(platformdirs.site_data_path))
152
+ def main(location: str) -> None:
153
+ """Update DUT1 data"""
154
+ path = pathlib.Path(location)
155
+ print(f"will write to {location!r}")
156
+ path.parent.mkdir(parents=True, exist_ok=True)
157
+ update_iersdata(path)
158
+
159
+
160
+ if __name__ == "__main__":
161
+ main()
wwvb/wwvbtk.py ADDED
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/python3
2
+ """Visualize the WWVB signal in realtime"""
3
+
4
+ # SPDX-FileCopyrightText: 2021-2024 Jeff Epler
5
+ #
6
+ # SPDX-License-Identifier: GPL-3.0-only
7
+ from __future__ import annotations
8
+
9
+ import functools
10
+ import threading
11
+ import time
12
+ from tkinter import Canvas, TclError, Tk
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ import click
16
+
17
+ import wwvb
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Generator
21
+
22
+
23
+ @functools.cache
24
+ def _app() -> Tk:
25
+ """Create the Tk application object lazily"""
26
+ return Tk()
27
+
28
+
29
+ def validate_colors(ctx: Any, param: Any, value: str) -> list[str]: # noqa: ARG001
30
+ """Check that all colors in a string are valid, splitting it to a list"""
31
+ app = _app()
32
+ colors = value.split()
33
+ if len(colors) not in (2, 3, 4, 6):
34
+ raise click.BadParameter(f"Give 2, 3, 4 or 6 colors (not {len(colors)}")
35
+ for c in colors:
36
+ try:
37
+ app.winfo_rgb(c)
38
+ except TclError as e:
39
+ raise click.BadParameter(f"Invalid color {c}") from e
40
+
41
+ if len(colors) == 2:
42
+ off, on = colors
43
+ return [off, off, off, on, on, on]
44
+ if len(colors) == 3:
45
+ return colors + colors
46
+ if len(colors) == 4:
47
+ off, c1, c2, c3 = colors
48
+ return [off, off, off, c1, c2, c3]
49
+ return colors
50
+
51
+
52
+ DEFAULT_COLORS = "#3c3c3c #3c3c3c #3c3c3c #cc3c3c #88883c #3ccc3c"
53
+
54
+
55
+ @click.command
56
+ @click.option("--colors", callback=validate_colors, default=DEFAULT_COLORS)
57
+ @click.option("--size", default=48)
58
+ @click.option("--min-size", default=None)
59
+ def main(colors: list[str], size: int, min_size: int | None) -> None: # noqa: PLR0915
60
+ """Visualize the WWVB signal in realtime"""
61
+ if min_size is None:
62
+ min_size = size
63
+
64
+ def sleep_deadline(deadline: float) -> None:
65
+ """Sleep until a deadline"""
66
+ now = time.time()
67
+ if deadline > now:
68
+ time.sleep(deadline - now)
69
+
70
+ def wwvbtick() -> Generator[tuple[float, wwvb.AmplitudeModulation], None, None]:
71
+ """Yield consecutive values of the WWVB amplitude signal, going from minute to minute"""
72
+ timestamp = time.time() // 60 * 60
73
+
74
+ while True:
75
+ tt = time.gmtime(timestamp)
76
+ key = tt.tm_year, tt.tm_yday, tt.tm_hour, tt.tm_min
77
+ timecode = wwvb.WWVBMinuteIERS(*key).as_timecode()
78
+ for i, code in enumerate(timecode.am):
79
+ yield timestamp + i, code
80
+ timestamp = timestamp + 60
81
+
82
+ def wwvbsmarttick() -> Generator[tuple[float, wwvb.AmplitudeModulation], None, None]:
83
+ """Yield consecutive values of the WWVB amplitude signal
84
+
85
+ .. but deal with time progressing unexpectedly, such as when the
86
+ computer is suspended or NTP steps the clock backwards
87
+
88
+ When time goes backwards or advances by more than a minute, get a fresh
89
+ wwvbtick object; otherwise, discard time signals more than 1s in the past.
90
+ """
91
+ while True:
92
+ for stamp, code in wwvbtick():
93
+ now = time.time()
94
+ if stamp < now - 60:
95
+ break
96
+ if stamp < now - 1:
97
+ continue
98
+ yield stamp, code
99
+
100
+ app = _app()
101
+ app.wm_minsize(min_size, min_size)
102
+ canvas = Canvas(app, width=size, height=size, highlightthickness=0)
103
+ circle = canvas.create_oval(4, 4, 44, 44, outline="black", fill=colors[0])
104
+ canvas.pack(fill="both", expand=True)
105
+ app.wm_deiconify()
106
+
107
+ def resize_canvas(event: Any) -> None:
108
+ """Keep the circle filling the window when it is resized"""
109
+ sz = min(event.width, event.height) - 8
110
+ if sz < 0:
111
+ return
112
+ canvas.coords(
113
+ circle,
114
+ event.width // 2 - sz // 2,
115
+ event.height // 2 - sz // 2,
116
+ event.width // 2 + sz // 2,
117
+ event.height // 2 + sz // 2,
118
+ )
119
+
120
+ canvas.bind("<Configure>", resize_canvas)
121
+
122
+ def led_on(i: int) -> None:
123
+ """Turn the canvas's virtual LED on"""
124
+ canvas.itemconfigure(circle, fill=colors[i + 3])
125
+
126
+ def led_off(i: int) -> None:
127
+ """Turn the canvas's virtual LED off"""
128
+ canvas.itemconfigure(circle, fill=colors[i])
129
+
130
+ def thread_func() -> None:
131
+ """Update the canvas virtual LED"""
132
+ for stamp, code in wwvbsmarttick():
133
+ sleep_deadline(stamp)
134
+ led_on(code)
135
+ app.update()
136
+ sleep_deadline(stamp + 0.2 + 0.3 * int(code))
137
+ led_off(code)
138
+ app.update()
139
+
140
+ thread = threading.Thread(target=thread_func, daemon=True)
141
+ thread.start()
142
+ app.mainloop()
143
+
144
+
145
+ if __name__ == "__main__":
146
+ main()