├── requirements.txt ├── docs ├── requirements.txt ├── Makefile ├── index.rst └── conf.py ├── images ├── adafruit-mcp2221a-board.jpg ├── raspberry-pi-pico-w-es100-mod.jpg ├── circuit-diagram-mcp2221-and-es100.png ├── raspberry-pi-es100-wiring-diagram.png ├── antenna-1-vs-2-vs-local-time-of-day.png ├── breakout-board-with-mcp2221-and-es100.jpg ├── raspberry-pi-pico-w-es100-mod-with-oled.jpg ├── universal-solder-everset-es100-wwvb-starter-kit.png └── figure1-simulated-coverage-area-for-the-legacy-WWVB-broadcast.png ├── wwvb ├── __init__.py ├── __main__.py ├── maidenhead.py ├── sun.py ├── config.py ├── misc.py ├── ntpdriver28.py └── wwvb.py ├── es100 ├── __init__.py ├── i2c_mcp2221_control.py ├── gpio_mcp2221_control.py ├── i2c_control.py ├── gpio_control.py └── es100.py ├── pico ├── main.py ├── board_led.py ├── config.json ├── irq_wait_for_edge.py ├── oled_display.py ├── logging.py ├── ssd1306.py ├── datetime.py └── wwvb_lite.py ├── setup.cfg ├── .readthedocs.yaml ├── wwvb.ini ├── LICENSE ├── util └── sht.py ├── setup.py ├── Makefile ├── .gitignore ├── CHANGELOG.md └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | smbus 2 | ephem 3 | RPi.GPIO 4 | sysv_ipc 5 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx>=4.0.0 2 | myst_parser>=1.0.0 3 | sphinx_rtd_theme>=1.2.0 4 | -------------------------------------------------------------------------------- /images/adafruit-mcp2221a-board.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahtin/es100-wwvb/HEAD/images/adafruit-mcp2221a-board.jpg -------------------------------------------------------------------------------- /images/raspberry-pi-pico-w-es100-mod.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahtin/es100-wwvb/HEAD/images/raspberry-pi-pico-w-es100-mod.jpg -------------------------------------------------------------------------------- /wwvb/__init__.py: -------------------------------------------------------------------------------- 1 | """__init__ 2 | 3 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 4 | """ 5 | -------------------------------------------------------------------------------- /images/circuit-diagram-mcp2221-and-es100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahtin/es100-wwvb/HEAD/images/circuit-diagram-mcp2221-and-es100.png -------------------------------------------------------------------------------- /images/raspberry-pi-es100-wiring-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahtin/es100-wwvb/HEAD/images/raspberry-pi-es100-wiring-diagram.png -------------------------------------------------------------------------------- /images/antenna-1-vs-2-vs-local-time-of-day.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahtin/es100-wwvb/HEAD/images/antenna-1-vs-2-vs-local-time-of-day.png -------------------------------------------------------------------------------- /images/breakout-board-with-mcp2221-and-es100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahtin/es100-wwvb/HEAD/images/breakout-board-with-mcp2221-and-es100.jpg -------------------------------------------------------------------------------- /images/raspberry-pi-pico-w-es100-mod-with-oled.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahtin/es100-wwvb/HEAD/images/raspberry-pi-pico-w-es100-mod-with-oled.jpg -------------------------------------------------------------------------------- /images/universal-solder-everset-es100-wwvb-starter-kit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahtin/es100-wwvb/HEAD/images/universal-solder-everset-es100-wwvb-starter-kit.png -------------------------------------------------------------------------------- /images/figure1-simulated-coverage-area-for-the-legacy-WWVB-broadcast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahtin/es100-wwvb/HEAD/images/figure1-simulated-coverage-area-for-the-legacy-WWVB-broadcast.png -------------------------------------------------------------------------------- /es100/__init__.py: -------------------------------------------------------------------------------- 1 | """__init__ 2 | 3 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 4 | """ 5 | 6 | from .es100 import ES100, ES100Error 7 | 8 | __version__ = '0.4.4' 9 | 10 | __all__ = ['ES100', 'ES100Error'] 11 | -------------------------------------------------------------------------------- /pico/main.py: -------------------------------------------------------------------------------- 1 | """ main() 2 | 3 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 4 | """ 5 | 6 | # This is key - once done, all the imports work 7 | import os 8 | os.chdir('/flash') 9 | 10 | from pico.wwvb_lite import wwvb_lite 11 | 12 | def main(): 13 | wwvb_lite() 14 | 15 | main() 16 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 3 | # 4 | 5 | # 6 | # setup.cfg for setup.py 7 | # 8 | #[build] 9 | # 10 | #[install] 11 | # 12 | #[sdist] 13 | # 14 | #[upload] 15 | # 16 | 17 | [metadata] 18 | description_file = README.md 19 | 20 | [bdist_wheel] 21 | universal = 1 22 | -------------------------------------------------------------------------------- /wwvb/__main__.py: -------------------------------------------------------------------------------- 1 | """__main__ 2 | 3 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 4 | """ 5 | 6 | import sys 7 | from wwvb import wwvb 8 | 9 | def main(args=None): 10 | """ WWVB API via command line """ 11 | if args is None: 12 | args = sys.argv[1:] 13 | wwvb.wwvb(args) 14 | 15 | if __name__ == '__main__': 16 | main() 17 | -------------------------------------------------------------------------------- /pico/board_led.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | board_led - so so simple 4 | 5 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 6 | """ 7 | 8 | from machine import Pin 9 | 10 | ledINT = Pin('LED', Pin.OUT) # internal on board LED 11 | led16 = Pin('GP16', Pin.OUT) # external LED built into pc board 12 | led17 = Pin('GP17', Pin.OUT) # external LED built into pc board 13 | led18 = Pin('GP18', Pin.OUT) # external LED built into pc board 14 | led19 = Pin('GP19', Pin.OUT) # external LED built into pc board 15 | 16 | ledINT.off() 17 | led16.off() 18 | led17.off() 19 | led17.off() 20 | led18.off() 21 | 22 | led = led16 # presently we used this LED 23 | 24 | def led_on(): 25 | led.on() 26 | def led_off(): 27 | led.off() 28 | 29 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 3 | # 4 | 5 | # .readthedocs.yaml 6 | # Read the Docs configuration file 7 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 8 | 9 | # Required 10 | version: 2 11 | 12 | # Optionally declare the Python requirements required to build your docs 13 | python: 14 | install: 15 | - requirements: docs/requirements.txt 16 | - requirements: requirements.txt 17 | 18 | # Set the version of Python and other tools you might need 19 | build: 20 | os: ubuntu-22.04 21 | tools: 22 | python: "3" 23 | #apt_packages: 24 | # - rpi.gpio 25 | 26 | # Build documentation in the docs/ directory with Sphinx 27 | sphinx: 28 | configuration: docs/conf.py 29 | 30 | -------------------------------------------------------------------------------- /pico/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "wifi.ssid": "network-ssid-name-goes-here", 3 | "wifi.password": "dont-commit-your-password-on-github", 4 | "wifi.lowpower": false, 5 | "wwvb.bus": 1, 6 | "wwvb.address": 50, 7 | "wwvb.irq": 16, 8 | "wwvb.en": 17, 9 | "wwvb.station": "sjc", 10 | "debug.debug": false, 11 | "debug.verbose": false, 12 | "sjc": { 13 | "name": "San José Mineta International Airport", 14 | "location": [ 15 | 37.363056, 16 | -121.928611 17 | ], 18 | "masl": 18.9, 19 | "antenna": null 20 | }, 21 | "den": { 22 | "name": "Colorado State Capitol Building", 23 | "location": [ 24 | 39.7393, 25 | -104.9848 26 | ], 27 | "masl": 1609.0, 28 | "antenna": null 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 3 | # 4 | 5 | # 6 | # Minimal makefile for Sphinx documentation 7 | # 8 | 9 | # You can set these variables from the command line, and also 10 | # from the environment for the first two. 11 | SPHINXOPTS ?= 12 | SPHINXBUILD ?= sphinx-build 13 | SOURCEDIR = . 14 | BUILDDIR = _build 15 | STATICDIR = _static 16 | 17 | # Put it first so that "make" without argument is like "make help". 18 | help: 19 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 20 | 21 | .PHONY: help Makefile 22 | 23 | # Catch-all target: route all unknown targets to Sphinx using the new 24 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 25 | %: Makefile 26 | mkdir -p ${BUILDDIR} ${STATICDIR} 27 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 28 | -------------------------------------------------------------------------------- /wwvb.ini: -------------------------------------------------------------------------------- 1 | # 2 | # WWVB defaults - example wwvb.ini file 3 | # Can exist in the current directory, your home directory, or /etc/wwvb.ini 4 | # 5 | 6 | [WWVB] 7 | # I2C values 8 | bus = 1 9 | address = 50 10 | # GPIO pins 11 | irq = 11 12 | en = 7 13 | # flags,, as needed 14 | nighttime = False 15 | tracking = False 16 | # select where the receiver is. Add a section below to match your choice 17 | # SJC & Denver are simply examples 18 | station = SJC 19 | #station = Denver 20 | 21 | [DEBUG] 22 | # should you want to debug anything 23 | debug = False 24 | verbose = False 25 | 26 | [NTPD] 27 | # remove comment to connect to NTPD via shared memory on unit 2 28 | # unit = 2 29 | 30 | [SJC] 31 | # Where's our receiver? 32 | name = San José Mineta International Airport 33 | location = [37.363056, -121.928611] 34 | masl = 18.9 35 | antenna = 36 | 37 | [Denver] 38 | # If we had a receiver in Colorado, this is its information 39 | name = Colorado State Capitol Building 40 | location = [39.7393N, 104.9848W] 41 | masl = 1609 42 | antenna = 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Martin J Levy - W6LHI/G8LHI - @mahtin 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the “Software”), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. 2 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 3 | 4 | .. es100-wwvb documentation master file, created by 5 | sphinx-quickstart on Tue Mar 14 10:46:25 2023. 6 | You can adapt this file completely to your liking, but it should at least 7 | contain the root `toctree` directive. 8 | 9 | A time and date decoder for the ES100-MOD WWVB receiver 10 | ======================================================= 11 | 12 | WWVB 60Khz Full funcionality receiver/parser for i2c bus based ES100-MOD. 13 | 14 | Description 15 | =========== 16 | 17 | A time and date decoder for the ES100-MOD WWVB receiver. 18 | This code implements tracking (for daytime reception) and full receive decoding (for nighttime reception). 19 | It provides Daylight Saving information and Leap Second information, if WWVB provides it. 20 | 21 | It's written 100% in Python and tested on the Raspberry Pi4 and on the Raspberry Pi Pico W (using `micropython`) with, or without an OLED display. 22 | 23 | The core ES100 library fully implements all the features described in ES100 Data Sheet - Ver 0.97. 24 | 25 | 26 | .. toctree:: 27 | :maxdepth: 2 28 | :caption: Contents: 29 | 30 | 31 | .. include:: modules.rst 32 | 33 | 34 | Indices and tables 35 | ================== 36 | 37 | * :ref:`genindex` 38 | * :ref:`modindex` 39 | * :ref:`search` 40 | -------------------------------------------------------------------------------- /pico/irq_wait_for_edge.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | irq_wait_for_edge 4 | 5 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 6 | """ 7 | 8 | import time 9 | from machine import Pin 10 | 11 | from pico.board_led import led_on, led_off 12 | 13 | irq_triggered_done = False 14 | 15 | def irq_triggered(pin): 16 | global irq_triggered_done 17 | irq_triggered_done = True 18 | 19 | def irq_wait_for_edge(gpio_irq, timeout=None): 20 | global irq_triggered_done 21 | led_off() 22 | blink_the_led_counter = 0 23 | irq_triggered_done = False 24 | gpio_irq.irq(handler=irq_triggered, trigger=Pin.IRQ_FALLING|Pin.IRQ_RISING) 25 | start_ms = time.ticks_ms() 26 | while not irq_triggered_done: 27 | if timeout is not None and time.ticks_diff(time.ticks_ms(), start_ms) > timeout: 28 | # timeout - kill callback and return 29 | gpio_irq.irq(handler=None, trigger=Pin.IRQ_FALLING|Pin.IRQ_RISING) 30 | led_off() 31 | return None 32 | blink_the_led_counter += 1 33 | if blink_the_led_counter == 500: 34 | led_on() 35 | if blink_the_led_counter >= 1000: 36 | led_off() 37 | blink_the_led_counter = 0 38 | # annoyingly, this is all we can do - a small sleep 39 | time.sleep(0.001) 40 | led_off() 41 | return gpio_irq 42 | -------------------------------------------------------------------------------- /util/sht.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | A simple display of the NTP shared memory segment 5 | 6 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 7 | """ 8 | 9 | import os 10 | import sys 11 | import time 12 | import datetime 13 | import logging 14 | 15 | sys.path.insert(0, os.path.abspath('.')) 16 | 17 | from wwvb.ntpdriver28 import NTPDriver28, NTPDriver28Error 18 | 19 | # based on ... 20 | # https://github.com/ntp-project/ntp/blob/9c75327c3796ff59ac648478cd4da8b205bceb77/util/sht.c 21 | 22 | def doit(args): 23 | """ doit """ 24 | 25 | required_format = '%(asctime)s %(name)s %(levelname)s %(message)s' 26 | logging.basicConfig(format=required_format) 27 | logging.basicConfig(level=logging.INFO) 28 | 29 | if len(args) > 0: 30 | try: 31 | unit = int(args[0]) 32 | except ValueError: 33 | sys.exit('%s: invalid argument - should be interger' % (args[0])) 34 | else: 35 | unit = 2 36 | 37 | try: 38 | d28 = NTPDriver28(unit=unit, debug=True) 39 | except NTPDriver28Error as err: 40 | sys.exit(err) 41 | 42 | while True: 43 | try: 44 | d28.load() 45 | d28.dump() 46 | time.sleep(1) 47 | except KeyboardInterrupt: 48 | break 49 | 50 | del d28 51 | 52 | def main(args=None): 53 | """ main """ 54 | if args is None: 55 | args = sys.argv[1:] 56 | doit(args) 57 | 58 | if __name__ == '__main__': 59 | main() 60 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 3 | # 4 | 5 | # Configuration file for the Sphinx documentation builder. 6 | # 7 | # For the full list of built-in configuration values, see the documentation: 8 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 9 | 10 | # -- Project information ----------------------------------------------------- 11 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 12 | 13 | import re 14 | with open('../es100/__init__.py', 'r') as f: 15 | _version_re = re.compile(r"__version__\s=\s'(.*)'") 16 | version = _version_re.search(f.read()).group(1) 17 | 18 | project = 'es100-wwvb' 19 | copyright = '2023, Martin J Levy' 20 | author = 'Martin J Levy' 21 | release = str(version) 22 | 23 | # -- General configuration --------------------------------------------------- 24 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 25 | 26 | extensions = [ 27 | 'myst_parser', 28 | 'sphinx.ext.autodoc', 29 | 'sphinx_rtd_theme', 30 | ] 31 | 32 | html_theme = "sphinx_rtd_theme" 33 | 34 | templates_path = ['_templates'] 35 | exclude_patterns = [ 36 | 'wwvb/__init__.py', 'es100/__init__.py', 37 | '_build', 'Thumbs.db', '.DS_Store' 38 | ] 39 | 40 | autoclass_content = 'both' 41 | 42 | # -- Options for HTML output ------------------------------------------------- 43 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 44 | 45 | html_static_path = ['_static'] 46 | 47 | import os 48 | import sys 49 | sys.path.insert(0, os.path.abspath('..')) 50 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ WWVB 60Khz Full functionality receiver/parser for i2c bus based ES100-MOD 2 | 3 | A time and date decoder for the ES100-MOD WWVB receiver. 4 | See README.md for detailed/further reading. 5 | 6 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 7 | """ 8 | 9 | import re 10 | from distutils.core import setup 11 | 12 | with open('es100/__init__.py', 'r') as f: 13 | _version_re = re.compile(r"__version__\s=\s'(.*)'") 14 | version = _version_re.search(f.read()).group(1) 15 | 16 | with open('README.md') as f: 17 | long_description = f.read() 18 | 19 | setup( 20 | name = 'es100-wwvb', 21 | packages = ['es100', 'wwvb'], 22 | version = version, 23 | license = 'OSI Approved :: MIT License', 24 | description = 'WWVB 60Khz Full functionality receiver/parser for i2c bus based ES100-MOD receiver', 25 | long_description = long_description, 26 | long_description_content_type = 'text/markdown', 27 | author = 'Martin J Levy', 28 | author_email = 'mahtin@mahtin.com', 29 | url = 'https://github.com/mahtin/es100-wwvb', 30 | download_url = 'https://github.com/mahtin/es100-wwvb/archive/refs/tags/%s.tar.gz' % version, 31 | keywords = ['WWVB', 'ES100', 'NIST', 'Time', 'Time Synchronization', 'VLW', 'Very Long Wavelength', 'NTP'], 32 | install_requires = ['smbus', 'ephem', 'RPi.GPIO','sysv_ipc'], 33 | options = {"bdist_wheel": {"universal": True}}, 34 | include_package_data = True, 35 | entry_points = {'console_scripts': ['wwvb=wwvb.__main__:main']}, 36 | python_requires=">=3.7", 37 | 38 | classifiers = [ 39 | 'Development Status :: 4 - Beta', 40 | 'Intended Audience :: Developers', 41 | 'Intended Audience :: System Administrators', 42 | 'Topic :: Communications :: Ham Radio', 43 | 'Topic :: System :: Networking :: Time Synchronization', 44 | 'License :: OSI Approved :: MIT License', 45 | 'Programming Language :: Python :: 3', 46 | ], 47 | ) 48 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 3 | # 4 | 5 | PYTHON = python 6 | PIP = pip 7 | PYLINT = pylint 8 | TWINE = twine 9 | 10 | NAME = "es100-wwvb" 11 | NAME_ = "es100_wwvb" 12 | 13 | all: 14 | ${FORCE} 15 | 16 | lint: 17 | ${PYLINT} --unsafe-load-any-extension=y es100/__init__.py es100/es100.py es100/gpio_control.py es100/i2c_control.py es100/pico/*.py wwvb/__init__.py wwvb/__main__.py wwvb/wwvb.py wwvb/misc.py wwvb/sun.py wwvb/ntpdriver28.py 18 | 19 | clean: 20 | rm -rf build dist 21 | mkdir build dist 22 | $(PYTHON) setup.py clean 23 | rm -rf ${NAME_}.egg-info 24 | 25 | test: all 26 | ${FORCE} 27 | 28 | sdist: all 29 | # make clean 30 | # make test 31 | $(PYTHON) setup.py sdist 32 | @ v=`ls -t dist/${NAME}-*.tar.gz | head -1` ; echo $(TWINE) check $$v ; $(TWINE) check $$v 33 | @ rm -rf ${NAME_}.egg-info build 34 | 35 | bdist: all 36 | ${PIP} wheel . -w dist --no-deps 37 | @ v=`ls -t dist/${NAME_}-*-py2.py3-none-any.whl | head -1` ; echo $(TWINE) check $$v ; $(TWINE) check $$v 38 | @ rm -rf ${NAME_}.egg-info build 39 | 40 | showtag: sdist 41 | @ v=`ls -t dist/${NAME}-*.tar.gz | head -1 | sed -e "s/dist\/${NAME}-//" -e 's/.tar.gz//'` ; echo "\tDIST VERSION =" $$v ; (git tag | fgrep -q "$$v") && echo "\tGIT TAG EXISTS" 42 | 43 | tag: sdist 44 | @ v=`ls -t dist/${NAME}-*.tar.gz | head -1 | sed -e "s/dist\/${NAME}-//" -e 's/.tar.gz//'` ; echo "\tDIST VERSION =" $$v ; (git tag | fgrep -q "$$v") || git tag "$$v" 45 | 46 | upload: clean all tag upload-github upload-pypi 47 | 48 | upload-github: 49 | git push 50 | git push origin --tags 51 | 52 | upload-pypi: sdist bdist 53 | @ v=`ls -t dist/${NAME}-*.tar.gz | head -1` ; echo $(TWINE) check $$v ; $(TWINE) check $$v 54 | @ v=`ls -t dist/${NAME_}-*-py2.py3-none-any.whl | head -1` ; echo $(TWINE) check $$v ; $(TWINE) check $$v 55 | ${TWINE} upload --repository ${NAME} `ls -t dist/${NAME}-*.tar.gz|head -1` `ls -t dist/${NAME_}-*-py2.py3-none-any.whl|head -1` 56 | 57 | 58 | docs: all 59 | sphinx-apidoc -Mfe -o docs . setup.py 60 | sphinx-build -j auto -b html docs docs/_build/html 61 | 62 | clean-docs: all 63 | rm -rf docs/*.rst docs/_build/ 64 | 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/*.rst 73 | docs/_build/* 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | -------------------------------------------------------------------------------- /pico/oled_display.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | OLED I2C display logic for ... 4 | I2C 0.96 Inch OLED I2C Display Module 128x64 Pixel 5 | https://www.amazon.com/dp/B09C5K91H7 6 | 7 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 8 | """ 9 | 10 | from machine import Pin, I2C 11 | 12 | from pico.ssd1306 import SSD1306_I2C 13 | 14 | class OLEDDisplay128x64: 15 | """ OLEDDisplay128x64 """ 16 | 17 | def __init__(self): 18 | """ __init__ """ 19 | self._ssd1306 = None 20 | self._open() 21 | 22 | def _open(self): 23 | """ _open() """ 24 | try: 25 | i2c = I2C(0, sda=Pin(8), scl=Pin(9)) 26 | except ValueError as err: 27 | print('I2C bus - failed open: %s' % (err)) 28 | return 29 | scan = i2c.scan() 30 | if len(scan) == 0: 31 | print('I2C bus - failed to find anything on the bus') 32 | return 33 | if scan[0] != 0x3c: 34 | print('I2C bus - failed to find LCD on the bus') 35 | return 36 | try: 37 | self._ssd1306 = SSD1306_I2C(128, 64, i2c) 38 | except OSError as err: 39 | self._ssd1306 = None 40 | print('I2C display - failed to open: %s' % (err)) 41 | return 42 | 43 | def background(self): 44 | """ background() """ 45 | if not self._ssd1306: 46 | return 47 | self._ssd1306.fill_rect(0, 0, 128, 16, 0) # yellow area 48 | self._ssd1306.fill_rect(0, 16, 128, 48, 0) # blue area (if your display is that type) 49 | self._ssd1306.show() 50 | 51 | def datetime(self, dt=None): 52 | """ datetime() """ 53 | if not self._ssd1306: 54 | return 55 | self._ssd1306.fill_rect(0, 0, 128, 16, 0) 56 | if dt is None: 57 | # leave screen blank 58 | self._ssd1306.show() 59 | return 60 | msec = int(dt.microsecond/1000.0) 61 | dt = dt.replace(microsecond=msec*1000) 62 | # 2023-03-22 17:57:42.803+00:00 63 | date_str = str(dt) 64 | self._ssd1306.text('%s' % date_str[0:0+10], 0, 0, 1) 65 | self._ssd1306.text('%s UTC' % date_str[11:11+10], 0, 8, 1) 66 | self._ssd1306.show() 67 | 68 | def text(self, s, x, y): 69 | """ text """ 70 | if not self._ssd1306: 71 | return 72 | self._ssd1306.fill_rect(x, y, 128, 8, 0) 73 | self._ssd1306.text(s, x, y, 1) 74 | self._ssd1306.show() 75 | 76 | def progress_bar(self, percent, x, y): 77 | """ progress_bar """ 78 | if not self._ssd1306: 79 | return 80 | width = min(128, int(128 * percent)) 81 | self._ssd1306.fill_rect(x, y, width, 8, 1) 82 | self._ssd1306.fill_rect(x+width, y, 128-width, 8, 0) 83 | self._ssd1306.show() 84 | -------------------------------------------------------------------------------- /pico/logging.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | logging for micropython - just the bare minimum 4 | 5 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 6 | """ 7 | 8 | from pico.datetime import datetime 9 | 10 | class logging: 11 | CRITICAL = 50 12 | ERROR = 40 13 | WARNING = 30 14 | INFO = 20 15 | DEBUG = 10 16 | NOTSET = 0 17 | 18 | level_names = { 19 | 50: 'CRITICAL', 20 | 40: 'ERROR', 21 | 30: 'WARNING', 22 | 20: 'INFO', 23 | 10: 'DEBUG', 24 | 0: 'NOTSET', 25 | } 26 | 27 | default_format = '%(asctime)s %(name)s %(levelname)s %(message)s' 28 | default_level = None 29 | 30 | @classmethod 31 | def basicConfig(cls, format=None, level=None): 32 | if format: 33 | cls.default_format = format 34 | if level: 35 | cls.default_level = level 36 | 37 | class getLogger: 38 | def __init__(self, name): 39 | self._name = name 40 | self._level = logging.default_level 41 | 42 | def setLevel(self, level): 43 | """ setLevel() 44 | :param level: set logging level 45 | """ 46 | self._level = level 47 | 48 | def critical(self, s, *args): 49 | """ critical() 50 | :param s: string to display 51 | :param *args: additional to display 52 | """ 53 | if self._level and self._level <= logging.CRITICAL: 54 | print(self._d(), self._name, self._l(logging.CRITICAL), s % args) 55 | 56 | def error(self, s, *args): 57 | """ erro() 58 | :param s: string to display 59 | :param *args: additional to display 60 | """ 61 | if self._level and self._level <= logging.ERROR: 62 | print(self._d(), self._name, self._l(logging.ERROR), s % args) 63 | 64 | def warning(self, s, *args): 65 | """ warning() 66 | :param s: string to display 67 | :param *args: additional to display 68 | """ 69 | if self._level and self._level <= logging.WARNING: 70 | print(self._d(), self._name, self._l(logging.WARNING), s % args) 71 | 72 | def info(self, s, *args): 73 | """ info() 74 | :param s: string to display 75 | :param *args: additional to display 76 | """ 77 | if self._level and self._level <= logging.INFO: 78 | print(self._d(), self._name, self._l(logging.INFO), s % args) 79 | 80 | def debug(self, s, *args): 81 | """ debug() 82 | :param s: string to display 83 | :param *args: additional to display 84 | """ 85 | if self._level and self._level <= logging.DEBUG: 86 | print(self._d(), self._name, self._l(logging.DEBUG), s % args) 87 | 88 | def _d(self): 89 | """ :meta private: """ 90 | return datetime.utcnow() 91 | 92 | def _l(self, level): 93 | """ :meta private: """ 94 | try: 95 | return logging.level_names[level] 96 | except IndexError: 97 | return 'UNKNOWN' 98 | -------------------------------------------------------------------------------- /wwvb/maidenhead.py: -------------------------------------------------------------------------------- 1 | """ maidenhead.py 2 | 3 | Provide maidenhead conversion functions 4 | 5 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 6 | """ 7 | 8 | # 9 | # Quoting https://en.wikipedia.org/wiki/Maidenhead_Locator_System 10 | # 11 | # Character pairs encode longitude first, and then latitude. 12 | # The first pair (a field) encodes with base 18 and the letters "A" to "R". 13 | # The second pair (square) encodes with base 10 and the digits "0" to "9". 14 | # The third pair (subsquare) encodes with base 24 and the letters "a" to "x". 15 | # The fourth pair (extended square) encodes with base 10 and the digits "0" to "9". 16 | # 17 | # (The fifth and subsequent pairs are not formally defined, but recursing to the third and fourth pair algorithms is a possibility, e.g.: BL11bh16oo66) 18 | # 19 | 20 | def maidenhead(locator): 21 | """ maidenhead() """ 22 | 23 | if len(locator) not in [2,4,6,8,10,12]: 24 | raise ValueError('Invalid Maidenhead Locator') 25 | locator = locator.upper() 26 | lon = -180.0 27 | lat = -90.0 28 | 29 | if 'A' <= locator[0] <= 'R' and 'A' <= locator[1] <= 'R': 30 | lon += (ord(locator[0]) - ord('A')) * 360.0/18 31 | lat += (ord(locator[1]) - ord('A')) * 180.0/18 32 | else: 33 | raise ValueError('Invalid Maidenhead Locator') 34 | if len(locator) == 2: 35 | lon += 360.0/18 * 0.5 36 | lat += 180.0/18 * 0.5 37 | return [float(lat), float(lon)] 38 | if '0' <= locator[2] <= '9' and '0' <= locator[3] <= '9': 39 | lon += (ord(locator[2]) - ord('0')) * 360.0/18/10 40 | lat += (ord(locator[3]) - ord('0')) * 180.0/18/10 41 | else: 42 | raise ValueError('Invalid Maidenhead Locator') 43 | if len(locator) == 4: 44 | lon += 360.0/18/10 * 0.5 45 | lat += 180.0/18/10 * 0.5 46 | return [float(lat), float(lon)] 47 | if 'A' <= locator[4] <= 'X' and 'A' <= locator[5] <= 'X': 48 | lon += (ord(locator[4]) - ord('A')) * 360.0/18/10/24 49 | lat += (ord(locator[5]) - ord('A')) * 180.0/18/10/24 50 | else: 51 | raise ValueError('Invalid Maidenhead Locator') 52 | if len(locator) == 6: 53 | lon += 360.0/18/10/24 * 0.5 54 | lat += 180.0/18/10/24 * 0.5 55 | return [float(lat), float(lon)] 56 | if '0' <= locator[6] <= '9' and '0' <= locator[7] <= '9': 57 | lon += (ord(locator[6]) - ord('0')) * 360.0/18/10/24/10 58 | lat += (ord(locator[7]) - ord('0')) * 180.0/18/10/24/10 59 | else: 60 | raise ValueError('Invalid Maidenhead Locator') 61 | if len(locator) == 8: 62 | lon += 360.0/18/10/24/10 * 0.5 63 | lat += 180.0/18/10/24/10 * 0.5 64 | return [float(lat), float(lon)] 65 | if 'A' <= locator[8] <= 'X' and 'A' <= locator[9] <= 'X': 66 | lon += (ord(locator[8]) - ord('A')) * 360.0/18/10/24/10/24 67 | lat += (ord(locator[9]) - ord('A')) * 180.0/18/10/24/10/24 68 | else: 69 | raise ValueError('Invalid Maidenhead Locator') 70 | if len(locator) == 10: 71 | lon += 360.0/18/10/24/10/24 * 0.5 72 | lat += 180.0/18/10/24/10/24 * 0.5 73 | return [float(lat), float(lon)] 74 | if '0' <= locator[10] <= '9' and '0' <= locator[11] <= '9': 75 | lon += (ord(locator[10]) - ord('0')) * 360.0/18/10/24/10/24/10 76 | lat += (ord(locator[11]) - ord('0')) * 180.0/18/10/24/10/24/10 77 | else: 78 | raise ValueError('Invalid Maidenhead Locator') 79 | lon += 360.0/18/10/24/10/24/10 * 0.5 80 | lat += 180.0/18/10/24/10/24/10 * 0.5 81 | return [float(lat), float(lon)] 82 | -------------------------------------------------------------------------------- /es100/i2c_mcp2221_control.py: -------------------------------------------------------------------------------- 1 | """ i2c communications for ES100 2 | 3 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 4 | """ 5 | 6 | import os 7 | import sys 8 | import time 9 | 10 | os.environ['BLINKA_MCP2221'] = "1" 11 | try: 12 | import board 13 | except RuntimeError: 14 | board = None 15 | 16 | class ES100I2CError(Exception): 17 | """ ES100I2CError """ 18 | 19 | class ES100I2C: 20 | """ ES100I2C """ 21 | 22 | ERROR_DELAY_SEC = 0.001 # 1 ms delay if i2c read/write error 23 | 24 | def __init__(self, bus, address, debug=False): 25 | """ __init__ """ 26 | self._device = None 27 | self._debug = debug 28 | self._i2c_bus = bus 29 | self._i2c_address = address 30 | if self._i2c_bus != 0: 31 | raise ES100I2CError('i2c bus number error: %s' % bus) 32 | if not 0x08 <= self._i2c_address <= 0x77: 33 | raise ES100I2CError('i2c address number error: %s' % address) 34 | self.open() 35 | 36 | def __del__(self): 37 | """ __del__ """ 38 | if not self._device: 39 | return 40 | self.close() 41 | 42 | def open(self): 43 | """ _setup """ 44 | if self._device: 45 | # already open 46 | return 47 | 48 | if board is None: 49 | raise ES100GPIOError('MCP2221 not present') 50 | self._device = board.I2C() 51 | try: 52 | self._device.unlock() 53 | except ValueError: 54 | pass 55 | while not self._device.try_lock(): 56 | pass 57 | print("DEBUG: open() - OK", file=sys.stderr) 58 | 59 | def close(self): 60 | """ _close """ 61 | if self._device: 62 | try: 63 | self._device.unlock() 64 | except ValueError: 65 | pass 66 | self._device = None 67 | 68 | def read(self, addr): 69 | """ read """ 70 | count = 0 71 | print("DEBUG: read(%d)" % addr, file=sys.stderr) 72 | while True: 73 | try: 74 | registers = bytearray(14) 75 | self._device.readfrom_into(self._i2c_address, registers) 76 | print("DEBUG: readfrom_into: %s - 0x%02x" % (registers, registers[addr])) 77 | rval = registers[addr] 78 | return rval 79 | except OSError as err: 80 | print('DEBUG: i2c read: %s' % (err)) 81 | if count > 10: 82 | raise ES100I2CError('i2c read: %s' % (err)) from err 83 | time.sleep(ES100I2C.ERROR_DELAY_SEC) 84 | count += 1 85 | 86 | def write_addr(self, addr, data): 87 | """ write_addr """ 88 | print("DEBUG: write_addr(%d)" % addr, file=sys.stderr) 89 | count = 0 90 | while True: 91 | try: 92 | self._device.writeto(self._i2c_address, bytes([data]), start=addr) 93 | return 94 | except OSError as err: 95 | if count > 10: 96 | raise ES100I2CError('i2c write 0x%02x: %s' % (addr, err)) from err 97 | time.sleep(ES100I2C.ERROR_DELAY_SEC) 98 | count += 1 99 | 100 | def write(self, data): 101 | """ write """ 102 | print("DEBUG: write(%s)" % data, file=sys.stderr) 103 | count = 0 104 | while True: 105 | try: 106 | self._device.writeto(self._i2c_address, bytes([data])) 107 | return 108 | except OSError as err: 109 | if count > 10: 110 | raise ES100I2CError('i2c write 0x%02x: %s' % (data, err)) from err 111 | time.sleep(ES100I2C.ERROR_DELAY_SEC) 112 | count += 1 113 | -------------------------------------------------------------------------------- /wwvb/sun.py: -------------------------------------------------------------------------------- 1 | """ sun.py 2 | 3 | Provide sun tracking functions for CLI wwvb command 4 | 5 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 6 | """ 7 | 8 | import datetime 9 | from math import degrees 10 | 11 | try: 12 | from zoneinfo import ZoneInfo 13 | except ImportError: 14 | ZoneInfo = None 15 | 16 | import ephem 17 | 18 | class Sun: 19 | """ Sun() 20 | 21 | :param lat: Latitude (in decimal degrees) 22 | :param lon: Longitude (in decimal degrees) 23 | :param elev: Elevation in meters above sea level (MASL) 24 | :return: New instance 25 | 26 | Sun tracking code for sunset/sunrise math. 27 | Used to decide if a location is in nightime or not. 28 | 29 | All times are kept in UTC. 30 | 31 | Based on PyEphem (which is a fantastic package!) 32 | https://rhodesmill.org/pyephem/ 33 | """ 34 | 35 | def __init__(self, lat, lon, elev=0.0): 36 | """ :meta private: """ 37 | self._sun = ephem.Sun() 38 | self._viewer = ephem.Observer() 39 | self._viewer.date = datetime.datetime.utcnow() 40 | self._viewer.lat = str(lat) 41 | self._viewer.lon = str(lon) 42 | self._viewer.elev = elev 43 | self._viewer.horizon = '0' 44 | 45 | def altitude(self, dtime=None): 46 | """ altitude() 47 | 48 | :param dtime: Optional datetime value (defaults to now) 49 | :return: Altitude +/-90 degrees relative to the horizon’s great circle 50 | 51 | Apparent position relative to horizon in degrees (negative means sun has set). 52 | """ 53 | if dtime: 54 | self._viewer.date = dtime 55 | else: 56 | self._viewer.date = datetime.datetime.utcnow() 57 | # always (re)compute as time tends to march-on. 58 | self._sun.compute(self._viewer) 59 | # yes, humans prefer degrees 60 | return degrees(self._sun.alt) 61 | 62 | # look for twilight, which is 6, 12, or 18 degrees below horizon 63 | # see https://www.weather.gov/lmk/twilight-types 64 | 65 | def civil_twilight(self, dtime=None): 66 | """ civil_twilight() 67 | :param dtime: Optional datetime value (defaults to now) 68 | :return: True if it's civil twilight 69 | """ 70 | return bool(self.altitude(dtime) <= -6.0) 71 | 72 | def nautical_twilight(self, dtime=None): 73 | """ nautical_twilight() 74 | :param dtime: Optional datetime value (defaults to now) 75 | :return: True if it's nautical twilight 76 | """ 77 | return bool(self.altitude(dtime) <= -12.0) 78 | 79 | def astronomical_twilight(self, dtime=None): 80 | """ astronomical_twilight() 81 | :param dtime: Optional datetime value (defaults to now) 82 | :return: True if it's astronomical twilight 83 | """ 84 | return bool(self.altitude(dtime) <= -18.0) 85 | 86 | def rising_setting(self, dtime=None, tz='UTC'): 87 | """ next_rising() 88 | :param dtime: Optional datetime value (defaults to now) 89 | :return: date/time of next rising 90 | """ 91 | if dtime: 92 | self._viewer.date = dtime 93 | else: 94 | self._viewer.date = datetime.datetime.utcnow() 95 | if not ZoneInfo: 96 | # zoneinfo is new in Python version 3.9. 97 | raise ImportError('ZoneInfo is in Python 3.9 or above; please upgrade your Python') 98 | if tz == 'UTC': 99 | tz = ZoneInfo('UTC') 100 | else: 101 | tz = ZoneInfo(tz) 102 | # always (re)compute as time tends to march-on. 103 | self._sun.compute(self._viewer) 104 | return ( 105 | ephem.to_timezone(self._viewer.next_rising(self._sun), tz), 106 | ephem.to_timezone(self._viewer.next_setting(self._sun), tz) 107 | ) 108 | -------------------------------------------------------------------------------- /wwvb/config.py: -------------------------------------------------------------------------------- 1 | """ config.py 2 | 3 | Provide config file functions for CLI wwvb command 4 | 5 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 6 | """ 7 | 8 | import os 9 | import configparser 10 | 11 | def readconfig(filename='wwvb.ini'): 12 | """ readconfig() 13 | :param filename: config file name 14 | 15 | Read config file (which is wwvb.ini by default. 16 | """ 17 | cp = configparser.ConfigParser() 18 | try: 19 | cp.read([ 20 | '.wwvb.ini', 21 | 'wwvb.ini', 22 | os.path.expanduser('~/.wwvb.ini'), 23 | '/etc/wwvb.ini' 24 | ], 'utf-8') 25 | except: 26 | # no configuration file - this is not an error; we are just done here 27 | return {} 28 | 29 | values = {} 30 | 31 | our_station = None 32 | 33 | section = 'WWVB' 34 | if cp.has_section(section): 35 | for option in ['bus', 'address', 'irq', 'en']: 36 | config_value = cp.get(section, option, fallback=None) 37 | if isinstance(config_value, str) and len(config_value) == 0: 38 | config_value = None 39 | try: 40 | if config_value is not None: 41 | config_value = int(config_value) 42 | except (ValueError, TypeError): 43 | pass 44 | values[section.lower() + '.' + option] = config_value 45 | for option in ['nighttime', 'tracking']: 46 | config_value = cp.getboolean(section, option, fallback=False) 47 | values[section.lower() + '.' + option] = config_value 48 | for option in ['station']: 49 | config_value = cp.get(section, option, fallback=None) 50 | if isinstance(config_value, str) and len(config_value) == 0: 51 | config_value = None 52 | if config_value: 53 | values[section.lower() + '.' + option] = config_value.lower() 54 | # special case for station 55 | our_station = config_value 56 | 57 | section = 'DEBUG' 58 | if cp.has_section(section): 59 | for option in ['debug', 'verbose']: 60 | config_value = cp.getboolean(section, option, fallback=False) 61 | values[section.lower() + '.' + option] = config_value 62 | 63 | section = 'NTPD' 64 | if cp.has_section(section): 65 | for option in ['unit']: 66 | config_value = cp.get(section, option, fallback=None) 67 | if isinstance(config_value, str) and len(config_value) == 0: 68 | config_value = None 69 | try: 70 | if config_value is not None: 71 | config_value = int(config_value) 72 | except (ValueError, TypeError): 73 | pass 74 | values[section.lower() + '.' + option] = config_value 75 | 76 | if our_station: 77 | if cp.has_section(our_station): 78 | section = our_station 79 | for option in ['name', 'location', 'masl', 'antenna']: 80 | config_value = cp.get(section, option, fallback=None) 81 | if isinstance(config_value, str) and len(config_value) == 0: 82 | config_value = None 83 | try: 84 | if config_value is not None: 85 | config_value = int(config_value) 86 | except (ValueError, TypeError): 87 | pass 88 | if isinstance(config_value, str) and len(config_value) > 0 and config_value[0] == '[' and config_value[-1] == ']': 89 | # convert to list 90 | config_value = config_value[1:-1].split(',') 91 | config_value = [v.strip() for v in config_value] 92 | try: 93 | config_value = [float(v) for v in config_value] 94 | except (ValueError, TypeError): 95 | pass 96 | 97 | values[section.lower() + '.' + option] = config_value 98 | 99 | return values 100 | -------------------------------------------------------------------------------- /es100/gpio_mcp2221_control.py: -------------------------------------------------------------------------------- 1 | """ GPIO control (for EN & IRQ lines) via MCP2221 2 | 3 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 4 | """ 5 | 6 | import os 7 | import sys 8 | import time 9 | 10 | os.environ['BLINKA_MCP2221'] = "1" 11 | try: 12 | import board 13 | import digitalio 14 | except RuntimeError: 15 | board = None 16 | 17 | IRQ_WAKEUP_DELAY = 2 # When waiting for an IRQ; wake up after this time and loop again 18 | 19 | class ES100GPIOError(Exception): 20 | """ ES100GPIOError 21 | 22 | ES100GPIOError is raised should errors occur when using ES100GPIO() class. 23 | """ 24 | 25 | def irq_wait_for_edge(gpio_irq, timeout=None): 26 | # XXX TODO - not correct; but could work 27 | time.sleep(0.001) 28 | return True 29 | 30 | class ES100GPIO(): 31 | """ ES100GPIO_MCP2221 32 | 33 | :param en: EN pin number 34 | :param irq: IRQ pin number 35 | :param debug: True to enable debug messages 36 | :return: New instance of ES100GPIO_MCP2221() 37 | 38 | All GPIO control is via ES100GPIO() class. 39 | """ 40 | 41 | def __init__(self, en=None, irq=None, debug=False): 42 | """ """ 43 | if en is None or irq is None: 44 | raise ES100GPIOError('GPIO must be defined - no default provided') 45 | self._gpio_en = en 46 | self._gpio_irq = irq 47 | self._debug = debug 48 | self._setup() 49 | 50 | def _setup(self): 51 | """ _setup """ 52 | if board is None: 53 | raise ES100GPIOError('MCP2221 not present') 54 | self._gpio_en = digitalio.DigitalInOut(getattr(board, 'G%d' % self._gpio_en)) 55 | self._gpio_en.direction = digitalio.Direction.OUTPUT 56 | self._gpio_irq = digitalio.DigitalInOut(getattr(board, 'G%d' % self._gpio_irq)) 57 | self._gpio_en.direction = digitalio.Direction.INPUT 58 | 59 | def __del__(self): 60 | """ __del__ """ 61 | self._close() 62 | 63 | def _close(self): 64 | """ _close """ 65 | self.en_low() 66 | 67 | def en_low(self): 68 | """ en_low() 69 | 70 | EN set low 71 | """ 72 | # Enable Input. When low, the ES100 powers down all circuitry. 73 | self._gpio_en = False 74 | 75 | def en_high(self): 76 | """ en_high() 77 | 78 | EN set high 79 | """ 80 | # Enable Input. When high, the device is operational. 81 | self._gpio_en = True 82 | 83 | def irq_wait(self, timeout=None): 84 | """ irq_wait(self, timeout=None) 85 | 86 | :param timeout: Either None or the number of seconds to control timeout 87 | :return: True if IRQ/Interrupt is active low, False with timeout 88 | 89 | IRQ- will go active low once the receiver has some info to return. 90 | """ 91 | # IRQ/Interrupt is active low to signal data available 92 | if self._debug: 93 | sys.stderr.write('IRQ WAIT: ') 94 | # sys.stderr.flush() 95 | while True: 96 | if not self._gpio_irq: 97 | break 98 | if self._debug: 99 | sys.stderr.write('H') 100 | # sys.stderr.flush() 101 | # now wait (for any transition) - way better than looping, sleeping, and checking 102 | if timeout: 103 | this_timeout=min(int(timeout*1000), IRQ_WAKEUP_DELAY*1000) 104 | else: 105 | this_timeout=IRQ_WAKEUP_DELAY*1000 106 | channel = irq_wait_for_edge(self._gpio_irq, timeout=this_timeout) 107 | if channel is None: 108 | # timeout happened 109 | if self._debug: 110 | sys.stderr.write('.') 111 | # sys.stderr.flush() 112 | if timeout: 113 | timeout -= IRQ_WAKEUP_DELAY 114 | if timeout <= 0: 115 | if self._debug: 116 | sys.stderr.write(' T\n') 117 | # sys.stderr.flush() 118 | return False 119 | if self._debug: 120 | sys.stderr.write(' L\n') 121 | # sys.stderr.flush() 122 | return True 123 | -------------------------------------------------------------------------------- /wwvb/misc.py: -------------------------------------------------------------------------------- 1 | """ misc.py 2 | 3 | Provide misc functions for CLI wwvb command 4 | 5 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 6 | """ 7 | 8 | from math import degrees, radians, sin, cos, acos, atan2 9 | 10 | from .sun import Sun 11 | from .maidenhead import maidenhead 12 | 13 | WWVB_FT_COLLINS = [40.6777225, -105.047153, 1585] # latitude, longitude, and MASL 14 | 15 | RADIOWAVE_SPEED = 299250.0 # km / sec 16 | 17 | def convert_location(lat_lon): 18 | """ convert_location """ 19 | 20 | if isinstance(lat_lon, str): 21 | try: 22 | # could it be a Ham Radio Maidenhead Locator? 23 | return maidenhead(lat_lon) 24 | except ValueError: 25 | # nope, but it was worth a try! 26 | pass 27 | if isinstance(lat_lon, list): 28 | lat = lat_lon[0] 29 | lon = lat_lon[1] 30 | else: 31 | # try to split a string 32 | try: 33 | if ',' in lat_lon: 34 | (lat, lon) = lat_lon.split(',',2) 35 | elif ' ' in lat_lon: 36 | (lat, lon) = lat_lon.split(' ',2) 37 | else: 38 | raise ValueError('invalid lat lon format') 39 | except ValueError as err: 40 | raise ValueError from err 41 | 42 | if isinstance(lat, float) and isinstance(lon, float): 43 | # done! 44 | return [lat, lon] 45 | 46 | lat = lat.strip() 47 | lon = lon.strip() 48 | if lat[-1] == 'N': 49 | lat = '+' + lat[:-1] 50 | elif lat[-1] == 'S': 51 | lat = '-' + lat[:-1] 52 | if lon[-1] == 'E': 53 | lon = '+' + lon[:-1] 54 | elif lon[-1] == 'W': 55 | lon = '-' + lon[:-1] 56 | 57 | # both these cases are bogus and should not happen 58 | if lat[0:2] in ['--', '-+', '+-', '++']: 59 | lat = lat[1:] 60 | if lon[0:2] in ['--', '-+', '+-', '++']: 61 | lon = lon[1:] 62 | 63 | try: 64 | return [float(lat), float(lon)] 65 | except ValueError as err: 66 | raise ValueError from err 67 | 68 | def caculate_latency(my_lat, my_lon): 69 | """ caculate_latency """ 70 | 71 | distance_km = great_circle_km( 72 | my_lat, my_lon, 73 | WWVB_FT_COLLINS[0], WWVB_FT_COLLINS[1]) 74 | 75 | bearing = bearing_degrees( 76 | my_lat, my_lon, 77 | WWVB_FT_COLLINS[0], WWVB_FT_COLLINS[1]) 78 | 79 | latency_secs = distance_km / RADIOWAVE_SPEED 80 | 81 | return (distance_km, bearing, latency_secs) 82 | 83 | sun_at_wwvb_ft_collins = None 84 | sun_at_my_receiver = None 85 | 86 | def is_it_nighttime(my_lat, my_lon, my_masl, dtime=None): 87 | """ is_it_nighttime """ 88 | global sun_at_wwvb_ft_collins, sun_at_my_receiver 89 | 90 | if not sun_at_wwvb_ft_collins: 91 | sun_at_wwvb_ft_collins = Sun(WWVB_FT_COLLINS[0], WWVB_FT_COLLINS[1], WWVB_FT_COLLINS[2]) 92 | if not sun_at_my_receiver: 93 | sun_at_my_receiver = Sun(my_lat, my_lon, my_masl) 94 | 95 | # VLW Reception is better at night (you learned that when becoming a ham radio operator) 96 | # if both location are dark presently, then radio waves should flow 97 | return bool(sun_at_wwvb_ft_collins.civil_twilight(dtime) and sun_at_my_receiver.civil_twilight(dtime)) 98 | 99 | # You can double check this caculation via GCMAP website. 100 | # http://www.gcmap.com/mapui?P=WWVB%3D40.678062N105.046688W%3B%0D%0ASJC-WWVB&R=1505Km%40WWVB&PM=b%3Adisc7%2B%22%25U%25+%28N%22&MS=wls2&DU=km 101 | 102 | def great_circle_km(lat1, lon1, lat2, lon2): 103 | """ great_circle """ 104 | # https://medium.com/@petehouston/calculate-distance-of-two-locations-on-earth-using-python-1501b1944d97 105 | lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2]) 106 | return 6371 * (acos(sin(lat1) * sin(lat2) + cos(lat1) * cos(lat2) * cos(lon1 - lon2))) 107 | 108 | def bearing_degrees(lat1, lon1, lat2, lon2): 109 | """ bearing_degrees """ 110 | # https://stackoverflow.com/questions/54873868/python-calculate-bearing-between-two-lat-long 111 | lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2]) 112 | x = cos(lat2) * sin(lon2 - lon1) 113 | y = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(lon2 - lon1) 114 | bearing = degrees(atan2(x, y)) 115 | if bearing < 0.0: 116 | return bearing + 360.0 117 | return bearing 118 | -------------------------------------------------------------------------------- /es100/i2c_control.py: -------------------------------------------------------------------------------- 1 | """ i2c communications for ES100 2 | 3 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 4 | """ 5 | 6 | import time 7 | 8 | DEVICE_LIBRARY_UNKNOWN = 0 9 | DEVICE_LIBRARY_SMBUS = 1 10 | DEVICE_LIBRARY_I2C = 2 11 | 12 | DEVICE_LIBRARY = DEVICE_LIBRARY_UNKNOWN 13 | 14 | try: 15 | # Linux or Mac's or something like that ... 16 | from smbus import SMBus 17 | DEVICE_LIBRARY = DEVICE_LIBRARY_SMBUS 18 | except ImportError: 19 | pass 20 | 21 | try: 22 | # Micropython on Raspberry Pi Pico (or Pico W) 23 | from machine import I2C 24 | DEVICE_LIBRARY = DEVICE_LIBRARY_I2C 25 | except ImportError: 26 | pass 27 | 28 | class ES100I2CError(Exception): 29 | """ ES100I2CError """ 30 | 31 | class ES100I2C: 32 | """ ES100I2C """ 33 | 34 | ERROR_DELAY_SEC = 0.001 # 1 ms delay if i2c read/write error 35 | 36 | def __init__(self, bus, address, debug=False): 37 | """ __init__ """ 38 | self._device = None 39 | if DEVICE_LIBRARY == DEVICE_LIBRARY_UNKNOWN: 40 | raise ES100I2CError('import SMBus or I2C failed - are you on a Raspberry Pi?') 41 | self._debug = debug 42 | self._i2c_bus = bus 43 | self._i2c_address = address 44 | #if not 0 <= self._i2c_bus <= 1: 45 | # raise ES100I2CError('i2c bus number error: %s' % bus) 46 | if not 0x08 <= self._i2c_address <= 0x77: 47 | raise ES100I2CError('i2c address number error: %s' % address) 48 | self.open() 49 | 50 | def __del__(self): 51 | """ __del__ """ 52 | if not self._device: 53 | return 54 | self.close() 55 | 56 | def open(self): 57 | """ _setup """ 58 | if self._device: 59 | # already open 60 | return 61 | 62 | try: 63 | if DEVICE_LIBRARY == DEVICE_LIBRARY_SMBUS: 64 | self._device = SMBus(self._i2c_bus) 65 | #self._device.open(self._i2c_bus) # not needed if passed on class creation 66 | if DEVICE_LIBRARY == DEVICE_LIBRARY_I2C: 67 | # Presently there's no Pin() passing option to this code; hence ... 68 | # bus0 -> I2C(0, freq=399361, scl=5, sda=4) i.e GP5 (pin 7) & GP4 (pin 6) 69 | # bus1 -> I2C(1, freq=399361, scl=7, sda=6) i.e GP7 (pin 10) & GP6 (pin 9) 70 | # ... use these deaults 71 | self._device = I2C(self._i2c_bus) 72 | except FileNotFoundError as err: 73 | raise ES100I2CError('i2c bus %d open error: %s' % (self._i2c_bus, err)) from err 74 | 75 | def close(self): 76 | """ _close """ 77 | if self._device: 78 | if DEVICE_LIBRARY == DEVICE_LIBRARY_SMBUS: 79 | self._device.close() 80 | if DEVICE_LIBRARY == DEVICE_LIBRARY_I2C: 81 | pass 82 | self._device = None 83 | 84 | def read(self, addr=0): 85 | """ read """ 86 | count = 0 87 | while True: 88 | try: 89 | if DEVICE_LIBRARY == DEVICE_LIBRARY_SMBUS: 90 | rval = self._device.read_byte(self._i2c_address) 91 | if DEVICE_LIBRARY == DEVICE_LIBRARY_I2C: 92 | rval = self._device.readfrom(self._i2c_address, 1) 93 | rval = rval[0] 94 | return rval 95 | except OSError as err: 96 | if count > 10: 97 | raise ES100I2CError('i2c read: %s' % (err)) from err 98 | time.sleep(ES100I2C.ERROR_DELAY_SEC) 99 | count += 1 100 | 101 | def write_addr(self, addr, data): 102 | """ write_addr """ 103 | count = 0 104 | while True: 105 | try: 106 | if DEVICE_LIBRARY == DEVICE_LIBRARY_SMBUS: 107 | self._device.write_byte_data(self._i2c_address, addr, data) 108 | if DEVICE_LIBRARY == DEVICE_LIBRARY_I2C: 109 | self._device.writeto_mem(self._i2c_address, addr, bytes([data])) 110 | return 111 | except OSError as err: 112 | if count > 10: 113 | raise ES100I2CError('i2c write 0x%02x: %s' % (addr, err)) from err 114 | time.sleep(ES100I2C.ERROR_DELAY_SEC) 115 | count += 1 116 | 117 | def write(self, data): 118 | """ write """ 119 | count = 0 120 | while True: 121 | try: 122 | if DEVICE_LIBRARY == DEVICE_LIBRARY_SMBUS: 123 | self._device.write_byte(self._i2c_address, data) 124 | if DEVICE_LIBRARY == DEVICE_LIBRARY_I2C: 125 | self._device.writeto(self._i2c_address, bytes([data])) 126 | return 127 | except OSError as err: 128 | if count > 10: 129 | raise ES100I2CError('i2c write 0x%02x: %s' % (data, err)) from err 130 | time.sleep(ES100I2C.ERROR_DELAY_SEC) 131 | count += 1 132 | -------------------------------------------------------------------------------- /pico/ssd1306.py: -------------------------------------------------------------------------------- 1 | # MicroPython SSD1306 OLED driver, I2C and SPI interfaces 2 | # 3 | # library taken from repository at: 4 | # https://github.com/micropython/micropython/blob/master/drivers/display/ssd1306.py 5 | # 6 | from micropython import const 7 | import framebuf 8 | 9 | # register definitions 10 | SET_CONTRAST = const(0x81) 11 | SET_ENTIRE_ON = const(0xA4) 12 | SET_NORM_INV = const(0xA6) 13 | SET_DISP = const(0xAE) 14 | SET_MEM_ADDR = const(0x20) 15 | SET_COL_ADDR = const(0x21) 16 | SET_PAGE_ADDR = const(0x22) 17 | SET_DISP_START_LINE = const(0x40) 18 | SET_SEG_REMAP = const(0xA0) 19 | SET_MUX_RATIO = const(0xA8) 20 | SET_COM_OUT_DIR = const(0xC0) 21 | SET_DISP_OFFSET = const(0xD3) 22 | SET_COM_PIN_CFG = const(0xDA) 23 | SET_DISP_CLK_DIV = const(0xD5) 24 | SET_PRECHARGE = const(0xD9) 25 | SET_VCOM_DESEL = const(0xDB) 26 | SET_CHARGE_PUMP = const(0x8D) 27 | 28 | # Subclassing FrameBuffer provides support for graphics primitives 29 | # http://docs.micropython.org/en/latest/pyboard/library/framebuf.html 30 | class SSD1306(framebuf.FrameBuffer): 31 | def __init__(self, width, height, external_vcc): 32 | self.width = width 33 | self.height = height 34 | self.external_vcc = external_vcc 35 | self.pages = self.height // 8 36 | self.buffer = bytearray(self.pages * self.width) 37 | super().__init__(self.buffer, self.width, self.height, framebuf.MONO_VLSB) 38 | self.init_display() 39 | 40 | def init_display(self): 41 | for cmd in ( 42 | SET_DISP | 0x00, # off 43 | # address setting 44 | SET_MEM_ADDR, 45 | 0x00, # horizontal 46 | # resolution and layout 47 | SET_DISP_START_LINE | 0x00, 48 | SET_SEG_REMAP | 0x01, # column addr 127 mapped to SEG0 49 | SET_MUX_RATIO, 50 | self.height - 1, 51 | SET_COM_OUT_DIR | 0x08, # scan from COM[N] to COM0 52 | SET_DISP_OFFSET, 53 | 0x00, 54 | SET_COM_PIN_CFG, 55 | 0x02 if self.width > 2 * self.height else 0x12, 56 | # timing and driving scheme 57 | SET_DISP_CLK_DIV, 58 | 0x80, 59 | SET_PRECHARGE, 60 | 0x22 if self.external_vcc else 0xF1, 61 | SET_VCOM_DESEL, 62 | 0x30, # 0.83*Vcc 63 | # display 64 | SET_CONTRAST, 65 | 0xFF, # maximum 66 | SET_ENTIRE_ON, # output follows RAM contents 67 | SET_NORM_INV, # not inverted 68 | # charge pump 69 | SET_CHARGE_PUMP, 70 | 0x10 if self.external_vcc else 0x14, 71 | SET_DISP | 0x01, 72 | ): # on 73 | self.write_cmd(cmd) 74 | self.fill(0) 75 | self.show() 76 | 77 | def poweroff(self): 78 | self.write_cmd(SET_DISP | 0x00) 79 | 80 | def poweron(self): 81 | self.write_cmd(SET_DISP | 0x01) 82 | 83 | def contrast(self, contrast): 84 | self.write_cmd(SET_CONTRAST) 85 | self.write_cmd(contrast) 86 | 87 | def invert(self, invert): 88 | self.write_cmd(SET_NORM_INV | (invert & 1)) 89 | 90 | def show(self): 91 | x0 = 0 92 | x1 = self.width - 1 93 | if self.width == 64: 94 | # displays with width of 64 pixels are shifted by 32 95 | x0 += 32 96 | x1 += 32 97 | self.write_cmd(SET_COL_ADDR) 98 | self.write_cmd(x0) 99 | self.write_cmd(x1) 100 | self.write_cmd(SET_PAGE_ADDR) 101 | self.write_cmd(0) 102 | self.write_cmd(self.pages - 1) 103 | self.write_data(self.buffer) 104 | 105 | 106 | class SSD1306_I2C(SSD1306): 107 | def __init__(self, width, height, i2c, addr=0x3C, external_vcc=False): 108 | self.i2c = i2c 109 | self.addr = addr 110 | self.temp = bytearray(2) 111 | self.write_list = [b"\x40", None] # Co=0, D/C#=1 112 | super().__init__(width, height, external_vcc) 113 | 114 | def write_cmd(self, cmd): 115 | self.temp[0] = 0x80 # Co=1, D/C#=0 116 | self.temp[1] = cmd 117 | self.i2c.writeto(self.addr, self.temp) 118 | 119 | def write_data(self, buf): 120 | self.write_list[1] = buf 121 | self.i2c.writevto(self.addr, self.write_list) 122 | 123 | # only required for SPI version (not covered in this project) 124 | class SSD1306_SPI(SSD1306): 125 | def __init__(self, width, height, spi, dc, res, cs, external_vcc=False): 126 | self.rate = 10 * 1024 * 1024 127 | dc.init(dc.OUT, value=0) 128 | res.init(res.OUT, value=0) 129 | cs.init(cs.OUT, value=1) 130 | self.spi = spi 131 | self.dc = dc 132 | self.res = res 133 | self.cs = cs 134 | import time 135 | 136 | self.res(1) 137 | time.sleep_ms(1) 138 | self.res(0) 139 | time.sleep_ms(10) 140 | self.res(1) 141 | super().__init__(width, height, external_vcc) 142 | 143 | def write_cmd(self, cmd): 144 | self.spi.init(baudrate=self.rate, polarity=0, phase=0) 145 | self.cs(1) 146 | self.dc(0) 147 | self.cs(0) 148 | self.spi.write(bytearray([cmd])) 149 | self.cs(1) 150 | 151 | def write_data(self, buf): 152 | self.spi.init(baudrate=self.rate, polarity=0, phase=0) 153 | self.cs(1) 154 | self.dc(1) 155 | self.cs(0) 156 | self.spi.write(buf) 157 | self.cs(1) 158 | -------------------------------------------------------------------------------- /pico/datetime.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | datetime and timezone for micropython - just the bare minimum 4 | 5 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 6 | """ 7 | 8 | from machine import RTC 9 | import utime 10 | 11 | # The RTC() does not provide millseconds/microseconds - however, there is a way to fake it using 12 | # the ticks_ms()'s call. 13 | # https://github.com/charlee/rp2_led_matrix/blob/master/src/lib/rtc_clock.py 14 | 15 | class datetime: 16 | """ datetime() 17 | 18 | :param year: Year 19 | :param month: Month 20 | :param day: Day 21 | :param hour: Hour 22 | :param minute: Minute 23 | :param second: Second 24 | :param microsecond: Microsecond 25 | :param tzinfo: TZ 26 | :return: datetime() instance 27 | 28 | Replacement for normal Python datetime(). Minimal implementation. 29 | """ 30 | 31 | # needs to be caculated at some point - see end of file 32 | initial_ticks_ms = 0 33 | 34 | def __init__(self, year, month, day, hour=0, minute=0, second=0, microsecond=0, tzinfo=None): 35 | """ :meta private: """ 36 | self._d = (year, month, day) 37 | self._t = (hour, minute, second, microsecond) 38 | self._tz = tzinfo 39 | 40 | # Via https://github.com/charlee/rp2_led_matrix/blob/master/src/lib/rtc_clock.py 41 | # Thank you @charlee 42 | @classmethod 43 | def calibrate(cls): 44 | """ calibrate() 45 | Adjust the sub-second counter accoring to the RTC. 46 | """ 47 | start_second = RTC().datetime()[6] 48 | while RTC().datetime()[6] == start_second: 49 | utime.sleep_ms(1) 50 | # It's a new seccond - record the millisecond point 51 | cls.initial_ticks_ms = utime.ticks_ms() % 1000 # we only need the 1000's part 52 | 53 | def replace(self, year=None, month=None, day=None, hour=None, minute=None, second=None, microsecond=None, tzinfo=True): 54 | """ replace() 55 | :param year: Year 56 | :param month: Month 57 | :param day: Day 58 | :param hour: Hour 59 | :param minute: Minute 60 | :param second: Second 61 | :param microsecond: Microsecond 62 | :param tzinfo: TZ 63 | :return: datetime() instance 64 | 65 | Replacement for normal Python datetime.replace(). Minimal implementation. 66 | """ 67 | if year is None: 68 | year = self._d[0] 69 | if month is None: 70 | month = self._d[1] 71 | if day is None: 72 | day = self._d[2] 73 | if hour is None: 74 | hour = self._t[0] 75 | if minute is None: 76 | minute = self._t[1] 77 | if second is None: 78 | second = self._t[2] 79 | if microsecond is None: 80 | microsecond = self._t[3] 81 | if tzinfo is True: 82 | tzinfo = self._tz 83 | return datetime(year, month, day, hour, minute, second, microsecond, tzinfo) 84 | 85 | def total_seconds(self): 86 | """ total_seconds() 87 | return: The total number of seconds (for hour, minute, second) values 88 | Replacement for normal Python datetime.replace(). Minimal implementation. 89 | """ 90 | return self._t[0] * 60 * 60 + self._t[1] * 60 + self._t[2] + self._t[3]/1000000.0 91 | 92 | @classmethod 93 | def utcnow(cls): 94 | """ utcnow() 95 | :return: Now as a datetime() option 96 | Replacement for normal Python datetime.replace(). Minimal implementation. 97 | """ 98 | # RTC return weekday (which is useless) and microsecond which is inaccurate or zero 99 | (year, month, day, _, hour, minute, second, _) = RTC().datetime() 100 | subseconds_ms = int((utime.ticks_ms() - datetime.initial_ticks_ms) % 1000) 101 | return datetime(year, month, day, hour, minute, second, subseconds_ms * 1000) 102 | 103 | @classmethod 104 | def setrtc(cls, dt): 105 | """ setrtc() 106 | :param dt: datetime of new "now" 107 | 108 | set RTC 109 | """ 110 | # setting microseconds does nothing 111 | now = (dt.year, dt.month, dt.day, 0, dt.hour, dt.minute, dt.second, 0) 112 | # we should adjust calibrate so it's still correct 113 | cls.initial_ticks_ms = utime.ticks_ms() % 1000 114 | RTC().datetime(now) 115 | 116 | @property 117 | def year(self): 118 | """ :return: year value """ 119 | return self._d[0] 120 | 121 | @property 122 | def month(self): 123 | """ :return: month value """ 124 | return self._d[1] 125 | 126 | @property 127 | def day(self): 128 | """ :return: day value """ 129 | return self._d[2] 130 | 131 | @property 132 | def hour(self): 133 | """ :return: hour value """ 134 | return self._t[0] 135 | 136 | @property 137 | def minute(self): 138 | """ :return: minute value """ 139 | return self._t[1] 140 | 141 | @property 142 | def second(self): 143 | """ :return: second value """ 144 | return self._t[2] 145 | 146 | @property 147 | def microsecond(self): 148 | """ :return: microsecond value """ 149 | return self._t[3] 150 | 151 | def __sub__(self, other): 152 | """ :meta private: """ 153 | return datetime( 154 | self._d[0] - other._d[0], self._d[1] - other._d[1], self._d[2] - other._d[2], 155 | self._t[0] - other._t[0], self._t[1] - other._t[1], self._t[2] - other._t[2], 156 | self._t[3] - other._t[3], 157 | self._tz) 158 | 159 | def __str__(self): 160 | """ :meta private: """ 161 | if self._tz: 162 | # return with timezone 163 | return '%04d-%02d-%02d %02d:%02d:%02d.%03d%s' % ( 164 | self._d[0], self._d[1], self._d[2], 165 | self._t[0], self._t[1], self._t[2], self._t[3]/1000, 166 | self._tz 167 | ) 168 | # return with no timezone 169 | return '%04d-%02d-%02d %02d:%02d:%02d.%03d' % ( 170 | self._d[0], self._d[1], self._d[2], 171 | self._t[0], self._t[1], self._t[2], self._t[3]/1000, 172 | ) 173 | 174 | def __repr__(self): 175 | """ :meta private: """ 176 | return self.__str__() 177 | 178 | # Lets calibrate the millseconds - could take from 0 to 999 milliseconds 179 | datetime.calibrate() 180 | 181 | # timezone only implements UTC 182 | class timezone: 183 | """ timezone() """ 184 | def __init__(self, offset): 185 | """ :meta private: """ 186 | self._offset = offset 187 | 188 | def __str__(self): 189 | """ :meta private: """ 190 | return '+00:00' 191 | 192 | timezone.utc = timezone(0) 193 | -------------------------------------------------------------------------------- /es100/gpio_control.py: -------------------------------------------------------------------------------- 1 | """ GPIO control (for EN & IRQ lines) 2 | 3 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 4 | """ 5 | 6 | import os 7 | import sys 8 | import time 9 | 10 | DEVICE_LIBRARY_UNKNOWN = 0 11 | DEVICE_LIBRARY_GPIO = 1 12 | DEVICE_LIBRARY_PIN = 2 13 | DEVICE_LIBRARY_BLINKA = 3 14 | 15 | DEVICE_LIBRARY = DEVICE_LIBRARY_UNKNOWN 16 | 17 | try: 18 | import RPi.GPIO as GPIO 19 | DEVICE_LIBRARY = DEVICE_LIBRARY_GPIO 20 | except ImportError: 21 | pass 22 | 23 | try: 24 | from machine import Pin 25 | DEVICE_LIBRARY = DEVICE_LIBRARY_PIN 26 | except ImportError: 27 | pass 28 | 29 | try: 30 | # https://docs.circuitpython.org/projects/blinka/en/latest/ 31 | # https://learn.adafruit.com/circuitpython-libraries-on-any-computer-with-mcp2221?view=all 32 | # pip install --upgrade adafruit-blinka 33 | os.environ['BLINKA_MCP2221'] = "1" 34 | import board 35 | import digitalio 36 | DEVICE_LIBRARY = DEVICE_LIBRARY_BLINKA 37 | except ImportError: 38 | pass 39 | 40 | if DEVICE_LIBRARY == DEVICE_LIBRARY_PIN: 41 | from pico.irq_wait_for_edge import irq_wait_for_edge 42 | 43 | if DEVICE_LIBRARY == DEVICE_LIBRARY_BLINKA: 44 | def irq_wait_for_edge(irq, timeout): 45 | ### XXX TODO needs work!!! 46 | time.sleep(0.001) 47 | return True 48 | 49 | IRQ_WAKEUP_DELAY = 2 # When waiting for an IRQ; wake up after this time and loop again 50 | 51 | class ES100GPIOError(Exception): 52 | """ ES100GPIOError 53 | 54 | ES100GPIOError is raised should errors occur when using ES100GPIO() class. 55 | """ 56 | 57 | class ES100GPIO: 58 | """ ES100GPIO 59 | 60 | :param en: EN pin number 61 | :param irq: IRQ pin number 62 | :param use_gpiod: use gpiod library (if present) 63 | :param debug: True to enable debug messages 64 | :return: New instance of ES100GPIO() 65 | 66 | All GPIO control is via ES100GPIO() class. 67 | """ 68 | 69 | def __init__(self, en=None, irq=None, use_gpiod=False, debug=False): 70 | """ """ 71 | if DEVICE_LIBRARY == DEVICE_LIBRARY_UNKNOWN: 72 | raise ES100GPIOError('import RPi.GPIO or machine failed - are you on a Raspberry Pi?') 73 | if en is None or irq is None: 74 | raise ES100GPIOError('GPIO must be defined - no default provided') 75 | self._gpio_en = en 76 | self._gpio_irq = irq 77 | self._use_gpiod = use_gpiod 78 | self._debug = debug 79 | self._setup() 80 | 81 | def _setup(self): 82 | if DEVICE_LIBRARY == DEVICE_LIBRARY_GPIO: 83 | GPIO.setwarnings(False) 84 | GPIO.setmode(GPIO.BOARD) 85 | GPIO.setup(self._gpio_en, GPIO.OUT) 86 | GPIO.setup(self._gpio_irq, GPIO.IN, GPIO.PUD_DOWN) 87 | if DEVICE_LIBRARY == DEVICE_LIBRARY_PIN: 88 | self._gpio_en = Pin('GP%d' % self._gpio_en, Pin.OUT) 89 | self._gpio_irq = Pin('GP%d'% self._gpio_irq, Pin.IN, Pin.PULL_DOWN) 90 | if DEVICE_LIBRARY == DEVICE_LIBRARY_BLINKA: 91 | self._gpio_en = digitalio.DigitalInOut(getattr(board, 'G%d' % self._gpio_en)) 92 | self._gpio_en.direction = digitalio.Direction.OUTPUT 93 | self._gpio_irq = digitalio.DigitalInOut(getattr(board, 'G%d' % self._gpio_irq)) 94 | self._gpio_irq.direction = digitalio.Direction.INPUT 95 | 96 | def __del__(self): 97 | """ __del__ """ 98 | self._close() 99 | 100 | def _close(self): 101 | """ _close """ 102 | self.en_low() 103 | if DEVICE_LIBRARY == DEVICE_LIBRARY_GPIO: 104 | GPIO.cleanup() 105 | if DEVICE_LIBRARY == DEVICE_LIBRARY_PIN: 106 | pass 107 | if DEVICE_LIBRARY == DEVICE_LIBRARY_BLINKA: 108 | pass 109 | 110 | def en_low(self): 111 | """ en_low() 112 | 113 | EN set low 114 | """ 115 | # Enable Input. When low, the ES100 powers down all circuitry. 116 | if DEVICE_LIBRARY == DEVICE_LIBRARY_GPIO: 117 | GPIO.output(self._gpio_en, GPIO.LOW) 118 | if DEVICE_LIBRARY == DEVICE_LIBRARY_PIN: 119 | self._gpio_en.off() 120 | if DEVICE_LIBRARY == DEVICE_LIBRARY_BLINKA: 121 | self._gpio_en.value = False 122 | 123 | def en_high(self): 124 | """ en_high() 125 | 126 | EN set high 127 | """ 128 | # Enable Input. When high, the device is operational. 129 | if DEVICE_LIBRARY == DEVICE_LIBRARY_GPIO: 130 | GPIO.output(self._gpio_en, GPIO.HIGH) 131 | if DEVICE_LIBRARY == DEVICE_LIBRARY_PIN: 132 | self._gpio_en.on() 133 | if DEVICE_LIBRARY == DEVICE_LIBRARY_BLINKA: 134 | self._gpio_en.value = True 135 | 136 | def irq_wait(self, timeout=None): 137 | """ irq_wait(self, timeout=None) 138 | 139 | :param timeout: Either None or the number of seconds to control timeout 140 | :return: True if IRQ/Interrupt is active low, False with timeout 141 | 142 | IRQ- will go active low once the receiver has some info to return. 143 | """ 144 | # IRQ/Interrupt is active low to signal data available 145 | if self._debug: 146 | sys.stderr.write('IRQ WAIT: ') 147 | # sys.stderr.flush() 148 | while True: 149 | if DEVICE_LIBRARY == DEVICE_LIBRARY_GPIO: 150 | if not GPIO.input(self._gpio_irq): 151 | break 152 | if DEVICE_LIBRARY == DEVICE_LIBRARY_PIN: 153 | if not self._gpio_irq.value(): 154 | break 155 | if DEVICE_LIBRARY == DEVICE_LIBRARY_BLINKA: 156 | if not self._gpio_irq.value: 157 | break 158 | if self._debug: 159 | sys.stderr.write('H') 160 | # sys.stderr.flush() 161 | # now wait (for any transition) - way better than looping, sleeping, and checking 162 | if timeout: 163 | this_timeout=min(int(timeout*1000), IRQ_WAKEUP_DELAY*1000) 164 | else: 165 | this_timeout=IRQ_WAKEUP_DELAY*1000 166 | if DEVICE_LIBRARY == DEVICE_LIBRARY_GPIO: 167 | channel = GPIO.wait_for_edge(self._gpio_irq, GPIO.BOTH, timeout=this_timeout) 168 | if DEVICE_LIBRARY == DEVICE_LIBRARY_PIN: 169 | channel = irq_wait_for_edge(self._gpio_irq, timeout=this_timeout) 170 | if DEVICE_LIBRARY == DEVICE_LIBRARY_BLINKA: 171 | channel = irq_wait_for_edge(self._gpio_irq, timeout=this_timeout) 172 | if channel is None: 173 | # timeout happened 174 | if self._debug: 175 | sys.stderr.write('.') 176 | # sys.stderr.flush() 177 | if timeout: 178 | timeout -= IRQ_WAKEUP_DELAY 179 | if timeout <= 0: 180 | if self._debug: 181 | sys.stderr.write(' T\n') 182 | # sys.stderr.flush() 183 | return False 184 | if self._debug: 185 | sys.stderr.write(' L\n') 186 | # sys.stderr.flush() 187 | return True 188 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | - 2024-08-18 12:02:56 -0700 [759765f](../../commit/759765f00b09e31b54112253b132bde525dbb71f) `Merge pull request #1 from scruss/main` 4 | - 2024-08-18 12:00:44 -0700 [a971054](../../commit/a9710549000c008d3b8eef613c3de05474d1aaeb) `Add CHANGELOG` 5 | - 2024-08-18 11:55:39 -0700 [eadba7e](../../commit/eadba7e60f42075885510544605b252c8ced3731) `Support for Adafruit Blinka Library - kinda still messy` 6 | - 2024-08-16 21:06:34 -0400 [a875e45](../../commit/a875e45b57bfbd25209532c116f9829c4c8379e5) `fixed config.json syntax` 7 | - 2023-09-23 13:25:56 -0700 [006b410](../../commit/006b4105f7679dd066e5a6b3a0046169610819a5) `remove check on bus number - needed for non Rpi boxes` 8 | - 2023-09-22 16:40:51 -0700 [d398cba](../../commit/d398cbaa9bd7c8e6ea19f8b01e5c8f0f6f31f7db) `__del__ and close were not handling library types - they do now` 9 | - 2023-09-22 16:38:36 -0700 [ebba244](../../commit/ebba2444073217c47b13ac5af8f08fe5780e6f14) `read() could take an addr value - not used presently` 10 | - 2023-09-22 15:58:50 -0700 [f4aa9a3](../../commit/f4aa9a376e5906a56b96b30415acd38981f5713e) `added Maidenhead Locator System as a choice of specifing location - to keep the ham folks happy` 11 | - 2023-09-17 17:13:03 -0700 [eae4b35](../../commit/eae4b35e2b3ebdacf59d29c0e447c2f9a89c0218) `First pass at MCP2221 support` 12 | - 2023-09-17 14:07:48 -0700 [d3fe08e](../../commit/d3fe08e227746ef765c48008471cc8745a5915f4) `First pass at MCP2221 support` 13 | - 2023-09-17 14:03:59 -0700 [5868e37](../../commit/5868e377fb3edee81f2553c85bf8c8008e325257) `First pass at MCP2221 support` 14 | - 2023-09-17 14:01:00 -0700 [9d48df3](../../commit/9d48df31ef00bf195fcf5ff143dcee1591d4f097) `First pass at MCP2221 support` 15 | - 2023-05-08 17:04:14 -0700 [b50e911](../../commit/b50e911aa66d4ce31b31cb1e8aa194e8a6a54b67) `changed json structure, added start of wifi config` 16 | - 2023-04-29 17:21:55 +0100 [5bb3b67](../../commit/5bb3b6746625702606a418b662dcf2097e0d3819) `first pass with new pc board and its four leds` 17 | - 2023-04-29 17:17:58 +0100 [14b77f8](../../commit/14b77f8307732fc07c6253e1d8aa4012a2d78f12) `cleanup of control0 debug prints` 18 | - 2023-04-29 17:14:55 +0100 [16c8fb2](../../commit/16c8fb283b7e7d7d1c89d0be68b73ad88c518a11) `moved IRQ/EN pins to new defaults to match new pc board design` 19 | - 2023-04-20 19:32:28 -0700 [8a988de](../../commit/8a988de4b114291a39719778403f10d422332a6c) `better start processing, fix debug of control0` 20 | - 2023-04-20 19:31:16 -0700 [56312ea](../../commit/56312ea8f99f1a0e0e3ea3d1b32abeffe7a664a7) `better prints, tracking support added` 21 | - 2023-04-05 15:03:45 -0700 [90167c3](../../commit/90167c318f14001a80156db5a673bf2acdd2a90d) `handle <3.9 Python` 22 | - 2023-04-03 14:14:37 -0700 [aee0e54](../../commit/aee0e547d8baa3a4160287f7f2c51cbdcb0a81a4) `HH:MM: added to tracking messages` 23 | - 2023-04-03 14:14:25 -0700 [9c9fe24](../../commit/9c9fe24818463fbcfaf1d226f906359eff01aa2b) `cleaned up docs on pins` 24 | - 2023-04-03 14:13:53 -0700 [908d319](../../commit/908d3194659ee5eb585d99d7f376220b77f97357) `HH:MM: added to tracking messages` 25 | - 2023-04-01 15:19:23 -0700 [ac0af1b](../../commit/ac0af1b72d5edc3ec1e8dfb89898c074b66ac7aa) `added and corrected antenna_locked logic, updated sleep info, irq/status0 reporting name fixed` 26 | - 2023-04-01 15:17:32 -0700 [98294cd](../../commit/98294cd6517f59a804098d6fcad846df16368d3e) `remove do_cycle for now` 27 | - 2023-03-31 10:46:11 -0700 [36d82eb](../../commit/36d82ebe635125da9e8aea2b6de3b491e57add6d) `timezone cleanup` 28 | - 2023-03-31 10:45:43 -0700 [1a3b8a5](../../commit/1a3b8a56fb200e5705d0d1a22b10abfa6c7d2389) `message cleanup` 29 | - 2023-03-30 21:30:25 -0700 [693f2a4](../../commit/693f2a47b24516d79b7605b8ed2f53e0324f64ce) `typo` 30 | - 2023-03-30 19:11:21 -0700 [7e4adf3](../../commit/7e4adf363204ef861f15e1b9da15a17c4da710fa) `cleaner start code, added HH:16 or HH:46 code` 31 | - 2023-03-30 19:10:39 -0700 [fbd8c62](../../commit/fbd8c62bddf950462268099cd1a7fa9a249d4c37) `typo` 32 | - 2023-03-30 09:30:59 -0700 [10bbe24](../../commit/10bbe24f8137682d3dbdeebb8ce52d2f1ec67873) `moved datetime to correct place for display code, add more timer cleanup` 33 | - 2023-03-30 09:09:06 -0700 [a0309b4](../../commit/a0309b440ec0a6ce5bf1ae807dc117e363d2659a) `blink the led corrected` 34 | - 2023-03-30 08:55:18 -0700 [027179f](../../commit/027179f0b71b40128418455bed38fd8ef6783813) `blink led when waiting for IRQ` 35 | - 2023-03-30 08:04:19 -0700 [55c9d07](../../commit/55c9d079e9a0e111e8ccc34c60507fdb6e5c54fc) `Merge branch 'main' of github.com:mahtin/es100-wwvb` 36 | - 2023-03-30 08:03:43 -0700 [85884f4](../../commit/85884f43be18eb4a55f08ba78bff244f2958f16e) `info()->debug() making code less chatty` 37 | - 2023-03-25 12:48:12 -0700 [df7f2b9](../../commit/df7f2b9d08ff34019f94eff5967c4fabf67e196d) `kill the timer when exiting` 38 | - 2023-03-25 07:57:15 -0700 [1e52561](../../commit/1e52561e71a08a251fc96df94025373214cb0d54) `added modules` 39 | - 2023-03-25 07:53:27 -0700 [d8f15c3](../../commit/d8f15c319a3fafbe82d98942c6cd5bf63912f728) `moved back to rst` 40 | - 2023-03-25 07:42:50 -0700 [93fd4ab](../../commit/93fd4ab5c68eeec05e2a0f0d736a2cd31656bd09) `reset msec on clock set` 41 | - 2023-03-25 07:42:22 -0700 [ddf5f45](../../commit/ddf5f45d5ec0ee61c7615de4dccb92f00467c3bb) `set WARNING as default logging level` 42 | - 2023-03-24 16:21:28 -0700 [fb4a26f](../../commit/fb4a26fa8b34e4e4f88b57b3f40946c7fda9454d) `delete __pycache_` 43 | - 2023-03-24 10:26:24 -0700 [1fc1734](../../commit/1fc1734af8964c4f9f229072c86ed267b0d65435) `Merge branch 'main' of github.com:mahtin/es100-wwvb` 44 | - 2023-03-24 10:20:44 -0700 [b975969](../../commit/b9759695e8935ce3aefff14cdb605acbb2a2ac64) `0.4.4` 45 | - 2023-03-24 10:20:25 -0700 [1f5deca](../../commit/1f5deca9b6fded785498f419fc53811d74e95aed) `added OLED i2c 128x64 display support` 46 | - 2023-03-24 09:56:33 -0700 [7c0173c](../../commit/7c0173c3d4a0424ef3c1ba4425740ebaa750cfc7) `white space cleanup` 47 | - 2023-03-24 09:52:55 -0700 [52a9288](../../commit/52a9288cf3c29bb8486f9a9da25c990a17f71d97) `added OLED i2c 128x64 display support` 48 | - 2023-03-24 09:50:47 -0700 [2b0c55c](../../commit/2b0c55c845a45020e04f58f72132da05489be185) `micropython ssd1306` 49 | - 2023-03-24 09:48:54 -0700 [cb16f6c](../../commit/cb16f6c4895e707baf5d2f334702bbd3f12efa11) `removed incorrect subsecond code` 50 | - 2023-03-23 16:59:29 -0700 [df329c3](../../commit/df329c3e0ef94782be6f4ab99ad9ce818b029d64) `more fixes for readthedocs` 51 | - 2023-03-23 16:49:23 -0700 [b8cf0d0](../../commit/b8cf0d08976ab99c55cf00ad31cf17082d096732) `more fixes for readthedocs` 52 | - 2023-03-23 16:48:55 -0700 [a23aa7b](../../commit/a23aa7b862f2b735669148d7ff8a75bcbed74f31) `fixed readthedocs docstring` 53 | - 2023-03-23 16:41:16 -0700 [76d1595](../../commit/76d1595533b44715bca6c51126c5733a68ead3b2) `more fixes for readthedocs` 54 | - 2023-03-23 16:38:27 -0700 [7b5c3ae](../../commit/7b5c3ae17eddb8a168bd819a529ab4dfc992f5e6) `more fixes for readthedocs` 55 | - 2023-03-23 16:36:00 -0700 [79d4569](../../commit/79d4569bc114bb0e57d0c98201b024cfb0fc82d9) `more fixes for readthedocs` 56 | - 2023-03-23 16:30:33 -0700 [7cd3cd1](../../commit/7cd3cd14c8b796436f9cb281a91ebd12c83a6620) `formats - not needed` 57 | - 2023-03-23 12:42:46 -0700 [c975cfd](../../commit/c975cfd72881564897c3190a4e4b09bce6c3ef80) `gitignore added` 58 | - 2023-03-23 12:40:06 -0700 [b8dc173](../../commit/b8dc173b6e7fd7acfeeded4502428722790d9fa7) `remove dist` 59 | - 2023-03-23 12:37:41 -0700 [0da3472](../../commit/0da3472fa9d73d2843f45ac5419b48d45734742c) `0.4.3` 60 | - 2023-03-23 12:36:29 -0700 [91d723b](../../commit/91d723b1104d0d81a5340b37eca8ed903044233a) `add picture of Raspberry Pi Pico` 61 | - 2023-03-23 12:26:02 -0700 [4e0d34b](../../commit/4e0d34bc82d1948b64f458ff4b53a01576079bc6) `0.4.2` 62 | - 2023-03-23 12:25:50 -0700 [0092d7a](../../commit/0092d7ad9d2ceecfebcb01f5415b857ab27e5657) `publish` 63 | -------------------------------------------------------------------------------- /pico/wwvb_lite.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ WWVB 60Khz receiver/parser for i2c bus based ES100-MOD on the Raspberry Pi Pico 3 | 4 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 5 | """ 6 | 7 | import sys 8 | import json 9 | 10 | import utime 11 | from machine import Timer 12 | 13 | from es100 import ES100 14 | from pico.datetime import datetime, timezone 15 | from pico.logging import logging 16 | from pico.oled_display import OLEDDisplay128x64 17 | 18 | MY_LOCATION = [37.363056, -121.928611, 18.9] # SJC Airport 19 | 20 | def wwvb_lite(): 21 | """ wwvb_lite() 22 | """ 23 | 24 | # Raspberry Pi Pico wiring diagram. 25 | # I2C bus 1 is on physical pins 9 & 10 (called GP6 & GP7) 26 | # GPIO for IRQ and Enable are physical pins 27 & 29 (called GP21 * GP22) 27 | # 28 | # I I 29 | # 2 2 30 | # C C 31 | # 1 1 32 | # - - 33 | # S S 34 | # C D 35 | # L A 36 | # * * * * * * * * * * * * * * * * * * * * 37 | # * * 38 | # * Raspberry Pi Pico (or Pico W) * USB 39 | # * * 40 | # * * * * * * * * * * * * * * * * * * * * 41 | # G G 3 G 42 | # P P . N 43 | # 2 2 3 D 44 | # 1 2 V 45 | 46 | i2c_bus = 1 47 | i2c_address = 0x32 # 50 decimal == 0x32 hex 48 | flag_debug = False 49 | flag_verbose = False 50 | es100_irq = 27 51 | es100_en = 29 52 | # our_location_name = '' 53 | # our_location = MY_LOCATION[:2] 54 | # our_masl = MY_LOCATION[2] 55 | flag_enable_nighttime = False 56 | flag_force_tracking = False 57 | antenna_choice = None 58 | 59 | try: 60 | with open('pico/config.json', 'r', encoding="utf-8") as fd: 61 | config = json.load(fd) 62 | print('config.json: loaded') 63 | except OSError: 64 | config = {} 65 | 66 | if 'wwvb.station' in config: 67 | try: 68 | station = config['wwvb.station'] 69 | # our_location_name = config[station]['name'] 70 | # our_location = convert_location(config[station]['location']) 71 | # our_masl = config[station]['masl'] 72 | antenna_choice = config[station]['antenna'] 73 | except KeyError: 74 | print('%s: station not found - continuing') 75 | 76 | if 'wwvb.bus' in config: 77 | i2c_bus = config['wwvb.bus'] 78 | if 'wwvb.address' in config: 79 | i2c_address = config['wwvb.address'] 80 | if 'wwvb.irq' in config: 81 | es100_irq = config['wwvb.irq'] 82 | if 'wwvb.en' in config: 83 | es100_en = config['wwvb.en'] 84 | if 'wwvb.nighttime' in config: 85 | flag_enable_nighttime = config['wwvb.nighttime'] 86 | if 'wwvb.tracking' in config: 87 | flag_enable_tracking = config['wwvb.tracking'] 88 | if 'debug.debug' in config: 89 | flag_debug = config['debug.debug'] 90 | if 'debug.verbose' in config: 91 | flag_verbose = config['debug.verbose'] 92 | 93 | # we want any warnings - there should be very few! 94 | logging.basicConfig(level=logging.WARNING) 95 | 96 | try: 97 | doit(antenna=antenna_choice, tracking=flag_enable_tracking, irq=es100_irq, en=es100_en, bus=i2c_bus, address=i2c_address, verbose=flag_verbose, debug=flag_debug) 98 | except KeyboardInterrupt: 99 | print('^C - exiting!') 100 | sys.exit(0) 101 | 102 | class SimpleOLED: 103 | """ SimpleOLED 104 | 105 | Text based display of WWVB data 106 | """ 107 | 108 | _d = None 109 | _ms_start = None 110 | _timer = None 111 | 112 | @classmethod 113 | def _mycallback(cls, t): 114 | """ _mycallback() """ 115 | if SimpleOLED._timer is None or SimpleOLED._ms_start is None: 116 | # should not have been called 117 | return 118 | dt = datetime.utcnow().replace(tzinfo=timezone.utc) 119 | SimpleOLED._d.datetime(dt) 120 | percent = ((utime.ticks_ms() - SimpleOLED._ms_start)/((134+10)*1000.0)) % 1.0 121 | SimpleOLED._d.progress_bar(percent, 0, 16) 122 | 123 | def __init__(self): 124 | """ __init__ """ 125 | self._d = OLEDDisplay128x64() 126 | 127 | # needed for callback 128 | SimpleOLED._ms_start = utime.ticks_ms() 129 | SimpleOLED._d = self._d 130 | 131 | SimpleOLED._timer = Timer(-1) 132 | SimpleOLED._timer.init(period=100, mode=Timer.PERIODIC, callback=SimpleOLED._mycallback) 133 | 134 | def __del__(self): 135 | if SimpleOLED._timer: 136 | # kill the timer 137 | SimpleOLED._timer.deinit() 138 | SimpleOLED._timer = None 139 | SimpleOLED._ms_start = None 140 | 141 | def background(self): 142 | """ background() """ 143 | self._d.background() 144 | self._d.datetime(None) 145 | self._d.progress_bar(0.0, 0, 16) 146 | self._d.text('Ant:', 0, 24) 147 | self._d.text('Delta:', 0, 32) 148 | self._d.text('DST:', 0, 40) 149 | self._d.text('Leap:', 0, 48) 150 | self._d.text('Count:', 0, 56) 151 | 152 | def update(self, ant, delta, dst, leap): 153 | """ update() """ 154 | self._d.text('Ant: %s' % (ant), 0, 24) 155 | if delta: 156 | self._d.text('Delta: %5.3f' % (delta), 0, 32) 157 | if dst: 158 | self._d.text('DST: %s' % (dst), 0, 40) 159 | if leap: 160 | self._d.text('Leap: %s' % (leap), 0, 48) 161 | 162 | def update_counts(self, successes, loops): 163 | """ update_counts() """ 164 | self._d.text('Count: %d/%d' % (successes, loops), 0, 56) 165 | 166 | def reset_timer(self): 167 | """ reset_timer() """ 168 | SimpleOLED._ms_start = utime.ticks_ms() 169 | 170 | def doit(antenna, tracking, irq, en, bus, address, verbose=False, debug=False): 171 | """ doit() 172 | :param antenna: Antenna number (1 or 2) 173 | :param tracking: Enable tracking 174 | :param en: Enable pin number 175 | :param irq: IRQ pin number 176 | :param bus: I2C bus number 177 | :param address: I2C address number 178 | :param verbose: Verbose level 179 | :param debug: Debug level 180 | :param verbose: Verbose level 181 | 182 | No frills loop to operate the ES100-MOD on the Raspberry Pi 183 | """ 184 | try: 185 | es = ES100(antenna=antenna, en=en, irq=irq, bus=bus, address=address, verbose=verbose, debug=debug) 186 | except Exception as err: 187 | # can't find device! 188 | print(err) 189 | return 190 | 191 | count_successes = 0 192 | count_loops = 0 193 | 194 | d = SimpleOLED() 195 | d.background() 196 | 197 | # loop forever - setting the system RTC as we proceed 198 | while True: 199 | d.reset_timer() 200 | 201 | received_dt = es.time(tracking=tracking) 202 | if received_dt: 203 | # if received_dt.year == 1 and received_dt.month == 1 and received_dt.day == 1: 204 | if tracking: 205 | print('WWVB: (tracking) HH:MM:%02d.%03d' % (received_dt.second, int(received_dt.microsecond / 1000))) 206 | count_successes += 1 207 | d.update(es.rx_antenna(), None, None, None) 208 | else: 209 | print('WWVB: %s at %s via %s with delta %0.3f' % (received_dt, es.system_time(), es.rx_antenna(), es.delta_seconds())) 210 | 211 | # set system time 212 | datetime.setrtc(received_dt) 213 | 214 | count_successes += 1 215 | d.update(es.rx_antenna(), es.delta_seconds(), es.is_presently_dst(), es.leap_second()) 216 | 217 | count_loops += 1 218 | d.update_counts(count_successes, count_loops) 219 | -------------------------------------------------------------------------------- /wwvb/ntpdriver28.py: -------------------------------------------------------------------------------- 1 | """ Shared Memory NTP Driver (#28) for NTP v4 2 | 3 | See README.md for detailed/further reading. 4 | 5 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 6 | """ 7 | 8 | import os 9 | import pwd 10 | import grp 11 | import logging 12 | import datetime 13 | 14 | try: 15 | import sysv_ipc 16 | except ImportError: 17 | sysv_ipc = None 18 | 19 | # https://github.com/ntp-project/ntp/blob/master-no-authorname/ntpd/refclock_shm.c 20 | NTPD_DEFAULT_KEY = 0x4E545030 21 | 22 | # Unclear how to code this up cleanly. 23 | # We guess the received clock precision to around -5 or 31.25 milliseconds 24 | # Note that a positive number reflects a very poor clock accuracy 25 | NTPD_PRECISION = { 26 | -10: pow(2, -10), # 0.9765625 milliseconds 27 | -9: pow(2, -9), # 1.953125 milliseconds 28 | -8: pow(2, -8), # 3.90625 milliseconds 29 | -7: pow(2, -7), # 7.8125 milliseconds 30 | -6: pow(2, -6), # 15.625 milliseconds 31 | -5: pow(2, -5), # 31.25 milliseconds 32 | -4: pow(2, -4), # 62.5 milliseconds 33 | -3: pow(2, -3), # 125 milliseconds 34 | -2: pow(2, -2), # 250 milliseconds 35 | -1: pow(2, -1), # 0.5 seconds 36 | 0: pow(2, 0), # 1 second 37 | +1: pow(2, +1), # 2 seconds 38 | +2: pow(2, +2), # 4 seconds 39 | +3: pow(2, +3), # 8 seconds 40 | +4: pow(2, +4), # 16 seconds 41 | +5: pow(2, +5), # 32 seconds 42 | } 43 | 44 | ARCH_TO_BITS = { 45 | 'i386': 32, 46 | 'i686': 32, 47 | 'x86_64': 64, 48 | 'armv6l': 32, 49 | 'armv7l': 32, 50 | 'arm64': 64, 51 | 'arch64': 64, 52 | 'aarch64': 64, 53 | } 54 | 55 | # 80 = 4(int) * 18 + 4(time_t) * 2 56 | # for 32 bit machine 57 | SHM_LAYOUT32 = { 58 | 'mode': ( 0, 4, 'int'), # size 4 # int mode 59 | 'count': ( 4, 4, 'int'), # size 4 # volatile int count 60 | 'clockTimeStampSec': ( 8, 4, 'time_t'), # size 4 # time_t clockTimeStampSec 61 | 'clockTimeStampUSec': (12, 4, 'int'), # size 4 # int clockTimeStampUSec 62 | 'receiveTimeStampSec': (16, 4, 'time_t'), # size 4 # time_t receiveTimeStampSec 63 | 'receiveTimeStampUSec': (20, 4, 'int'), # size 4 # int receiveTimeStampUSec 64 | 'leap': (24, 4, 'int'), # size 4 # int leap 65 | 'precision': (28, 4, 'int'), # size 4 # int precision 66 | 'nsamples': (32, 4, 'int'), # size 4 # int nsamples 67 | 'valid': (36, 4, 'int'), # size 4 # volatile int valid 68 | 'clockTimeStampNSec': (40, 4, 'unsigned'), # size 4 # unsigned clockTimeStampNSec 69 | 'receiveTimeStampNSec': (44, 4, 'unsigned'), # size 4 # unsigned receiveTimeStampNSec 70 | 'dummy': (48, 32, 'int[8]') # size 4 * 8 # int[8] 71 | } 72 | 73 | # 96 = 4(int) * 18 + 8(time_t) * 2 + 4(padding) + 4(padding) 74 | # for M1 Mac (arm64) or Raspberry Pi arch64/aarch64 75 | SHM_LAYOUT64 = { 76 | 'mode': ( 0, 4, 'int'), # size 4 # int mode 77 | 'count': ( 4, 4, 'int'), # size 4 # volatile int count 78 | 'clockTimeStampSec': ( 8, 8, 'time_t'), # size 8 # time_t clockTimeStampSec 79 | 'clockTimeStampUSec': (16, 4, 'int'), # size 4 # int clockTimeStampUSec 80 | # padding 4 81 | 'receiveTimeStampSec': (24, 8, 'time_t'), # size 8 # time_t receiveTimeStampSec 82 | 'receiveTimeStampUSec': (32, 4, 'int'), # size 4 # int receiveTimeStampUSec 83 | 'leap': (36, 4, 'int'), # size 4 # int leap 84 | 'precision': (40, 4, 'int'), # size 4 # int precision 85 | 'nsamples': (44, 4, 'int'), # size 4 # int nsamples 86 | 'valid': (48, 4, 'int'), # size 4 # volatile int valid 87 | 'clockTimeStampNSec': (52, 4, 'unsigned'), # size 4 # unsigned clockTimeStampNSec 88 | 'receiveTimeStampNSec': (56, 4, 'unsigned'), # size 4 # unsigned receiveTimeStampNSec 89 | # padding 4 90 | 'dummy': (64, 32, 'int[8]') # size 4 * 8 # int[8] 91 | } 92 | 93 | class NTPDriver28Error(Exception): 94 | """ raise this any NTPDriver28 error """ 95 | 96 | class NTPDriver28: 97 | """ NTPDriver28() 98 | 99 | :param unit: The unit number of this clock source (0 thru 255) 100 | :param debug: True to enable debug messages 101 | :param verbose: True to enable verbose messages 102 | :return: New instance of NTPDriver28() 103 | 104 | Implements driver28 - allows a Shared Memory segment to be used to talk between NTPv4 and clock 105 | See https://www.ntp.org/documentation/drivers/driver28/ and 106 | https://github.com/ntp-project/ntp/blob/master-no-authorname/ntpd/refclock_shm.c 107 | 108 | NTPD uses SysV IPC vs Posix IPC. Requires "pip install sysv_ipc". 109 | See http://semanchuk.com/philip/sysv_ipc/#shared_memory & https://github.com/osvenskan/sysv_ipc 110 | """ 111 | 112 | def __init__(self, unit=0, debug=False, verbose=False): 113 | """ :meta private: """ 114 | 115 | self._shm = None 116 | 117 | if not sysv_ipc: 118 | raise NTPDriver28Error('sysv_ipc package not installed - no shared memory access') 119 | 120 | if isinstance(unit, str) and len(unit) > 0: 121 | try: 122 | unit = int(unit) 123 | except ValueError: 124 | raise NTPDriver28Error('ntp shared memory unit invalid: "%s"' % (unit)) 125 | elif isinstance(unit, int): 126 | if not 0 <= unit < 256: 127 | raise NTPDriver28Error('ntp shared memory unit invalid: %d' % (unit)) 128 | else: 129 | raise NTPDriver28Error('ntp shared memory unit invalid "%s"' % (unit)) 130 | self._unit = unit 131 | 132 | self._log = logging.getLogger(__class__.__name__) 133 | 134 | self._debug = debug 135 | if self._debug: 136 | self._log.setLevel(logging.DEBUG) 137 | 138 | self._verbose = verbose 139 | if self._verbose: 140 | self._log.setLevel(logging.INFO) 141 | 142 | self._attach() 143 | 144 | self._cpu_word_size = self._find_arch() 145 | 146 | # If we wrote some C code, this could be caculated directly 147 | # However, in order to run this is pure Python, we do some pretty accurate guessing 148 | # Actually, we wrote some trivial c code and came up with the 80 & 96 numbers 149 | # then hardcoded it all in here. 150 | if self._shm.size == 80 and self._cpu_word_size == 32: 151 | # 4 byte int's and 8 byte time_t's 152 | self._mapping = SHM_LAYOUT32 153 | self._size = 80 154 | elif self._shm.size == 96 and self._cpu_word_size == 64: 155 | # 4 byte int's and 16 byte time_t's plus some padding 156 | self._mapping = SHM_LAYOUT64 157 | self._size = 96 158 | else: 159 | self._detach() 160 | self._shm = None 161 | raise NTPDriver28Error('arch and size mismatch/unknown') 162 | 163 | self._log.info('SHM connected: %r', self) 164 | 165 | # starting thing off by reading the shared memory - we don't do anything with it yet 166 | self.load() 167 | 168 | def __del__(self): 169 | """ __del__ """ 170 | self._detach() 171 | self._shm = None 172 | 173 | def __str__(self): 174 | """ __str__ """ 175 | return '[0x%08X+%d]' % (NTPD_DEFAULT_KEY, self._unit) 176 | 177 | def __repr__(self): 178 | """ __repr__ """ 179 | try: 180 | uid = pwd.getpwuid(self._shm.uid).pw_name 181 | except KeyError: 182 | uid = str(self._shm.uid) 183 | try: 184 | gid = grp.getgrgid(self._shm.gid).gr_name 185 | except KeyError: 186 | gid = str(self._shm.gid) 187 | return '[id=%d, key=0x%X, size=%d, mode=%s, owner=%s.%s, attached=%s, n_attached=%d]' % ( 188 | self._shm.id, 189 | self._shm.key, 190 | self._shm.size, 191 | self._decode_mode(self._shm.mode), 192 | uid, 193 | gid, 194 | self._shm.attached, 195 | self._shm.number_attached, 196 | ) 197 | 198 | def read(self, byte_count=0, offset=0): 199 | """ read() 200 | :param byte_count: Number of bytes to read 201 | :param offset: Number of bytes from start of shared memory 202 | 203 | :return: The bytes read 204 | """ 205 | return self._shm.read(byte_count, offset) 206 | 207 | def write(self, some_bytes, offset=0): 208 | """ write() 209 | :param some_bytes: The bytes to write 210 | :param offset: Number of bytes from start of shared memory 211 | 212 | :return: The number of bytes written 213 | """ 214 | return self._shm.write(some_bytes, offset) 215 | 216 | def load(self): 217 | """ load() 218 | 219 | Load up the shared memory into instance 220 | """ 221 | self._shm_time = bytearray(self.read(self._size, 0)) 222 | self._log.info('SHM loaded') 223 | 224 | def unload(self): 225 | """ unload() 226 | 227 | Unload the instance copy into shared memory 228 | """ 229 | self.write(self._shm_time, 0) 230 | self._log.info('SHM unloaded') 231 | 232 | def dump(self, msg=None): 233 | """ dump() 234 | 235 | :param msg: an extra debug message 236 | 237 | Provide a pretty printed version of the contents of the shared memory. 238 | Must call load() before calling dump() 239 | """ 240 | 241 | if self._log.getEffectiveLevel() > logging.DEBUG: 242 | # short cut 243 | return 244 | 245 | lines = [] 246 | lines += ['%s' % (self)] 247 | 248 | for name, offset_size_ctype in self._mapping.items(): 249 | offset, size, ctype = offset_size_ctype 250 | value = self._shm_time[offset:offset+size] 251 | if ctype == 'int': 252 | val = int.from_bytes(value, 'little', signed=True) 253 | lines += ['%02d %-24s %2d %-8s = %13d' % (offset, name, size, ctype, val)] 254 | elif ctype == 'unsigned': 255 | val = int.from_bytes(value, 'little', signed=False) 256 | lines += ['%02d %-24s %2d %-8s = %13d' % (offset, name, size, ctype, val)] 257 | elif ctype == 'time_t': 258 | val = int.from_bytes(value, 'little', signed=False) 259 | dt = datetime.datetime.utcfromtimestamp(val).replace(tzinfo=datetime.timezone.utc) 260 | lines += ['%02d %-24s %2d %-8s = %13d # %s' % (offset, name, size, ctype, val, dt)] 261 | else: 262 | buf = ','.join([('%02x' % v) for v in value]) 263 | if len(buf) > 13: 264 | buf = buf[0:13-3] + '...' 265 | lines += ['%02d %-24s %2d %-8s = %s' % (offset, name, size, ctype, buf)] 266 | 267 | if msg: 268 | self._log.debug('%s %s', msg, '\n\t\t'.join(lines)) 269 | else: 270 | self._log.debug('%s', '\n\t\t'.join(lines)) 271 | 272 | def update(self, received_dt, sys_received_dt, leap_second=None): 273 | """ update() 274 | 275 | :param received_dt: WWVB received date and time 276 | :param sys_received_dt: System time when received 277 | 278 | Do the nitty-gritty NTP update via shared memory 279 | """ 280 | 281 | self._log.info('update(%s, %s, %s)', received_dt, sys_received_dt, leap_second) 282 | 283 | wwvb_time = received_dt.timestamp() 284 | sys_time = sys_received_dt.timestamp() 285 | 286 | self.load() 287 | self._store_value('mode', 1) # 1 == operational mode 1 288 | 289 | self._store_value('clockTimeStampSec', int(wwvb_time)) 290 | self._store_value('clockTimeStampUSec', int(1000000 * (wwvb_time%1))) 291 | self._store_value('clockTimeStampNSec', 0) 292 | 293 | self._store_value('receiveTimeStampSec', int(sys_time)) 294 | self._store_value('receiveTimeStampUSec', int(1000000 * (sys_time%1))) 295 | self._store_value('receiveTimeStampNSec', 0) 296 | 297 | # values taken from include/ntp.h 298 | if leap_second is None: 299 | self._store_value('leap', 0) # LEAP_NOWARNING 300 | elif leap_second == 'positive': 301 | self._store_value('leap', 1) # LEAP_ADDSECOND 302 | elif leap_second == 'negative': 303 | self._store_value('leap', 2) # LEAP_DELSECOND 304 | else: 305 | self._store_value('leap', 3) # LEAP_NOTINSYNC 306 | 307 | self._store_value('precision', -5) # 1/32'nd of a second. See NTPD_PRECISION above 308 | 309 | count = self._read_value('count') 310 | count += 1 311 | self._store_value('count', count) 312 | 313 | self._store_value('valid', 1) # go! 314 | self.dump('Sending time to NTP count=%d ' % (count)) 315 | self.unload() 316 | 317 | def _attach(self): 318 | """ _attach """ 319 | try: 320 | self._shm = sysv_ipc.SharedMemory(NTPD_DEFAULT_KEY + self._unit) 321 | except Exception as err: 322 | raise NTPDriver28Error('unable to attach to shared memory: %s' % (err)) from err 323 | self._log.debug('SHM attached') 324 | 325 | def _detach(self): 326 | """ _attach """ 327 | if self._shm: 328 | self._shm.detach() 329 | self._log.debug('SHM detached') 330 | # self._shm.remove() 331 | 332 | def _decode_mode(self, mode=None): 333 | """ _decode_mode """ 334 | if mode is None: 335 | mode = self._shm.mode 336 | rwx = [ 337 | '---','--x','-w-','-wx', 338 | 'r--','r-x','rw-','rwx' 339 | ] 340 | return ''.join([ 341 | '--', 342 | rwx[mode >> 6 & 7], 343 | rwx[mode >> 3 & 7], 344 | rwx[mode & 7], 345 | ]) 346 | 347 | def _find_arch(self): 348 | """ _find_arch """ 349 | arch = os.uname().machine 350 | 351 | try: 352 | cpu_word_size = ARCH_TO_BITS[arch] 353 | except IndexError: 354 | # maybe the name gives us a clue? 355 | if arch[-2:] in ['32','64']: 356 | cpu_word_size = int(arch[-2:]) 357 | else: 358 | # we guess 359 | cpu_word_size = 32 360 | self._log.info('SHM %dbit word size', cpu_word_size) 361 | return cpu_word_size 362 | 363 | def _read_value(self, name): 364 | """ _read_value() 365 | 366 | :param name: Name of shmTime element 367 | :return: Value from shmTime struct 368 | """ 369 | 370 | try: 371 | offset, size, ctype = self._mapping[name] 372 | except IndexError: 373 | return None 374 | 375 | value = self._shm_time[offset:offset+size] 376 | if ctype == 'int': 377 | return int.from_bytes(value, 'little', signed=True) 378 | if ctype == 'unsigned': 379 | return int.from_bytes(value, 'little', signed=False) 380 | if ctype == 'time_t': 381 | val = int.from_bytes(value, 'little', signed=False) 382 | return datetime.datetime.utcfromtimestamp(val).replace(tzinfo=datetime.timezone.utc) 383 | return value 384 | 385 | def _store_value(self, name, value): 386 | """ _store_value() 387 | 388 | :param name: Name of shmTime element 389 | :param value: New value 390 | """ 391 | 392 | try: 393 | offset, size, ctype = self._mapping[name] 394 | except IndexError: 395 | #should not happen 396 | raise NTPDriver28Error('%s: invalid structure element name' % (name)) 397 | 398 | if ctype == 'int': 399 | buf = int(value).to_bytes(size, 'little', signed=True) 400 | elif ctype in ['unsigned', 'time_t']: 401 | buf = int(value).to_bytes(size, 'little', signed=False) 402 | else: 403 | #should not happen - we only write int's, unsigned's, and time_t's 404 | raise NTPDriver28Error('%s: invalid ctype' % (ctype)) 405 | 406 | # copy the bytes into the correct place in the shared memory copy 407 | # the size of buf is always the correct length of bytes 408 | for ii,val in enumerate(buf): 409 | self._shm_time[offset+ii] = val 410 | -------------------------------------------------------------------------------- /wwvb/wwvb.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ WWVB 60Khz Full funcionality receiver/parser for i2c bus based ES100-MOD 3 | 4 | A time and date decoder for the ES100-MOD WWVB receiver. 5 | See README.md for detailed/further reading. 6 | 7 | wwvb - full function command line command to provide control and receiption from ES100-MOD WWVB receiver. 8 | 9 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 10 | """ 11 | 12 | import os 13 | import sys 14 | import time 15 | import logging 16 | import signal 17 | import getopt 18 | import platform 19 | from datetime import timedelta 20 | 21 | from es100 import ES100, ES100Error, __version__ 22 | from .misc import convert_location, caculate_latency, is_it_nighttime 23 | from .config import readconfig 24 | 25 | from .ntpdriver28 import NTPDriver28, NTPDriver28Error 26 | 27 | # ES100's pins as connected to Raspberry Pi GPIO pins 28 | 29 | # ES100 Pin 1 == VCC 3.6V (2.0-3.6V recommended) 30 | RPI_DEFAULT_GPIO_IRQ = 11 # GPIO-17 # ES100 Pin 2 == IRQ Interrupt Request 31 | # ES100 Pin 3 == SCL 32 | # ES100 Pin 4 == SDA 33 | RPI_DEFAULT_GPIO_EN = 7 # GPIO-4 # ES100 Pin 5 == EN Enable 34 | # ES100 Pin 6 == GND 35 | 36 | MY_LOCATION = [37.363056, -121.928611, 18.9] # SJC Airport 37 | 38 | def doit(program_name, args): 39 | """ doit 40 | 41 | :param program_name: $0 in shell terms 42 | :param args: $* in shell terms 43 | :return: nothing 44 | 45 | This code will loop forever until an error occurs, or is interrupt is reached 46 | """ 47 | 48 | i2c_bus = None 49 | i2c_address = None 50 | flag_debug = False 51 | flag_verbose = False 52 | es100_irq = RPI_DEFAULT_GPIO_IRQ 53 | es100_en = RPI_DEFAULT_GPIO_EN 54 | our_location_name = '' 55 | our_location = MY_LOCATION[:2] 56 | our_masl = MY_LOCATION[2] 57 | flag_enable_nighttime = False 58 | flag_force_tracking = False 59 | antenna_choice = None 60 | ntpd_unit_number = None 61 | flag_gpiod = False 62 | 63 | # needed within this and other modules 64 | required_format = '%(asctime)s %(name)s %(levelname)s %(message)s' 65 | logging.basicConfig(format=required_format) 66 | 67 | usage = program_name + ' ' + ' '.join([ 68 | '[-V|--version]', 69 | '[-h|--help]', 70 | '[-v|--verbose]', 71 | '[-d|--debug]', 72 | '[-b|--bus={0-N}]', 73 | '[-a|--address={8-127}]', 74 | '[-i|--irq={1-40}]', 75 | '[-e|--en={1-40}]', 76 | '[-l|--location=lat,long]', 77 | '[-m|--masl={0-99999}]', 78 | '[-n|--nighttime]', 79 | '[-t|--tracking]', 80 | '[-A|--antenna={0-1}]', 81 | '[-N|--ntpd={0-255}]', 82 | '[-G|--gpiod]', 83 | ]) 84 | 85 | # we set defaults from config file - so that command line can override 86 | config = readconfig() 87 | 88 | if 'wwvb.station' in config: 89 | station = config['wwvb.station'] 90 | our_location_name = config[station + '.' + 'name'] 91 | our_location = config[station + '.' + 'location'] 92 | our_location = convert_location(our_location) 93 | our_masl = config[station + '.' + 'masl'] 94 | antenna_choice = config[station + '.' + 'antenna'] 95 | 96 | if 'wwvb.bus' in config: 97 | i2c_bus = config['wwvb.bus'] 98 | if 'wwvb.address' in config: 99 | i2c_address = config['wwvb.address'] 100 | if 'wwvb.irq' in config: 101 | es100_irq = config['wwvb.irq'] 102 | if 'wwvb.en' in config: 103 | es100_en = config['wwvb.en'] 104 | if 'wwvb.nighttime' in config: 105 | flag_enable_nighttime = config['wwvb.nighttime'] 106 | if 'wwvb.tracking' in config: 107 | flag_enable_nighttime = config['wwvb.tracking'] 108 | if 'debug.debug' in config: 109 | flag_debug = config['debug.debug'] 110 | if 'debug.verbose' in config: 111 | flag_verbose = config['debug.verbose'] 112 | if 'ntpd.unit' in config: 113 | ntpd_unit_number = config['ntpd.unit'] 114 | if 'wwvb.gpiod' in config: 115 | flag_gpiod = config['wwvb.gpiod'] 116 | 117 | try: 118 | opts, args = getopt.getopt(args, 119 | 'Vhvdb:a:i:e:l:m:ntAN:G', 120 | [ 121 | 'version', 122 | 'help', 123 | 'verbose', 124 | 'debug', 125 | 'bus=', 126 | 'address=', 127 | 'size=', 128 | 'irq=', 129 | 'en=', 130 | 'location=', 131 | 'masl=', 132 | 'nighttime', 133 | 'tracking', 134 | 'antenna', 135 | 'ntpd=', 136 | 'gpiod', 137 | ]) 138 | except getopt.GetoptError: 139 | sys.exit('usage: ' + usage) 140 | 141 | for opt, arg in opts: 142 | if opt in ('-V', '--version'): 143 | print("%s %s" % (program_name, __version__), file=sys.stderr) 144 | sys.exit(0) 145 | if opt in ('-h', '--help'): 146 | print("%s %s" % ('usage:', usage), file=sys.stderr) 147 | sys.exit(0) 148 | if opt in ('-v', '--verbose'): 149 | logging.basicConfig(level=logging.INFO) 150 | flag_verbose = True 151 | continue 152 | if opt in ('-d', '--debug'): 153 | logging.basicConfig(level=logging.DEBUG) 154 | flag_debug = True 155 | continue 156 | if opt in ('-b', '--bus'): 157 | try: 158 | i2c_bus = int(arg) 159 | except ValueError: 160 | print("%s %s" % (program_name, 'invalid i2c bus number'), file=sys.stderr) 161 | sys.exit('usage: ' + usage) 162 | continue 163 | if opt in ('-a', '--address'): 164 | try: 165 | i2c_address = int(arg, 16) 166 | except ValueError: 167 | print("%s %s" % (program_name, 'invalid address'), file=sys.stderr) 168 | sys.exit('usage: ' + usage) 169 | continue 170 | if opt in ('-i', '--irq'): 171 | try: 172 | es100_irq = int(arg) 173 | except ValueError: 174 | print("%s %s" % (program_name, 'invalid irq'), file=sys.stderr) 175 | sys.exit('usage: ' + usage) 176 | continue 177 | if opt in ('-e', '--en'): 178 | try: 179 | es100_en = int(arg) 180 | except ValueError: 181 | print("%s %s" % (program_name, 'invalid en'), file=sys.stderr) 182 | sys.exit('usage: ' + usage) 183 | continue 184 | if opt in ('-l', '--location'): 185 | try: 186 | our_location = convert_location(arg) 187 | except ValueError: 188 | print("%s %s" % (program_name, 'invalid location'), file=sys.stderr) 189 | sys.exit('usage: ' + usage) 190 | continue 191 | if opt in ('-m', '--masl'): 192 | try: 193 | our_masl = int(arg) 194 | except ValueError: 195 | print("%s %s" % (program_name, 'invalid masl'), file=sys.stderr) 196 | sys.exit('usage: ' + usage) 197 | continue 198 | if opt in ('-n', '--nighttime'): 199 | flag_enable_nighttime = True 200 | continue 201 | if opt in ('-t', '--tracking'): 202 | flag_force_tracking = True 203 | continue 204 | if opt in ('-A', '--antenna'): 205 | try: 206 | antenna_choice = int(arg) 207 | if antenna_choice not in [1, 2]: 208 | raise ValueError 209 | except ValueError: 210 | print("%s %s" % (program_name, 'invalid antenna number'), file=sys.stderr) 211 | sys.exit('usage: ' + usage) 212 | continue 213 | if opt in ('-N', '--ntpd'): 214 | try: 215 | ntpd_unit_number = int(arg) 216 | if not 0 <= ntpd_unit_number < 256: 217 | raise ValueError 218 | except ValueError: 219 | print("%s %s" % (program_name, 'invalid ntpd unit number'), file=sys.stderr) 220 | sys.exit('usage: ' + usage) 221 | continue 222 | if opt in ('-G', '--gpiod'): 223 | flag_gpiod = True 224 | if es100_irq == RPI_DEFAULT_GPIO_IRQ or es100_en == RPI_DEFAULT_GPIO_EN: 225 | print("%s %s" % (program_name, 'gpiod based boards requires irq/en pin selection'), file=sys.stderr) 226 | sys.exit('usage: ' + usage) 227 | continue 228 | 229 | if not is_i2c_bus_valid(i2c_bus): 230 | print("%s %s" % (program_name, 'i2c bus number not present on system'), file=sys.stderr) 231 | sys.exit('usage: ' + usage) 232 | 233 | log = logging.getLogger(program_name) 234 | if flag_debug: 235 | log.setLevel(logging.DEBUG) 236 | if flag_verbose: 237 | log.setLevel(logging.INFO) 238 | 239 | (distance_km, bearing, latency_secs) = caculate_latency(our_location[0], our_location[1]) 240 | 241 | log.info('The great circle distance to WWVB: %.1f Km and ' + 242 | 'direction is %.1f degrees; ' + 243 | 'hence latency %.3f Milliseconds', 244 | distance_km, 245 | bearing, 246 | latency_secs * 1000.0 247 | ) 248 | 249 | our_latency = timedelta(microseconds=latency_secs*1000000.0) 250 | 251 | try: 252 | es100 = ES100(antenna=antenna_choice, irq=es100_irq, en=es100_en, bus=i2c_bus, address=i2c_address, use_gpiod=flag_gpiod, debug=flag_debug, verbose=flag_verbose) 253 | except ES100Error as err: 254 | sys.exit(err) 255 | 256 | # If we are talking to NTPD, now's the time to set that up. 257 | if ntpd_unit_number is not None: 258 | try: 259 | driver28 = NTPDriver28(unit=ntpd_unit_number, debug=flag_debug, verbose=flag_verbose) 260 | log.info('ntpd connected via: %s' % (driver28)) 261 | except NTPDriver28Error as err: 262 | log.warning('failed to connect to ntpd, continuing anyway') 263 | ntpd_unit_number = None 264 | driver28 = None 265 | else: 266 | driver28 = None 267 | 268 | # All set. Let's start receiving till the end of time 269 | 270 | while True: 271 | received_dt = receive(es100, log, flag_force_tracking, flag_enable_nighttime, our_location, our_masl) 272 | if not received_dt: 273 | continue 274 | 275 | # by default WWVB has microsecond == 0 (as it's not in the receive frames) 276 | 277 | # Remember that our_latency we caculated based on our location? 278 | # We now add it into the time received time to correct for our location 279 | received_dt += our_latency 280 | 281 | sys_received_dt = es100.system_time() 282 | if received_dt.year == 1 and received_dt.month == 1 and received_dt.day == 1: 283 | # tracking result with only seconnd and microsecond being accurate 284 | log.info('Time received (seconds only): HH:MM:%02d.%03d at %s', 285 | received_dt.second, 286 | int(received_dt.microsecond / 1000), 287 | sys_received_dt 288 | ) 289 | print('WWVB: (tracking) HH:MM:%02d.%03d at %s' % ( 290 | received_dt.second, 291 | int(received_dt.microsecond / 1000), 292 | sys_received_dt 293 | )) 294 | sys.stdout.flush() 295 | continue 296 | 297 | delta_seconds = es100.delta_seconds() 298 | rx_antenna = es100.rx_antenna() 299 | 300 | if driver28: 301 | leap_second = es100.leap_second() 302 | update_ntpd(driver28, log, received_dt, sys_received_dt, leap_second) 303 | 304 | log.info('Reception of %s at system time %s with difference %.3f via %s', 305 | received_dt, 306 | sys_received_dt, 307 | delta_seconds, 308 | rx_antenna 309 | ) 310 | 311 | print('WWVB: %s at %s' % (received_dt, sys_received_dt)) 312 | sys.stdout.flush() 313 | 314 | # not reached 315 | 316 | previous_nighttime = None 317 | 318 | def receive(es100, log, flag_force_tracking, flag_enable_nighttime, our_location, our_masl): 319 | """ receive() 320 | 321 | :param es100: The previously opened instance used to talk with the ES100-MOD 322 | :param log: Standard Python logging instance 323 | :param flag_force_tracking: A flag used to run the ES100-MODE in trackmode all the time (Default is False) 324 | :param flag_enable_nighttime: A flag used to produce compluted nighttime/daytime. Such that the ES100-MODE can swap between daytime tracking and nighttime reception. (Default is False) 325 | :param our_location [lat, lon]: Receivers location. Negative lat and lon is South and West. 326 | :param our_masl: Receivers MASL (Meters Above Sea Level) 327 | 328 | :return: The received date and time as datetime.datetime 329 | 330 | Setup everything to receive the date and time. 331 | """ 332 | 333 | global previous_nighttime 334 | 335 | if flag_force_tracking: 336 | # Always do tracking (ignore nighttime flag) 337 | new_tracking_flag = True 338 | log.info('Reception starting (tracking forced on)') 339 | else: 340 | if flag_enable_nighttime: 341 | if is_it_nighttime(our_location[0], our_location[1], our_masl): 342 | # nighttime 343 | new_tracking_flag = False 344 | if previous_nighttime is not True: 345 | log.info('Nighttime in-progress (Reception starting)') 346 | previous_nighttime = True 347 | else: 348 | # daytime 349 | new_tracking_flag = True 350 | if previous_nighttime is not False: 351 | log.info('Daytime in-progress (Tracking starting)') 352 | previous_nighttime = False 353 | else: 354 | # Don't care about nighttime/daytime; always receive 355 | new_tracking_flag = False 356 | log.info('Reception starting') 357 | 358 | try: 359 | received_dt = es100.time(tracking=new_tracking_flag) 360 | except (ES100Error, OSError): 361 | return None 362 | 363 | return received_dt 364 | 365 | def update_ntpd(driver28, log, received_dt, sys_received_dt, leap_second): 366 | """ update_ntpd() 367 | 368 | :param driver28: shared memory instance 369 | :param log: logging instance 370 | :param received_dt: date and time just received from WWVB 371 | :param sys_received_dt: date and time of system when date and time was received 372 | :param leap_second: leap second indication 373 | 374 | Try to update NTPD via SHM 375 | """ 376 | log.info('NTPD being updated: %s', received_dt) 377 | driver28.update(received_dt, sys_received_dt, leap_second) 378 | 379 | def is_i2c_bus_valid(bus): 380 | """ _is_i2c_bus_valid """ 381 | system = platform.system() 382 | if system == 'Linux': 383 | try: 384 | dev_name = '/dev/i2c-%d' % (bus) 385 | except ValueError: 386 | return False 387 | return os.path.exists(dev_name) 388 | if system == 'Darwin' or system == 'Windows': 389 | # don't know how to test/check (yet) - but return true in case there's a library being used 390 | return True 391 | # no idea where you're running! 392 | return False 393 | 394 | def signal_handler(signalnum, current_stack_frame): 395 | """ signal_handler() 396 | :param signalnum: Signal number (i.e. 15 == SIGTERM) 397 | :param current_stack_frame: Current stack frame or None 398 | 399 | """ 400 | # cleanup of ES100 and the like will be done by exit 401 | if signalnum == signal.SIGINT: 402 | sys.exit('^C') 403 | sys.exit('Signal received: %s' % (signalnum)) 404 | 405 | def wwvb(args=None): 406 | """ wwvb() 407 | 408 | :param args: list The command line paramaters (shell's $0 $*) (Default is None) 409 | :return: nothing 410 | 411 | WWVB command line entry point 412 | """ 413 | 414 | if args is None: 415 | args = sys.argv[1:] 416 | 417 | signal.signal(signal.SIGTERM, signal_handler) 418 | signal.signal(signal.SIGINT, signal_handler) 419 | 420 | #program_name = sys.argv[0] 421 | program_name = 'wwvb' 422 | doit(program_name, args) 423 | 424 | sys.exit(0) 425 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # es100-wwvb 2 | **WWVB** 60Khz Full functionality receiver/parser for i2c bus based **ES100-MOD** 3 | 4 | ## Description 5 | A time and date decoder for the **ES100-MOD** **WWVB** receiver. 6 | This code implements tracking (for daytime reception) and full receive decoding (for nighttime reception). 7 | It provides Daylight Saving information and Leap Second information, if WWVB provides it. 8 | 9 | It's written 100% in Python and tested on the **Raspberry Pi4** and on the **Raspberry Pi Pico W** (using `micropython`) with, or without an OLED display. 10 | 11 | The core ES100 library fully implements all the features described in **ES100 Data Sheet - Ver 0.97**. 12 | 13 | ## Install 14 | 15 | Use PyPi (see [package](https://pypi.python.org/pypi/es100-wwvb) for details) or GitHub (see [package](https://github.com/mahtin/es100-wwvb) for details). 16 | 17 | ### Via PyPI 18 | 19 | ```bash 20 | $ pip install es100-wwvb 21 | $ 22 | ``` 23 | 24 | The sudo version of that command may be needed in some cases. YMMV 25 | 26 | ### Setting boot options and permissions on Raspberry Pi (or other similar O/S's) 27 | 28 | The program requires access to the GPIO and I2C subsystems. The following provides that access. 29 | 30 | ```bash 31 | $ sudo adduser pi gpio ; sudo adduser pi i2c 32 | Adding user `pi' to group `gpio' ... 33 | Adding user pi to group gpio 34 | Done. 35 | Adding user `pi' to group `i2c' ... 36 | Adding user pi to group i2c 37 | Done. 38 | $ 39 | 40 | $ id 41 | uid=1000(pi) gid=1000(pi) groups=...,997(gpio),998(i2c),... 42 | $ 43 | ``` 44 | Additionally, like all uses of the `adduser` command, the user should logout and log back in for this to take effect; however, the following step will force that action. 45 | 46 | For GPIO on a Raspberry Pi, adding this package does everything required. 47 | ```bash 48 | $ sudo apt-get install rpi.gpio 49 | ... 50 | $ 51 | ``` 52 | 53 | On the Raspberry Pi, the `raspi-config` command should be used to enable the I2C subsystem. 54 | Click on `Interface Options` and then `Advanced Options`. 55 | There you will see an `I2C` option. 56 | Enable it. 57 | Exit. 58 | ```bash 59 | $ sudo raspi-config 60 | ... 61 | $ 62 | ``` 63 | A reboot is required via the `sudo reboot now` command for this to take effect.. 64 | 65 | ## NTP support 66 | 67 | The `wwvb` command line tool provides support for setting the system time via `ntpd`'s shared memory driver. 68 | The short instructions are: 69 | 70 | Add a shared memory server to `/etc/ntp.conf` file. 71 | ``` 72 | server 127.127.28.2 73 | fudge 127.127.28.2 time1 0.0 time2 0.0 stratum 0 refid WWVB flag4 1 74 | ``` 75 | 76 | The `.2` at the end of the address tells `ntpd` pick a shared memory segment that's `2` above the base value of `0x4E545030`. 77 | If you use `0` or `1` for the unit number; then the shared memory area is only readable as `root`. 78 | This is not recommended in any way; hence the reason to recommend `2` (or above) as those are read/write by any user. 79 | See https://www.ntp.org/documentation/drivers/driver28/ for the full details. 80 | 81 | Then add `--ntpd=2` to the running version of `wwvb`. 82 | ```bash 83 | $ wwvb -v -n --ntpd=2 84 | ``` 85 | See the section on setting location, as latency from WWVB is important to calculate correctly. 86 | 87 | See the section of `wwvb.ini` configuration file. 88 | 89 | ## Hardware 90 | This code requires a [UNIVERSAL-SOLDER® Everset® ES100-MOD WWVB-BPSK Receiver Module V1.1](https://universal-solder.ca/downloads/EverSet_ES100-MOD_V1.1.pdf) board/chipset and antenna(s). 91 | 92 | > ES100-MOD is a receiver module for the phase-modulated time signal broadcasted by the NIST radio 93 | > station WWVB near Fort Collins, Colorado, and is based on Everset® Technology’s fully 94 | > self-contained receiver-demodulator-decoder Chip ES100. 95 | 96 | ![](https://github.com/mahtin/es100-wwvb/raw/main/images/universal-solder-everset-es100-wwvb-starter-kit.png) 97 | 98 | Ordering from [UNIVERSAL-SOLDER](https://universal-solder.ca/product-category/atomic-clock-radio-receiver/), along with more information, can me found here: 99 | 100 | * The chipset: [EverSet ES100-MOD WWVB BPSK Phase Modulation Receiver Module](https://universal-solder.ca/product/everset-es100-mod-wwvb-bpsk-phase-modulation-receiver-module/) presently at C$22.90 101 | * The starter kit: [EverSet ES100 WWVB BPSK Atomic Clock Starter Kit](https://universal-solder.ca/product/everset-es100-wwvb-starter-kit/) presently at C$34.90 102 | * The full Arduino-based board: [Application Development Kit for EverSet ES100-MOD Atomic Clock Receiver](https://universal-solder.ca/product/canaduino-application-development-kit-with-everset-es100-mod-wwvb-bpsk-atomic-clock-receiver-module/) presently at C$59.90 103 | 104 | The starter kit is the easiest way to get up-n-running with this software. 105 | 106 | Information about the chip's reception process and operational configuration can be found via [Everset Technologies](https://everset.tech/receivers/): 107 | 108 | * Everset [ES100 Data Sheet – Ver 0.97](https://everset.tech/wp-content/uploads/2014/11/ES100DataSheetver0p97.pdf) 109 | * Everset [Antenna Considerations](https://everset.tech/wp-content/uploads/2014/11/AN-005_Everset_Antenna_Considerations_rev_1p1.pdf) 110 | * EverSet [ES100 Energy Consumption Minimization](https://everset.tech/wp-content/uploads/2014/11/AN-002_ES100_Energy_Consumption_Minimization_rev_2p1.pdf) 111 | 112 | EverSet is a fabless IC company (based in Richardson, Texas). 113 | UNIVERSAL-SOLDER (based in Yorkton Saskatchewan Canada) is the exclusive maker of receiver kits. 114 | 115 | [1] Image and PDF's are Copyright UNIVERSAL-SOLDER Electronics Ltd. 2016-2022. All Rights Reserved. 116 | 117 | ## Wiring 118 | 119 | The ES100 connections are: 120 | 121 | * ES100 Pin 1 == VCC 3.6V (2.0-3.6V recommended) 122 | * ES100 Pin 2 == IRQ Interrupt Request 123 | * ES100 Pin 3 == SCL 124 | * ES100 Pin 4 == SDA 125 | * ES100 Pin 5 == EN Enable 126 | * ES100 Pin 6 == GND 127 | 128 | ### Wiring on Raspberry Pi 129 | It's recommended that IRQ goes to GPIO-17 (physical pin 11) and EN goes to GPIO-4 (physical pin 7). 130 | 131 | ![](https://github.com/mahtin/es100-wwvb/raw/main/images/raspberry-pi-es100-wiring-diagram.png) 132 | 133 | This can be changed via command line arguments. 134 | 135 | ### Wiring on Raspberry Pi Pico W 136 | It's recommended that IRQ goes to GP21 (physical pin 27) and EN goes to GP22 (physical pin 29). 137 | This can be changed via command line arguments. 138 | I2C bus 1 is on GP6 and GP7 (physical pins 9 & 10) 139 | 140 | See the Pico section below for more information 141 | 142 | ### Wiring on any host with an Microchip MCP2221 USB to I2C/GPIO board 143 | _(THIS IS WORK IN PROGRESS - IT ALL WORKS ONCE THIS STATEMENT IS REMOVED)_ 144 | 145 | This is based on the [Adafruit MCP2221A breakout board](https://www.adafruit.com/product/4471). 146 | 147 | ![](https://github.com/mahtin/es100-wwvb/raw/main/images/adafruit-mcp2221a-board.jpg) 148 | 149 | It's recommended that IRQ goes to G1 and EN goes to G0. 150 | Here's a breakout board with the MCP2221A and the ES100. 151 | 152 | ![](https://github.com/mahtin/es100-wwvb/raw/main/images/breakout-board-with-mcp2221-and-es100.jpg) 153 | 154 | Note that MCP2221A does not have internally controlable pulldown resistors and hence one is needed for the IRQ input. A wide range of values can be used. 155 | 156 | ![](https://github.com/mahtin/es100-wwvb/raw/main/images/circuit-diagram-mcp2221-and-es100.png) 157 | 158 | The breakout board above also has two LEDs on GPIO ports G3 & G4 for fun reasons. This is not shown on the circuit diagram. 159 | 160 | See the MCP2221 section below for more information. 161 | 162 | ## Radio Station WWVB 163 | 164 | [WWVB](https://www.nist.gov/pml/time-and-frequency-division/time-distribution/radio-station-wwvb) is located in Ft Collins Colorado USA and is operated by [NIST](https://www.nist.gob/). 165 | To quote the website: 166 | > WWVB continuously broadcasts digital time codes on a 60 kHz carrier that may serve as a stable frequency 167 | > reference traceable to the national standard at NIST. The time codes are synchronized with the 60 kHz 168 | > carrier and are broadcast continuously in two different formats at a rate of 1 bit per second using pulse 169 | > width modulation (PWM) as well as phase modulation (PM). 170 | 171 | ### Further reading 172 | 173 | * https://www.nist.gov/pml/time-and-frequency-division/time-distribution/radio-station-wwvb 174 | * https://tsapps.nist.gov/publication/get_pdf.cfm?pub_id=914904 175 | 176 | ### Location 177 | To use the service; you will need to be within receiving distance (see below) of WWVB in [WWVB, 5701 CO-1, Fort Collins, Colorado 80524](https://goo.gl/maps/KgRn1jDmJ3zSUfxx7). 178 | 179 | It's geographic coordinates are: `40.678062N, 105.046688W`; however, to be be more accurate; there are two antennas as follows: 180 | 181 | * North antenna coordinates: `40° 40' 51.3" N, 105° 03' 00.0" W` == `40.680917 N, 105.050000 W` 182 | * South antenna coordinates: `40° 40' 28.3" N, 105° 02' 39.5" W` == `40.674528 N, 105.044306 W` 183 | 184 | ## Speed of radio waves ... 185 | Light (or radio waves) travel at the speed of light (or close to the speed of light). 186 | In order to know your own accurate time; you need to know the speed of the signal and the distance from the transmitter. 187 | Speed of ground level radio waves was explained in the 1950 paper [The Speed of Radio Waves and Its Importance in Some Applications](https://ieeexplore.ieee.org/document/1701081) by R.L. Smith-Rose Department of Scientific and Industrial Research, Radio Research Station, DSIR, Slough, UK. 188 | 189 | The key number from the paper are: 190 | 191 | * `299,775 km/s` in a vacuum 192 | * `299,250 km/s` for 100 Khz at ground level 193 | * `299,690 km/s` for cm waves 194 | * `299,750 km/s` for aircraft at 30,000 feet (9,800 meters) 195 | 196 | I choose `299,250 km/s` as that matches the WWVB configuration as close as needed. 197 | 198 | ## Best propagation is during nighttime 199 | This code calculates if the transmitter and receiver are at nighttime or not. 200 | This could help decide if the receiver can produce a result. Very Long Wavelength signals propagate better at night. 201 | 202 | The 2014 paper [WWVB Time Signal Broadcast: A New Enhanced Broadcast Format and Multi-Mode Receiver](https://www.nist.gov/publications/wwvb-time-signal-broadcast-new-enhanced-broadcast-format-and-multi-mode-receiver) [1] provides a diagram of the potential propagation of the WWVB signal. 203 | It's calculated for 2am (i.e. middle of night). 204 | 205 | ![](https://github.com/mahtin/es100-wwvb/raw/main/images/figure1-simulated-coverage-area-for-the-legacy-WWVB-broadcast.png) 206 | 207 | > **Figure 1.** Simulated coverage area for the legacy WWVB broadcast at 0800 208 | > UTC (Coordinated Universal Time) in October, where the shaded area is 209 | > the day-night boundary. The simulated coverage assumes the use of a 210 | > properly oriented antenna and the absence of interference and shielding 211 | > losses. These three assumptions are often invalid in indoor applications. 212 | 213 | [1] Lowe, J. , Liang, Y. , Eliezer, O. and Rajan, D. (2014), WWVB Time Signal Broadcast: A New Enhanced Broadcast Format and Multi-Mode Receiver, IEEE Communications Magazine, [online], https://tsapps.nist.gov/publication/get_pdf.cfm?pub_id=915289 (Accessed March 5, 2023) 214 | 215 | Antenna placement and orientation is vital in order to receive a signal. Indoors with a noisy RF environment will limit reception. Have either one antenna perfectly oriented with the WWVB transmitter site; however, that means the other antenna will not receive anything. A better plan is to have the two antennas splitting the direction - that way, both antennas can work. 216 | 217 | This is my reception in California during daytime/nighttime. 218 | 219 | ![](https://github.com/mahtin/es100-wwvb/raw/main/images/antenna-1-vs-2-vs-local-time-of-day.png) 220 | 221 | Clearly, my antenna placement is biased towards antenna 1. Local interference could also affect how each antenna performs. 222 | 223 | The program calculates and prints the direction to point (in degrees clockwise from North) for optimum reception. 224 | 225 | ## Time transmission times 226 | 227 | **NIST**'s **WWVB** 60 Khz signal broadcasts 24/7. However, during the hour cycle, the PM (Phase Modulation) signal changes as follows [1]: 228 | 229 | > Every half-hour, for a duration of six minutes, the normal WWVB-PM 1-minute frames are replaced by the WWVBPM 230 | > extended-mode time code sequences. The ES100 is not capable of receiving during these six-minute intervals that 231 | > occur from HH:10 to HH:16 and HH:40 to HH:46 each hour (i.e. HH= 00, 01,…, 23). 232 | 233 | This means you should not expect to see full reception or time for 5 minutes plus 5 minutes per hour. 234 | Presently the code does not take this into account; it just keeps trying to receive. 235 | As power consumption isn't a prime factor for a Raspberry Pi based systems, this is not a large issue. 236 | 237 | [1] EverSet [ES100 Energy Consumption Minimization](https://everset.tech/wp-content/uploads/2014/11/AN-002_ES100_Energy_Consumption_Minimization_rev_2p1.pdf) 238 | 239 | ## System time vs WWVB receive time 240 | 241 | It is assumed that any modern-day Linux environment has a stable clock and also runs some form of NTP (Network Time Protocol) such that the system time is pretty close to the real time. 242 | The code uses that fact to manage the tracking mode reception. 243 | 244 | ## Getting Started 245 | 246 | The package comes with a command line tool called **wwvb**. 247 | This provides a way to run the ES100 hardware in continuous mode while printing the received date and time upon a successful decode by the hardware. 248 | There are various command options. This example shows how a location (as in Lat/Long) can be passed on the command line. 249 | ```bash 250 | $ wwvb -l 39.861667N,104.673056W 251 | The great circle distance to WWVB: 1501.6 Km and direction is 70.6 degrees; hence latency 5.018 Milliseconds 252 | ... 253 | WWVB: 2023-03-04 12:58:22.005018+00:00 254 | WWVB: 2023-03-04 13:00:36.005018+00:00 255 | WWVB: 2023-03-04 13:05:04.005018+00:00 256 | WWVB: 2023-03-04 13:07:18.005018+00:00 257 | ... 258 | ^C 259 | $ 260 | ``` 261 | Full usage of the command line tool can be found with the `--help` option: 262 | ```bash 263 | $ wwvb --help 264 | usage: wwvb [-V|--version] [-h|--help] [-v|--verbose] [-d|--debug] [-b|--bus={0-N}] [-a|--address={8-127}] [-i|--irq={1-40}] [-e|--en={1-40}] [-l|--location=lat,long] [-m|--masl={0-99999}] [-n|--nighttime] [-t|--tracking] [-A|--antenna={0-1}] [-N|--ntpd={0-255}] [-M|--mcp2221={0-1}] 265 | $ 266 | ``` 267 | 268 | The `--bus` and `--address` options refer to the **i2c** bus position for the ES100 module. These rarely change and in fact are presently unused. 269 | The `--irq` and `--en` options are needed if you connect the ES100 module differently than shown above. Any available GPIO port can be used. 270 | Note that if the lines are wired incorrectly the program will simply hang. 271 | The `--location` and `--masl` provide locations and MASL (Meters Above Sea Level) information. 272 | 273 | The `--location` is required as the program needs to know its accurate location in order to calculate latency. If you don't provide it, your time could be off by many milliseconds. Available formats so far are: 274 | ```bash 275 | --location 37.4,-121.9 276 | --location 37.363056,-121.928611 277 | --location 37.363056N,121.928611W 278 | --location CM97ai 279 | ``` 280 | 281 | A location can also be provided using the four or six character Ham Radio [Maidenhead Locator System](https://en.wikipedia.org/wiki/Maidenhead_Locator_System) for example, **CM97ai**. 282 | 283 | If a location is not provided; then it defaults to my local airport: **SJC** (San José Mineta International Airport). This is possibly incorrect. 284 | 285 | The `--nighttime` option enables the tracking vs reception mode logic for daytime/nighttime reception. 286 | If the flag is not used, then full reception is operating 24/7. 287 | This flag is normally only needed in power-saving situations. 288 | 289 | The `--tracking` flag forces tracking reception 24/7. This will only provide second-resolution responses. 290 | 291 | The `--antenna` flag can force the antenna to be locked into `1` or `2`. 292 | Without this flag, the antenna swap between each reception. 293 | 294 | The `--ntp` flag enables the setting of system time via NTP. See the NTP section above. 295 | 296 | ## Config file 297 | 298 | The `wwvb` command will read a `wwvb.ini` configuration file, either from the current directory, your home directory or `/etc/wwvb.ini`. 299 | This allow setting to be stored without using command line options. 300 | If you provide a command line option, it will override the configuration file. 301 | 302 | Here's a sample configuration file. 303 | ```bash 304 | $ cat wwvb.ini 305 | [WWVB] 306 | # I2C values 307 | bus = 1 308 | address = 50 309 | # GPIO pins 310 | irq = 11 311 | en = 7 312 | # MCP2221/MCP2221A usage for GPIO & I2C 313 | mcp2221 = False 314 | # flags,, as needed 315 | nighttime = False 316 | tracking = False 317 | # select where the receiver is. Add a section below to match your choice 318 | # SJC & Denver are simply examples 319 | station = SJC 320 | #station = Denver 321 | 322 | [DEBUG] 323 | # should you want to debug anything 324 | debug = False 325 | verbose = False 326 | 327 | [NTPD] 328 | # remove comment to connect to NTPD via shared memory on unit 2 329 | # unit = 2 330 | 331 | [SJC] 332 | # Where's our receiver? 333 | name = San José Mineta International Airport 334 | location = [37.363056, -121.928611] 335 | masl = 18.9 336 | antenna = 337 | 338 | [Denver] 339 | # If we had a receiver in Colorado, this is its information 340 | name = Colorado State Capitol Building 341 | location = [39.7393N, 104.9848W] 342 | masl = 1609 343 | antenna = 344 | $ 345 | ``` 346 | 347 | A mimimal `wwvb.ini` file could be: 348 | ```bash 349 | $ cat wwvb.ini 350 | [WWVB] 351 | station = SJC 352 | [SJC] 353 | location = [37.363056, -121.928611] 354 | masl = 18.9 355 | $ 356 | ``` 357 | 358 | If you are runing `wwvb` as a daemon, then the `/etc/wwvb.ini` file would be a better choice. 359 | 360 | ## Raspberry Pi Pico W 361 | 362 | The Pico support runs without features like **NTP**, it does set the RTC (Real Time Clock) on the **Pico** based on the **WWVB** time. 363 | At the present time, there's minimal instruction for this. See below. 364 | 365 | ![](https://github.com/mahtin/es100-wwvb/raw/main/images/raspberry-pi-pico-w-es100-mod.jpg) 366 | 367 | To run the code on a **Raspberry Pi Pico W** using `micropython`, first clone the github repo. 368 | ```bash 369 | $ git clone https://github.com/mahtin/es100-wwvb.git 370 | ... 371 | $ 372 | ``` 373 | 374 | Copy the `es100` and `pico` folder and it's content to `/flash` on the Pico. Producing this file layout: 375 | ```bash 376 | $ rshell 377 | Connecting to /dev/cu.usbmodem1101 (buffer-size 512)... 378 | Trying to connect to REPL connected 379 | Retrieving sysname ... rp2 380 | ... 381 | /Users/martin> cp -r es100 /flash/ 382 | /Users/martin> cp -r pico /flash/ 383 | /Users/martin> ls -l /flash/es100 /flash/pico 384 | /flash/es100: 385 | 198 Mar 22 09:49 __init__.py 386 | 28509 Mar 22 09:50 es100.py 387 | 4808 Mar 22 09:49 gpio_control.py 388 | 4479 Mar 22 09:50 i2c_control.py 389 | 390 | /flash/pico: 391 | 259 Mar 22 09:50 board_led.py 392 | 6418 Mar 22 09:50 datetime.py 393 | 977 Mar 22 09:50 irq_wait_for_edge.py 394 | 2764 Mar 22 09:50 logging.py 395 | 254 Mar 22 09:50 main.py 396 | 2028 Mar 22 09:50 wwvb_lite.py 397 | /Users/martin> repl 398 | Entering REPL. Use Control-X to exit. 399 | > 400 | MicroPython v1.19.1-966-g05bb26010 on 2023-03-13; Raspberry Pi Pico W with RP2040 401 | Type "help()" for more information. 402 | >>> 403 | >>> import flash.pico.main 404 | WWVB: 2023-03-22 14:20:29.000+00:00 2023-03-22 14:20:29.033+00:00 Antenna2 -0.033 405 | WWVB: 2023-03-22 14:24:55.000+00:00 2023-03-22 14:24:55.001+00:00 Antenna2 -0.001 406 | ... 407 | ``` 408 | 409 | ## Adding an OLED display to the Pico 410 | 411 | The code includes basic code to drive an OLED I2C display. 412 | Presently tested with an **I2C 0.96 Inch OLED I2C 128x64 Pixel Display Module** purchased from Amazon at [https://www.amazon.com/dp/B09C5K91H7](https://www.amazon.com/dp/B09C5K91H7). 413 | This code will operate silently without that display attached. 414 | The code expects to be wired to I2C `bus0` using pins `GP8` & `GP9` (this can be changed in code). 415 | Refer to the `oled_display.py` file for more information. 416 | 417 | ![](https://github.com/mahtin/es100-wwvb/raw/main/images/raspberry-pi-pico-w-es100-mod-with-oled.jpg) 418 | 419 | The display will update the screen once a WWVB signal has been received. 420 | 421 | This software port will be expanded upon over time. 422 | 423 | ## Any machine with an Adafruit MCP2221A 424 | 425 | The Linux release provides support for the MCP2221A via a driver module `hid-mcp2221a`. Load that module and you have access to the GPIO pins. 426 | Additionally, the command line and python libraries should be loaded. 427 | 428 | ```bash 429 | $ sudo apt-get install gpiod python3-libgpiod 430 | ... 431 | $ 432 | ``` 433 | 434 | The board (assuming it's plugged in and the Linux driver is found) should show up like this: 435 | 436 | ```bash 437 | $ gpioinfo gpiochip2 438 | gpiochip2 - 4 lines: 439 | line 0: "GP0" unused output active-high 440 | line 1: "GP1" unused input active-high 441 | line 2: "GP2" unused output active-high 442 | line 3: "GP3" unused output active-high 443 | $ 444 | ``` 445 | 446 | Placing the GPIO pins in the right direction is a simple set of commands: 447 | 448 | ```bash 449 | $ gpioset gpiochip2 0=0 && gpioget gpiochip2 1 450 | 1 451 | $ 452 | ``` 453 | 454 | Alternately, (for example on a Mac), CircuitPython provides support for the MCP2221A's i2c port and the GPIO pins. Follow Adafruit's information for installing that. 455 | 456 | ## Other ES100 projects found 457 | 458 | Additional software is out there; here are some of what I found. 459 | I believe my code is presently the most complete code. 460 | 461 | UNIVERSAL-SOLDER 462 | * https://universal-solder.ca/downloads/wwvb_bpsk_es100.zip (use `curl -O` to grab) 463 | 464 | Fio Cattaneo Nov 2019 - es100-wwvb-refclock 465 | * https://github.com/fiorenzo1963/es100-wwvb-refclock 466 | 467 | Keith Greiner April 2019 - How to Receive 60 KHz Time Signals with Arduino Due and ES100 Module 468 | * https://sites.google.com/site/wwvbreceiverwitharduino/home?authuser=0 469 | * https://sites.google.com/site/wwvbreceiverwitharduino/home/es100_starter_code_with_amendments?authuser=0 470 | 471 | ## Changelog 472 | 473 | An automatically generated CHANGELOG is provided [here](CHANGELOG.md). 474 | 475 | ## Author & Copyright 476 | Copyright (C) 2023-2024 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 477 | 478 | -------------------------------------------------------------------------------- /es100/es100.py: -------------------------------------------------------------------------------- 1 | """ WWVB 60Khz Full functionality receiver/parser for i2c bus based ES100-MOD 2 | 3 | A time and date decoder for the ES100-MOD WWVB receiver. 4 | See README.md for detailed/further reading. 5 | 6 | Copyright (C) 2023 Martin J Levy - W6LHI/G8LHI - @mahtin - https://github.com/mahtin 7 | """ 8 | 9 | import time 10 | import random 11 | 12 | try: 13 | from datetime import datetime, timezone 14 | except ImportError: 15 | # micropython does not have datetime, timezone 16 | from pico.datetime import datetime, timezone 17 | 18 | try: 19 | import logging 20 | except ImportError: 21 | # micropython does not have logging 22 | from pico.logging import logging 23 | 24 | from es100.gpio_control import ES100GPIO, ES100GPIOError 25 | from es100.i2c_control import ES100I2C, ES100I2CError 26 | 27 | I2C_DEFAULT_BUS = 1 28 | ES100_SLAVE_ADDR = 0x32 # I2C slave address 29 | 30 | T_WAKEUP = 0.001 # Wakeup time - 1ms 31 | T_1MINUTE_FRAME_RECEPTION = 134 # Reeception time 134 seconds 32 | T_TRACKING_RECEPTION = 24.5 # Tracking Reception 24.5 seconds 33 | T_IRQ_DELAY = 0.1 # -100 thru 100 ms 34 | 35 | T_SLACK = 10 # This is just for timeouts on IRQ's - should never happen 36 | 37 | class ES100Error(Exception): 38 | """ raise this any ES100 error """ 39 | 40 | class ES100: 41 | """ ES100() 42 | 43 | :param antenna: 1 or 2 or None 44 | :param irq: IRQ pin number 45 | :param en: EN pin number 46 | :param bus: i2c bus number 47 | :param address: i2c address 48 | :param use_gpiod: gpiod usage (default is no) 49 | :param debug: True to enable debug messages 50 | :param verbose: True to enable verbose messages 51 | :return: New instance of ES100() 52 | 53 | ES100() provides all the controls for communicating with the ES100-MOD receiver 54 | """ 55 | 56 | # ES100-MOD Registers 57 | class REGISTERS: 58 | """ :meta private: """ 59 | 60 | CONTROL0 = 0x00 61 | CONTROL1 = 0x01 62 | IRQSTATUS = 0x02 63 | STATUS0 = 0x03 64 | YEAR = 0x04 65 | MONTH = 0x05 66 | DAY = 0x06 67 | HOUR = 0x07 68 | MINUTE = 0x08 69 | SECOND = 0x09 70 | NEXT_DST_MONTH = 0x0a 71 | NEXT_DST_DAY = 0x0b 72 | NEXT_DST_HOUR = 0x0c 73 | DEVICE_ID = 0x0d # should return 0x10 (the device id of ES100) 74 | RESERVED0 = 0x0e 75 | RESERVED1 = 0x0f 76 | 77 | # Control0 Read/Write 78 | class CONTROL0: 79 | """ :meta private: """ 80 | START = 0x01 # 1 == start processing, 0 == stops/stopped receiving 81 | ANT1_OFF = 0x02 # 1 == Antenna1 disabled, 0 == Antenna1 enabled 82 | ANT2_OFF = 0x04 # 1 == Antenna2 disabled, 0 == Antenna2 enabled 83 | START_ANT = 0x08 # 1 == Start reception with Antenna2, 0 = Antenna1 84 | TRACKING_ENABLE = 0x10 # 1 == Tracking mode enabled, 0 == disabled 85 | BIT5 = 0x20 86 | BIT6 = 0x40 87 | BIT7 = 0x80 88 | 89 | # Valid values are: 90 | # 0x01 normal mode, starts on Antenna1, toggles between antennas 91 | # 0x03 (Antenna 2 only) 92 | # 0x05 (Antenna 1 Only) 93 | # 0x09 normal mode, starts on Antenna2, toggles between antennas 94 | # 0x13 tracking mode, Antenna2 95 | # 0x15 tracking mode, Antenna1 96 | 97 | # Control1 Read/Write (presently unused) 98 | class CONTROL1: 99 | """ :meta private: """ 100 | BIT0 = 0x01 101 | BIT1 = 0x02 102 | BIT2 = 0x04 103 | BIT3 = 0x08 104 | BIT4 = 0x10 105 | BIT5 = 0x20 106 | BIT6 = 0x40 107 | BIT7 = 0x80 108 | 109 | # IRQ Status Read only 110 | class IRQSTATUS: 111 | """ :meta private: """ 112 | RX_COMPLETE = 0x01 # 1 == Reception complete, 0 == (default) 113 | BIT1 = 0x02 114 | CYCLE_COMPLETE = 0x04 # 1 == Cycle Complete, unsuccessful reception, 0 == (default) 115 | BIT3 = 0x08 116 | BIT4 = 0x10 117 | BIT5 = 0x20 118 | BIT6 = 0x40 119 | BIT7 = 0x80 120 | 121 | # Status0 Read Only 122 | class STATUS0: 123 | """ :meta private: """ 124 | RX_OK = 0x01 # 1 == successful reception 125 | ANT = 0x02 # 1 == Antenna2, 0 == Antenna1 126 | BIT2 = 0x04 127 | LSW0 = 0x08 # LSW[0:1] 00 == Current month doesn't have leap second 128 | LSW1 = 0x10 # LSW[0:1] 10 == Negative leap second, 11 == positive leap second 129 | DST0 = 0x20 # DST[0:1] 00 == No DST, 10 == DST begins today 130 | DST1 = 0x40 # DST[0:1] 11 == DST in effect, 01 == DST ends today 131 | TRACKING = 0x80 # 1 == reception was tracking operation 132 | 133 | def __init__(self, antenna=None, irq=None, en=None, bus=None, address=None, use_gpiod=False, debug=False, verbose=False): 134 | """ :meta private: """ 135 | 136 | self._gpio = None 137 | self._i2c = None 138 | 139 | if isinstance(antenna, str) and len(antenna) > 0: 140 | # antenna defined via string value 141 | try: 142 | antenna = int(antenna) 143 | except ValueError: 144 | raise ES100Error('antenna number incorrect: "%s"' % (antenna)) 145 | if antenna not in [1, 2]: 146 | raise ES100Error('antenna number incorrect: %d' % (antenna)) 147 | self._antenna = antenna 148 | self._antenna_locked = True 149 | elif isinstance(antenna, int): 150 | # antenna defined via int value 151 | if antenna not in [1, 2]: 152 | raise ES100Error('antenna number incorrect: %d' % (antenna)) 153 | self._antenna = antenna 154 | self._antenna_locked = True 155 | else: 156 | # we choose for the user 157 | self._antenna = random.choice([1, 2]) 158 | self._antenna_locked = False 159 | 160 | self._log = logging.getLogger(__class__.__name__) 161 | self._debug = debug 162 | if self._debug: 163 | self._log.setLevel(logging.DEBUG) 164 | self._verbose = verbose 165 | if self._verbose: 166 | self._log.setLevel(logging.INFO) 167 | 168 | self._gpio_irq = irq 169 | if self._gpio_irq is None: 170 | raise ES100Error('gpio irq (interrupt-request) pin must be provided') 171 | 172 | self._gpio_en = en 173 | if self._gpio_en is None: 174 | raise ES100Error('gpio en (enable) pin must be provided') 175 | 176 | self._i2c_bus = bus 177 | if self._i2c_bus is None: 178 | self._i2c_bus = I2C_DEFAULT_BUS 179 | 180 | self._i2c_address = address 181 | if self._i2c_address is None: 182 | self._i2c_address = ES100_SLAVE_ADDR 183 | 184 | self._use_gpiod = use_gpiod 185 | if self._use_gpiod is None: 186 | self._use_gpiod = False 187 | 188 | # start settting up hardware - if it exists! 189 | 190 | try: 191 | self._gpio = ES100GPIO(self._gpio_en, self._gpio_irq, use_gpiod=self._use_gpiod, debug=debug) 192 | except ES100GPIOError as err: 193 | raise ES100Error('GPIO open error: %s' % (err)) from err 194 | self._log.info('gpio connected (EN/Enable=%d IRQ=%d)', self._gpio_en, self._gpio_irq) 195 | 196 | # just in case the device was left with enable enabled 197 | self._disable() 198 | time.sleep(T_WAKEUP) 199 | # wake up sleepy head; time to do some receiving 200 | self._enable() 201 | time.sleep(T_WAKEUP) 202 | 203 | try: 204 | self._i2c = ES100I2C(self._i2c_bus, self._i2c_address, debug=debug) 205 | except ES100I2CError as err: 206 | raise ES100Error('i2c bus %d open error: %s' % (self._i2c_bus, err)) from err 207 | self._log.info('i2c connected (bus=%d address=0x%02x)', self._i2c_bus, self._i2c_address) 208 | 209 | self._device_id = None 210 | self._recv_date = {} 211 | self._recv_time = {} 212 | self._recv_dst_info = {} 213 | self._system_time_received = None 214 | self._wwvb_time_received = None 215 | self._delta_seconds = None 216 | self._status0 = 0x00 217 | self._irq_status = 0x00 218 | self._control0 = 0x00 219 | self._status_ok = 0x00 220 | self._rx_antenna = None 221 | self._tracking_operation = None 222 | self._rx_complete = None 223 | self._cycle_complete = None 224 | self._leap_second = None 225 | self._lsw_bits = 0x0 226 | self._dst_bits = 0x0 227 | self._dst = None 228 | self._dst_begins_today = None 229 | self._dst_ends_today = None 230 | self._dst_next = [None, None, None] 231 | self._dst_special = None 232 | 233 | # find device id 234 | if not self._es100_device_id(): 235 | raise ES100Error('i2c bus probe failed to find ES100 chip') 236 | 237 | def __del__(self): 238 | """ __del__ """ 239 | 240 | if self._i2c: 241 | self._i2c = None 242 | self._log.info('i2c disconnected') 243 | 244 | if self._gpio: 245 | self._disable() 246 | time.sleep(T_WAKEUP) 247 | self._gpio = None 248 | self._log.info('gpio disconnected') 249 | 250 | def __str__(self): 251 | """ :meta private: """ 252 | return 'ES100(DeviceID=%s Antenna=%s, en=%d, irq=%d, bus=%d, address=%d)' % ( 253 | '?' if self._device_id is None else hex(self._device_id), 254 | '?' if self._antenna is None else str(self._antenna), 255 | self._gpio_en, self._gpio_irq, 256 | self._i2c_bus, self._i2c_address 257 | ) 258 | 259 | def __repr__(self): 260 | """ :meta private: """ 261 | return self.__str__() 262 | 263 | def system_time(self): 264 | """ system_time() 265 | 266 | :return: datetime value for reception system time 267 | 268 | After a successful reception, this returns the time the reception interrupt happened. 269 | """ 270 | if not self._rx_complete and not self._status_ok: 271 | raise ES100Error('No reception yet') 272 | return self._system_time_received 273 | 274 | def wwvb_time(self): 275 | """ wwvb_time() 276 | 277 | :return: datetime value for WWVB received time 278 | 279 | After a successful reception, this returns the time heard from WWVB 280 | """ 281 | if not self._rx_complete and not self._status_ok: 282 | raise ES100Error('No reception yet') 283 | return self._wwvb_time_received 284 | 285 | def rx_antenna(self): 286 | """ rx_antenna() 287 | 288 | :return: The antenna number (1 or 2) 289 | 290 | After a successful reception, this returns the antenna used (1 or 2) 291 | """ 292 | if not self._rx_complete and not self._status_ok: 293 | raise ES100Error('No reception yet') 294 | return self._rx_antenna 295 | 296 | def leap_second(self): 297 | """ leap_second() 298 | 299 | :return: The leap second value returned by WWVB 300 | """ 301 | if not self._rx_complete and not self._status_ok: 302 | raise ES100Error('No reception yet') 303 | return self._leap_second 304 | 305 | def is_presently_dst(self): 306 | """ is_presently_dst() 307 | 308 | :return: True if presently DST 309 | """ 310 | if not self._rx_complete and not self._status_ok: 311 | raise ES100Error('No reception yet') 312 | return self._dst 313 | 314 | def delta_seconds(self): 315 | """ delta_seconds() 316 | 317 | :return: The delta seconds between the system received time and the wwvb time 318 | """ 319 | if not self._rx_complete and not self._status_ok: 320 | raise ES100Error('No reception yet') 321 | return self._delta_seconds 322 | 323 | def _enable(self): 324 | """ _enable """ 325 | self._gpio.en_high() 326 | self._log.info('enable set high') 327 | 328 | def _disable(self): 329 | """ _disable """ 330 | self._gpio.en_low() 331 | self._log.info('enable set low') 332 | 333 | def _wait_for_interrupt(self, timeout=None): 334 | """ _wait_for_interrupt """ 335 | self._log.debug('wait for irq') 336 | self._system_time_received = None 337 | irq_happened = self._gpio.irq_wait(timeout) 338 | # save away the current time quikly - i.e. time of decoded reception 339 | self._system_time_received = datetime.utcnow().replace(tzinfo=timezone.utc) 340 | # round down to milliseconds 341 | # WWVB is accurate; but our reception isn't down to the microsecond ('cause linux) 342 | msec = int(self._system_time_received.microsecond/1000.0) 343 | self._system_time_received = self._system_time_received.replace(microsecond=msec*1000) 344 | if not irq_happened: 345 | self._log.warning('wait for irq - timeout') 346 | 347 | def _read_register(self, addr): 348 | """ _read_register 349 | 350 | :param addr: Either an integer value or a string 351 | :return: Register value (it's a byte) 352 | 353 | Core function to read a register from the ES100-MOD 354 | """ 355 | if isinstance(addr, str): 356 | # allow by name references 357 | try: 358 | addr = getattr(ES100.REGISTERS, addr) 359 | except AttributeError as err: 360 | self._log.error('_read_register: %s: invalid name', addr) 361 | raise ES100Error('i2c read: %s' % (err)) from err 362 | 363 | try: 364 | self._i2c.write(addr) 365 | except ES100I2CError as err: 366 | self._log.error('i2c read: %s', err) 367 | raise ES100Error('i2c read: %s' % (err)) from err 368 | 369 | try: 370 | rval = self._i2c.read(addr) 371 | except ES100I2CError as err: 372 | self._log.error('i2c read: %s', err) 373 | raise ES100Error('i2c read: %s' % (err)) from err 374 | self._log.debug('register %d read => 0x%02x', addr, rval & 0xff) 375 | return rval & 0xff 376 | 377 | def _write_register(self, addr, data): 378 | """ _write_register """ 379 | self._log.debug('register %d write <= 0x%02x', addr, data) 380 | try: 381 | self._i2c.write_addr(addr, data) 382 | except ES100I2CError as err: 383 | self._log.error('i2c write: %s', err) 384 | raise ES100Error('i2c write: %s' % (err)) from err 385 | 386 | def _get_device_id(self): 387 | """ _get_device_id """ 388 | self._log.debug('get device_id') 389 | #Read DEVICE_ID register 390 | return self._read_register(int(ES100.REGISTERS.DEVICE_ID)) 391 | 392 | def _get_irq_status(self): 393 | """ _get_irq_status """ 394 | self._log.debug('get irq') 395 | #Read IRQ status register 396 | return self._read_register(int(ES100.REGISTERS.IRQSTATUS)) 397 | 398 | def _get_status0(self): 399 | """ _get_status0 """ 400 | self._log.debug('get status0') 401 | #Read STATUS0 register 402 | return self._read_register(int(ES100.REGISTERS.STATUS0)) 403 | 404 | def _get_control0(self): 405 | """ _get_control0 """ 406 | self._log.debug('get control0') 407 | #Read CONTROL0 register 408 | return self._read_register(int(ES100.REGISTERS.CONTROL0)) 409 | 410 | def _write_control0(self, val): 411 | """ _write_control0 """ 412 | self._write_register(int(ES100.REGISTERS.CONTROL0), val) 413 | 414 | def _read_and_report_irq_and_status0_reg(self): 415 | """ _read_and_report_irq_and_status0_reg """ 416 | self._irq_status = self._get_irq_status() 417 | self._cycle_complete = bool(self._irq_status & ES100.IRQSTATUS.CYCLE_COMPLETE) 418 | self._rx_complete = bool(self._irq_status & ES100.IRQSTATUS.RX_COMPLETE) 419 | 420 | if not self._rx_complete: 421 | self._log.info('irq_status = 0x%02x <...,%s,-,%s>', 422 | self._irq_status, 423 | 'CYCLE_COMPLETE' if self._cycle_complete else '-', 424 | 'RX_COMPLETE' if self._rx_complete else '-', 425 | ) 426 | # don't bother with status0 because it's not valid yet 427 | return 428 | 429 | # status0 should now contain information 430 | self._status0 = self._get_status0() 431 | self._tracking_operation = bool(self._status0 & ES100.STATUS0.TRACKING) 432 | self._rx_antenna = 'Antenna2' if self._status0 & ES100.STATUS0.ANT else 'Antenna1' 433 | self._status_ok = bool(self._status0 & ES100.STATUS0.RX_OK) 434 | 435 | self._log.info('irq_status = 0x%02x <...,%s,-,%s> | status0 = 0x%02x <%s,...,%s,%s>', 436 | self._irq_status, 437 | 'CYCLE_COMPLETE' if self._cycle_complete else '-', 438 | 'RX_COMPLETE' if self._rx_complete else '-', 439 | self._status0, 440 | 'TRACKING' if self._tracking_operation else '-', 441 | self._rx_antenna if self._cycle_complete or self._rx_complete else '-', 442 | 'RX_OK' if self._status_ok else '-', 443 | ) 444 | 445 | def _read_and_report_control0_reg(self): 446 | """ _read_and_report_control0_reg """ 447 | self._control0 = self._get_control0() 448 | 449 | # we don't need to save any of thise bits becuase they aren't referenced 450 | tracking_enabled = bool(self._control0 & ES100.CONTROL0.TRACKING_ENABLE) 451 | start_ant = 2 if self._control0 & ES100.CONTROL0.START_ANT else 1 452 | ant2_off = bool(self._control0 & ES100.CONTROL0.ANT2_OFF) 453 | ant1_off = bool(self._control0 & ES100.CONTROL0.ANT1_OFF) 454 | start = bool(self._control0 & ES100.CONTROL0.START) 455 | 456 | self._log.info('control0 = 0x%02x <...,%s,%s,%s,%s,%s>', 457 | self._control0, 458 | 'TRACKING_ENABLE' if tracking_enabled else '-', 459 | 'Antenna' + str(start_ant) if not tracking_enabled or ant1_off or ant2_off else '-', 460 | 'ANT2_OFF' if ant2_off else '-', 461 | 'ANT1_OFF' if ant1_off else '-', 462 | 'START' if start else '-', 463 | ) 464 | 465 | def _start(self, tracking): 466 | """ _start """ 467 | if not tracking: 468 | control0 = ES100.CONTROL0.START 469 | if self._antenna_locked: 470 | self._log.info('start rx via Antenna%d and locked', self._antenna) 471 | if self._antenna == 1: 472 | control0 |= ES100.CONTROL0.ANT2_OFF 473 | else: 474 | control0 |= ES100.CONTROL0.ANT1_OFF 475 | else: 476 | self._log.info('start rx via Antenna%d', self._antenna) 477 | if self._antenna == 1: 478 | pass # just start bit 479 | else: 480 | control0 |= ES100.CONTROL0.START_ANT 481 | else: 482 | control0 = ES100.CONTROL0.TRACKING_ENABLE | ES100.CONTROL0.START 483 | self._log.info('start tracking via Antenna%d', self._antenna) 484 | if self._antenna == 1: 485 | control0 |= ES100.CONTROL0.ANT2_OFF 486 | else: 487 | control0 |= ES100.CONTROL0.ANT1_OFF 488 | self._write_control0(control0) 489 | 490 | def _start_rx(self): 491 | """ _start_rx """ 492 | 493 | # Every half-hour, for a duration of six minutes, the normal WWVB-PM 1-minute frames are 494 | # replaced by the WWVBPM extended-mode time code sequences. 495 | # The ES100 is not capable of receiving during these six-minute intervals that occur 496 | # from HH:10 to HH:16 and HH:40 to HH:46 each hour (i.e. HH= 00, 01,…, 23). 497 | self._wait_till_16_or_46_minutes() 498 | self._start(tracking=False) 499 | 500 | def _start_tracking(self): 501 | """ _start_tracking """ 502 | 503 | # The duration of a tracking reception is ~24.5 seconds (22 seconds of reception, 504 | # plus ~2.5 seconds of processing and IRQ- generation), 505 | 506 | # The write to Control 0 must occur when the clock second transitions to :55 507 | # (refer to the timing diagrams to see how this supports drift between +4s and -4s). 508 | 509 | self._wait_till_55seconds() 510 | self._start(tracking=True) 511 | 512 | def _es100_device_id(self): 513 | """ _es100_device_id """ 514 | 515 | if self._device_id is None: 516 | try: 517 | self._device_id = self._get_device_id() 518 | except ES100Error: 519 | self._device_id = 0x00 520 | 521 | if self._device_id != 0x10: 522 | self._log.warning('device ID = 0x%02x (unknown device)', self._device_id) 523 | return False 524 | 525 | self._log.info('device ID = 0x%02x (confirmed as ES100-MOD)', self._device_id) 526 | return True 527 | 528 | def _wait_till_16_or_46_minutes(self): 529 | """ _wait_till_16_or_46_minutes """ 530 | 531 | # Reception should not start between HH:10 to HH:16 and HH:40 to HH:46 532 | # (we assume ntp is running - chicken-n-egg issue) 533 | 534 | # however, this present logic is flawed; because we loop after an unsuccessful reception 535 | # somewhere else in the code and simple timeout a reception there. 536 | # this will only be hit if we do a successful reception first. 537 | 538 | time_now = datetime.utcnow() 539 | if not (10 <= time_now.minute < 16 or 40 <= time_now.minute < 46): 540 | # all good! 541 | return 542 | 543 | # need to delay - we only use the lower digit of the minute 544 | # we caculate remaining seconds till HH:16:00 or HH:46:00 545 | remaining_seconds = 6 * 60 - ((time_now.minute % 10) * 60 + time_now.second) 546 | 547 | self._log.info('sleeping %d seconds till %02d:%1d6:00', remaining_seconds, time_now.hour, int(time_now.minute / 10)) 548 | # The suspension time may be longer than requested by an arbitrary amount, because 549 | # of the scheduling of other activity in the system. 550 | # We ignore this fact presently 551 | time.sleep(remaining_seconds) 552 | 553 | def _wait_till_55seconds(self): 554 | """ _wait_till_55seconds """ 555 | 556 | # Tracking should not start till :55 second point 557 | # (we assume ntp is running - chicken-n-egg issue) 558 | 559 | time_now = datetime.utcnow() 560 | remaining_seconds = 55.0 - (time_now.second + time_now.microsecond/1000000.0) 561 | if remaining_seconds < 0.0: 562 | remaining_seconds += 60.0 563 | self._log.debug('sleeping %.1f seconds till HH:MM:55', remaining_seconds) 564 | # The suspension time may be longer than requested by an arbitrary amount, because 565 | # of the scheduling of other activity in the system. 566 | # We ignore this fact presently 567 | time.sleep(remaining_seconds) 568 | 569 | def _es100_receive(self, tracking=False, do_cycles=False): 570 | """ _es100_receive """ 571 | 572 | # start reception 573 | if not tracking: 574 | self._start_rx() 575 | else: 576 | self._start_tracking() 577 | 578 | # the host microcontroller initiates the reception attempt by writing to the CONTROL 0 579 | # register to set the START bit high. This will cause the ES100 to begin signal reception 580 | # and processing. After receiving and processing the signal, the ES100 will generate a 581 | # falling edge on the IRQ- output pin. The host microcontroller then reads the IRQ Status 582 | # register to determine what caused the interrupt. 583 | 584 | # perform read of control0 register 585 | self._read_and_report_control0_reg() 586 | 587 | # loop until time received 588 | while True: 589 | self._read_and_report_irq_and_status0_reg() 590 | 591 | # When the IRQ STATUS register is read with the CYCLE_COMPLETE bit set high, 592 | # indicating an unsuccessful reception attempt, the ES100 automatically drives 593 | # the IRQ- pin back high and attempts another reception. 594 | 595 | if do_cycles and self._cycle_complete: 596 | # we have completed a cycle - caller wants us to return 597 | # but reception is still happening - maybe we should stop receiver? 598 | # needs fixing. Don't get do_cycles quite yet - TODO 599 | return 600 | 601 | # If the RX_COMPLETE bit is set, as in the second attempt in this example, 602 | # the Status, Date, Time, and Next DST registers are all valid and can be 603 | # read by the host. 604 | if self._rx_complete: 605 | # we have info - let's go do stuff! 606 | return 607 | 608 | # now we wait - how long? 134 seconds according to the manual for receive 609 | # we don't actually loop reading the IRQ line, we look for an edge (up or down) 610 | # - way more cpu efficient! 611 | # however, we also set a timeout depending on the operation; just in case! 612 | timeout = T_TRACKING_RECEPTION if tracking else T_1MINUTE_FRAME_RECEPTION 613 | # we need some "extra slack" because this timing could vary 614 | timeout += T_SLACK 615 | self._wait_for_interrupt(timeout) 616 | # We don't assume that the interrupt has compeleted; we loop around and recheck 617 | 618 | # yippe - we exited the loop because RX_COMPLETE is set 619 | # hence there should be a reception/tracking info 620 | 621 | @classmethod 622 | def _bcd(cls, val): 623 | """ _bcd """ 624 | return (val & 0x0f) + ((val >> 4) & 0x0f) * 10 625 | 626 | def time(self, antenna=None, tracking=False, do_cycles=False): 627 | """ time() 628 | 629 | :param antenna: Select antenna (None, 1, or 2) 630 | :param tracking: False means receive operation, True means tracking operation 631 | :return: datetime value for reception system time 632 | 633 | After a successful reception, this returns the time heard from WWVB 634 | """ 635 | 636 | # We should be enabled already 637 | # self._enable() 638 | # time.sleep(T_WAKEUP) 639 | 640 | if antenna: 641 | # user defined 642 | if antenna not in [1, 2]: 643 | raise ES100Error('antenna number incorrect: %d' % (antenna)) 644 | self._antenna = antenna 645 | self._antenna_locked = True 646 | 647 | if not self._antenna_locked: 648 | # swap 2 -> 1 and 1 -> 2 649 | self._antenna = 2 if self._antenna == 1 else 1 650 | 651 | try: 652 | # receive time from WWVB 653 | self._es100_receive(tracking, do_cycles) 654 | except ES100Error as err: 655 | self._log.warning('read/receive failed: %s', err) 656 | return None 657 | 658 | if self._tracking_operation: 659 | if not self._status_ok: 660 | self._log.debug('tracking operation unsuccessful, %s', self._rx_antenna) 661 | return None 662 | 663 | # No value for date/time or other items in tracking mode; just second. 664 | # Manual says: 665 | # Tracking detects the WWVB sync word, including the leading “0” at second :59, 666 | # and provides (in register 0x09) the current WWVB second that begins on the 667 | # falling-edge of IRQ-. 668 | # Note that the registers representing the Year, Month, Day, Hour, Minute 669 | # and Next DST are not valid for a tracking reception. 670 | 671 | self._recv_date = {} 672 | self._recv_time = {} 673 | self._recv_dst_info = {} 674 | 675 | # only second register is valid 676 | for reg in ['SECOND']: 677 | self._recv_time[reg] = self._read_register(reg) 678 | 679 | seconds = ES100._bcd(self._recv_time['SECOND'] & 0x7f) 680 | self._log.info('tracking operation successful, HH:MM:%02d at system time %02d.%03d, %s', 681 | seconds, 682 | self._system_time_received.second, 683 | int(self._system_time_received.microsecond / 1000), 684 | self._rx_antenna 685 | ) 686 | 687 | # we return an obviously wrong result. Only the second value is correct. 688 | self._wwvb_time_received = datetime( 689 | 1, 1, 1, 690 | 0, 0, seconds, 691 | microsecond=0, 692 | tzinfo=timezone.utc 693 | ) 694 | 695 | return self._wwvb_time_received 696 | 697 | if not self._status_ok: 698 | self._log.debug('reception unsuccessful, %s', self._rx_antenna) 699 | # No value for data/time, didn't get reception 700 | return None 701 | 702 | # we have date and time and much more 703 | self._read_all_registers() 704 | 705 | self._wwvb_time_received = datetime( 706 | ES100._bcd(self._recv_date['YEAR'] & 0xff) + 2000, 707 | ES100._bcd(self._recv_date['MONTH'] & 0x1f), 708 | ES100._bcd(self._recv_date['DAY'] & 0x3f), 709 | ES100._bcd(self._recv_time['HOUR'] & 0x3f), 710 | ES100._bcd(self._recv_time['MINUTE'] & 0x7f), 711 | ES100._bcd(self._recv_time['SECOND'] & 0x7f), 712 | microsecond=0, 713 | tzinfo=timezone.utc 714 | ) 715 | 716 | # Success! We have date and time! 717 | self._delta_seconds = (self._wwvb_time_received - self._system_time_received).total_seconds() 718 | self._log.info('Reception of %s at system time %s with difference %.3f via %s', 719 | self._wwvb_time_received, 720 | self._system_time_received, 721 | self._delta_seconds, 722 | self._rx_antenna 723 | ) 724 | 725 | # It seems we could do this; however, we don't (becuase we aren't in a power saving world) 726 | # self._disable() 727 | # time.sleep(T_WAKEUP) 728 | 729 | return self._wwvb_time_received 730 | 731 | def _read_all_registers(self): 732 | """ _read_all_registers() 733 | 734 | Read all the registers - date time dst leap etc 735 | """ 736 | 737 | # read all the date and time registers 738 | self._recv_date = {} 739 | self._recv_time = {} 740 | self._recv_dst_info = {} 741 | for reg in ['YEAR', 'MONTH', 'DAY']: 742 | self._recv_date[reg] = self._read_register(reg) 743 | for reg in ['HOUR', 'MINUTE', 'SECOND']: 744 | self._recv_time[reg] = self._read_register(reg) 745 | # read dst registers 746 | for reg in ['NEXT_DST_MONTH', 'NEXT_DST_DAY', 'NEXT_DST_HOUR']: 747 | self._recv_dst_info[reg] = self._read_register(reg) 748 | self._log.debug('recv date = %s, recv time = %s, dst_info = %s', 749 | self._recv_date, 750 | self._recv_time, 751 | self._recv_dst_info 752 | ) 753 | 754 | # now process all the data! 755 | 756 | # leap second and DST information is coded in two 2-bit areas 757 | # while this looks like a perdantic way to code this up; it's easy to understand 758 | self._lsw_bits = (0x2 if self._status0 & ES100.STATUS0.LSW1 else 0x0) | (0x1 if self._status0 & ES100.STATUS0.LSW0 else 0x0) 759 | self._dst_bits = (0x2 if self._status0 & ES100.STATUS0.DST1 else 0x0) | (0x1 if self._status0 & ES100.STATUS0.DST0 else 0x0) 760 | 761 | if self._lsw_bits == 0x0 or self._lsw_bits == 0x01: 762 | self._leap_second = None 763 | if self._lsw_bits == 0x2: 764 | self._leap_second = 'negative' 765 | self._log.debug('%s leap second', self._leap_second) 766 | elif self._lsw_bits == 0x3: 767 | self._leap_second = 'positive' 768 | self._log.debug('%s leap second', self._leap_second) 769 | 770 | if self._dst_bits == 0x0: 771 | self._dst = self._dst_begins_today = self._dst_ends_today = False 772 | elif self._dst_bits == 0x1: 773 | self._dst = self._dst_ends_today = True 774 | self._dst_begins_today = False 775 | elif self._dst_bits == 0x2: 776 | self._dst = self._dst_ends_today = False 777 | self._dst_begins_today = True 778 | elif self._dst_bits == 0x3: 779 | self._dst = True 780 | self._dst_begins_today = self._dst_ends_today = False 781 | 782 | if self._dst or self._dst_begins_today or self._dst_ends_today: 783 | self._log.debug('DST info: %s %s %s', 784 | 'DST' if self._dst else '', 785 | 'BEGINS-TODAY' if self._dst_begins_today else '', 786 | 'ENDS-TODAY' if self._dst_ends_today else '', 787 | ) 788 | 789 | # Next DST transition info 790 | self._dst_next = [ 791 | ES100._bcd(self._recv_dst_info['NEXT_DST_MONTH'] & 0x1f), 792 | ES100._bcd(self._recv_dst_info['NEXT_DST_DAY'] & 0x3f), 793 | ES100._bcd(self._recv_dst_info['NEXT_DST_HOUR'] & 0x0f), 794 | ] 795 | dst_special = self._recv_dst_info['NEXT_DST_HOUR'] & 0xf0 >> 4 796 | if dst_special & 0x80 == 0x00: 797 | self._dst_special = '' # No DST Special condition 798 | elif dst_special & 0x07 == 0x00: 799 | self._dst_special = 'DST date and time is outside of defined schedule table' 800 | elif dst_special & 0x07 == 0x01: 801 | self._dst_special = 'DST off (regardless of date)' 802 | elif dst_special & 0x07 == 0x02: 803 | self._dst_special = 'DST on (regardless of date)' 804 | else: 805 | self._dst_special = None # invalid 806 | 807 | self._log.debug('Next DST transition YYYY:%02d:%02d @ %02d:00:00 %s', 808 | self._dst_next[0], self._dst_next[1], self._dst_next[2], 809 | self._dst_special 810 | ) 811 | --------------------------------------------------------------------------------