From 38889ded77a68e5caa7244f60b6fce94246f968c Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Fri, 23 Oct 2026 15:13:12 -0400 Subject: [PATCH] Rewritten in python, functionality updated. Weather service improved, now closer to I38's method. --- .gitignore | 2 + INSTALL | 30 +- README.md | 308 ++++++++++++++---- {src => sounds/default}/15.ogg | Bin {src => sounds/default}/30.ogg | Bin {src => sounds/default}/45.ogg | Bin {src => sounds/default}/bell.ogg | Bin {src => sounds/default}/prepend.ogg | Bin src/.testrc | 4 - src/talking-clock | 469 ---------------------------- src/talking_clock/__init__.py | 1 + src/talking_clock/__main__.py | 76 +++++ src/talking_clock/audio.py | 141 +++++++++ src/talking_clock/config.py | 157 ++++++++++ src/talking_clock/gui.py | 404 ++++++++++++++++++++++++ src/talking_clock/runtime.py | 160 ++++++++++ src/talking_clock/speech.py | 59 ++++ src/talking_clock/weather.py | 186 +++++++++++ talking-clock | 12 + talking-clock-gui/README | 4 - talking-clock-gui/talking-clock-gui | 199 ------------ talking-clock.desktop | 8 + tests/test_clock.py | 253 +++++++++++++++ tests/test_gui.py | 360 +++++++++++++++++++++ tests/test_speech.py | 97 ++++++ tests/test_weather.py | 151 +++++++++ 26 files changed, 2334 insertions(+), 747 deletions(-) create mode 100644 .gitignore rename {src => sounds/default}/15.ogg (100%) rename {src => sounds/default}/30.ogg (100%) rename {src => sounds/default}/45.ogg (100%) rename {src => sounds/default}/bell.ogg (100%) rename {src => sounds/default}/prepend.ogg (100%) delete mode 100644 src/.testrc delete mode 100755 src/talking-clock create mode 100644 src/talking_clock/__init__.py create mode 100644 src/talking_clock/__main__.py create mode 100644 src/talking_clock/audio.py create mode 100644 src/talking_clock/config.py create mode 100644 src/talking_clock/gui.py create mode 100644 src/talking_clock/runtime.py create mode 100644 src/talking_clock/speech.py create mode 100644 src/talking_clock/weather.py create mode 100755 talking-clock delete mode 100644 talking-clock-gui/README delete mode 100755 talking-clock-gui/talking-clock-gui create mode 100644 talking-clock.desktop create mode 100644 tests/test_clock.py create mode 100644 tests/test_gui.py create mode 100644 tests/test_speech.py create mode 100644 tests/test_weather.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43ae0e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.py[cod] diff --git a/INSTALL b/INSTALL index 46514b1..e6eca26 100755 --- a/INSTALL +++ b/INSTALL @@ -1,18 +1,16 @@ #!/bin/bash - -#To install using this script: -#chmod 700 INSTALL -#then run by typing ./INSTALL as root or with sudo. -# If you want to install by hand, just copy the commands below the word fi that ends the if statement below. Exclude the exit 0 statement. -if [[ $(whoami) != "root" ]] ; then -echo "Need to be root." -exit 1 -fi - -cp src/talking-clock /usr/bin/talking-clock -chmod 755 /usr/bin/talking-clock -mkdir -p /usr/share/talking-clock/ -cp src/{bell,prepend,15,30,45}.ogg /usr/share/talking-clock/ -cp README.md /usr/share/talking-clock/README.md -exit 0 +# Run with suitable permissions, or use DESTDIR for packaging/staging. +set -euo pipefail +cd -- "$(dirname -- "$0")" +prefix="${PREFIX:-/usr}" +destination="${DESTDIR:-}" +install -Dm755 talking-clock "$destination$prefix/bin/talking-clock" +install -d "$destination$prefix/share/talking-clock/python/talking_clock" +for module in src/talking_clock/*.py; do + install -m644 "$module" "$destination$prefix/share/talking-clock/python/talking_clock/" +done +install -d "$destination$prefix/share/talking-clock/sounds/default" +install -m644 sounds/default/*.ogg "$destination$prefix/share/talking-clock/sounds/default/" +install -m644 README.md LICENSE "$destination$prefix/share/talking-clock/" +install -Dm644 talking-clock.desktop "$destination$prefix/share/applications/talking-clock.desktop" diff --git a/README.md b/README.md index ab53a96..bfdc7e3 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,263 @@ -talking-clock -============= +# Talking Clock -A highly configurable clock written in bash with soundpack and voice options. -Written by Storm Dragon -Released under the terms of the WTFPL: http://wtfpl.net, or see the included LICENSE file. +An accessible clock with a large, updating time display, scheduled speech, and +soundpack chimes. Written in Python with a GTK 3 interface. Released under the +WTFPL; see LICENSE. -talking-clock can chime and/or announce the time when called usually by cron. It is highly configurable. It can be set to chime every hour, quarter, hour or half hour. For instructions on editing the crontab file see man crontab. Here are some suggested entries: -#chime every hour between 10:00AM and 10:00PM -0 10-22 * * * talking-clock -#chime every half-hour: -0,30 * * * * talking-clock -#chime every quarter-hour: -0,15,30,45 * * * * talking-clock -talking-clock can modify cron for you. to do so use -talking-clock --cron number -where number is 1 for every hour, 2 for every half-hour, or four for every quarter-hour. Any other entry removes talking-clock settings from, causing talking-clock to not chime at all unless called specifically by you. The --cron switch only allows one argument, using multiple arguments will not work. If you use the --cron argument it must be the first argument follow by only one other argument. +## Running -talking-clock understands the following arguments: --a --audio Sound command. Command to play sound. the default is play -q provided by the sox package. Another example, if you have vorbis-tools installed would be -ogg123 -q -or -paplay --c --nochime Turn off chimes. --n --nospeak turn off spoken time. --s --soundpack Set path to soundpack. Sound packs should be in ogg format and contain 1.ogg, 2.ogg, ... 11.ogg, 12.ogg and 15.ogg, 30.ogg, and 45.ogg for the quarter-hour chimes. --v --voice Select voice. Default is espeak other options are -cepstral, espeak, festival, flite, flite_time, gama_tts, googletts (requires the translate-shell package), rhvoice-(voicename), sam, speech-dispatcher and custom. -If your tts files for gama_tts are somewhere other than /usr/local/share/gama_tts/data/english/ then you can specify it in your ~/.config/talking-clock/talking-clockrc. Just do gama_tts_path="/path/to/tts/vtmN where N is the number you want. -To set a custom voice enter the command as in: --v 'espeak -v en-us+klatt2' -Note that rhvoice requires a - followed by the voice name you want, for example, rhvoice-bdl or rhvoice-alan. -zipcode and flite_time are incompatable. If you have zipcode set the temperature will not be read while using flite_time. --z --zipcode Postal code: Will speak the temperature along with the time so long as you do not have nospeak set. E.G. -z 90210. With a recent change in the weather provider, you can now use airport codes, or the name of a location, remember to put anything with spaces in quotes, so zipcode="city state". Also, postal codes seem to be less reliable than location names or airport codes. For more information on what types of locations are accepted, run the following command: +```sh +./talking-clock # open the clock window +./talking-clock -h # show help +./talking-clock -H # run headlessly with saved settings +./talking-clock -k # stop the running instance, including active audio +``` -curl https://wttr.in/:help +These are the only command-line options. Closing the clock window stops the +whole program. Headless mode runs until stopped with `-k`, SIGTERM, or Ctrl+C. +Only one instance runs per user. Launching normally presents the existing GUI +window, or stops a headless instance and replaces it with the GUI process. +The GUI launch first checks that a display is available; if it cannot open a +display, the headless instance keeps running. Closing the replacement window +stops the clock completely. Launching with `-H` while an instance is already +running leaves that instance in its current mode. -Although zipcode should more accurately be called location, the old zipcode is kept for compatibility. With the new changes, it is much more powerful and accurate. +The main window has two keyboard focus targets: the time display and Settings. +Focus starts on the time; Tab or Shift+Tab reaches Settings. Alt+S also opens +Settings. Ctrl+Q closes the entire program from the clock or Settings window; +unsaved settings are discarded. Press Space on the time display to speak the current time using your +selected voice, without chimes, even if scheduled speech is disabled. +The Weather tab's "Include weather in time announcement (Space)" checkbox +also includes current weather when enabled; it is off by default and independent +of weather in scheduled announcements. +Space on the Settings button activates that button normally. Requests made +while another announcement is playing are skipped to avoid overlapping audio. +The display updates every second. Display preferences include 12/24 +hour time, showing seconds, font size (18–240 points), and white text on black. -talking clock can also read settings from the following files? -/etc/talking-clockrc global settings -XDG_CONFIG_HOME/talking-clock/talking-clockrc -(Usually XDG_CONFIG_HOME is ~/.config) -Settings for individual users. If both files exist the user specific settings will be used. Command line arguments are considered all powerful and will override all others. +Settings has General, Sounds, Speech, Display, and Weather tabs. Changes take +effect when saved; Cancel discards edits. General has independent checkboxes for +announcements on the hour (:00), half hour (:30), and quarter hours (:15/:45). +Speech and chimes can each be enabled separately. Defaults are hourly speech +and chimes, 12-hour time with seconds, and a 72-point display. -Configuration files should be written as command=setting. Here is an example of a configuration file: -format=24 -sound=play -q -soundpack=/home/USER/thunderstorm -chime=true -speak=true -zipcode="Raleigh North Carolina" -Soundpack should be the path to a directory. Do not include the trailing / talking-clock adds it for you. -voice=espeak -v en-us+klatt2 -s 300 -Be careful when using custom voice commands, if it is not valid the program will not work. You do not have to specify parameters for voice commands if you want to use the defaults. You can use voice=espeak (this is the default), voice=cepstral, voice=speech-dispatcher, voice=rhvoice-bdl, or voice=festival. +The clock follows local system time. It does not announce immediately when +started partway through a minute, replay missed announcements after suspend, +or repeat an announcement for a local minute already announced during a +backward clock change. Audio plays in a worker thread so the interface stays +responsive. An announcement is skipped if the previous one is still playing. -Creating soundpacks is very simple. Make a directory named anything you wish, thunderstorm for example, and place files inside it. The naming scheme for soundpack files follows the hours 1 through 12 and quarter hours 15, 30, 45. Each sound file must be in .ogg format. So, your directory will contain 1.ogg, 2.ogg, ... 12.ogg, 15.ogg, 30.ogg, and 45.ogg. If hourly chimes sound the same, use a bell for example, just name the chime bell.ogg. If hourly chimes should be prepended with a sound, then name the sound prepend.ogg, will play at each hour, before the chimes.To download or submit soundpacks go to: -https://stormdragon.tk/talking-clock/ +## Dependencies and installation -If you wish to contact me you can find me on GNU Social as @storm@social.stormdragon.tk. Also don't forget to check out my blog located at: -https://stormdragon.tk/ -I can also usually be found on irc.talkabout.cf in channel #a11y +Required: - - +- Python 3.8 or later. +- For the GUI: PyGObject with GTK 3 introspection bindings. +- For the default chimes: SoX (`play`) with Ogg Vorbis support. +- For speech: Speech Dispatcher (`spd-say`) with a configured output module. +- For module/voice discovery in Settings: Speech Dispatcher's Python bindings + (the `speechd` module). + +Install your chosen audio player and speech engine separately. Yad and cron +are no longer dependencies. Headless mode does not import GTK. + +Run directly from this checkout using `./talking-clock`, or install: + +```sh +sudo ./INSTALL +``` + +The installer defaults to `/usr`, installs the launcher, Python modules, +default soundpack, documentation, and a desktop application entry. It accepts +`PREFIX` for another installation prefix and `DESTDIR` for package staging: + +```sh +DESTDIR=/tmp/talking-clock-package PREFIX=/usr ./INSTALL +``` + +## Configuration + +Settings are JSON data in +`$XDG_CONFIG_HOME/talking-clock/settings.json`, normally +`~/.config/talking-clock/settings.json`. The Settings dialog creates this file +on Save. The headless clock uses the same settings and reloads external edits +while running. Invalid external edits are reported to stderr and the previous +working settings remain active. + +For a headless-only installation, this minimal file enables hourly chimes +without speech: + +```json +{ + "hourly": true, + "half_hour": false, + "quarter_hour": false, + "chime": true, + "speak": false, + "soundpack": "default" +} +``` + +Unspecified settings use their defaults. To see every setting, save once from +the GUI. Files are written atomically with permissions restricted to your user. +The audio player command accepts an executable name and quoted arguments; +shell operators, substitutions, and pipelines are not interpreted. + +All speech uses Speech Dispatcher. The Speech tab has combo boxes for module, +voice, rate, pitch, and volume. Module and voice offer System default, or you +can select an installed module (such as eSpeak NG or RHVoice) and one of its +voices. Voice discovery queries Speech Dispatcher without producing speech +and runs in the background. Changing module reloads the available voices and +resets the voice selection to System default. If discovery fails, saved +module/voice selections are preserved and the dialog shows the error. + +Voices are filtered to the system language and region (for example `en-US`). +If there is no exact match, voices for the broader language (`en`) are shown. +This keeps eSpeak NG's list limited to relevant voices and variants. Language +is taken from the first non-C/POSIX setting in `LC_ALL`, `LANGUAGE`, +`LC_MESSAGES`, and `LANG`, with English as the fallback. The same language is +sent to Speech Dispatcher for announcements. An unavailable saved voice is +replaced by System default when saving after successful discovery. + +Rate, pitch, and volume use Speech Dispatcher's -100 to +100 scale, with 0 as +normal. Combo boxes offer steps of 10 and preserve any intermediate value +already saved in JSON. Some output modules may ignore unsupported controls. +Choose Test speech to hear the current time with the selections currently in +the Speech tab, before saving. Testing does not save changes or include chimes +or weather. +These preferences are used for both scheduled announcements and Space to +speak the time. See the [Speech Dispatcher client documentation](https://github.com/brailcom/speechd/blob/master/doc/spd-say.texi). + +Weather is optional and off by default. It uses Open-Meteo for temperature and +conditions, with Fahrenheit (default) or Celsius selected in the Weather tab. +For example: "77 degrees Fahrenheit and cloudy." No forecasts or other weather +measurements are requested. A failed refresh silently omits weather from the +spoken announcement; the clock still speaks the time, without a weather error. +Technical errors go to stderr or the service journal. + +When the location fields are empty, opening Settings prefills an approximate +city and coordinates using IPinfo, as I38 does. IP location may be inaccurate, +especially with a VPN. Review the detected location before saving. To correct +it, enter a city and region (or postal code) and choose Find city, or enter +latitude and longitude directly. City search uses Open-Meteo's geocoding API +and GeoNames data. A changed city does not retain the old city's coordinates. +Network lookups run in background threads, without freezing the interface. + +IP detection contacts IPinfo using your public IP; city search sends your +search text to Open-Meteo, and weather requests send coordinates to Open-Meteo. +All use HTTPS. Lookups and weather require an internet connection. Automatic +location detection is performed only when no location has been configured. + +The client caches results under `$XDG_CACHE_HOME/talking-clock/`, normally +`~/.cache/talking-clock/`. The cache and request budget survive restarts and +are shared by simultaneous requests. Weather is fetched on demand, at most +once per quarter-hour slot (:00, :15, :30, :45), including when coordinates +change. Changing units converts the cached temperature without a new request. +Expired data is omitted if a refresh fails. The cache expires at the next +quarter hour, but that does not guarantee the underlying conditions are less +than 15 minutes old: these are weather-model estimates, and provider updates +can be delayed. See [Open-Meteo's current-weather documentation](https://open-meteo.com/en/docs). + +IP location is cached for 24 hours, and a city search for 30 days. Repeated +lookups are throttled, failures back off, and HTTP `Retry-After` is respected. +Regular quarter-hour weather checks make at most 96 forecast requests per day. +Open-Meteo's free noncommercial service permits fewer than 10,000 calls/day, +5,000/hour, and 600/minute; its terms also list a 300,000/month limit. +See [Open-Meteo's terms](https://open-meteo.com/en/terms). +IPinfo's unauthenticated endpoint permits 1,000 daily requests shared across +the public IP; see [IPinfo's public API limits](https://support.ipinfo.io/hc/en-us/articles/30792479436562-What-Is-the-Difference-Between-Using-the-Authenticated-Free-Plan-and-the-Public-API-With-No-Account). + +Weather data: [Open-Meteo](https://open-meteo.com/), licensed under +[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/); the clock rounds +temperatures, converts units, and describes WMO codes in plain English. +City lookup data: [GeoNames](https://www.geonames.org/), via +[Open-Meteo geocoding](https://open-meteo.com/en/docs/geocoding-api). + +## Soundpacks + +Bundled sounds live in `sounds/default/` in the repository. Installed packs +are discovered under: + +- `$XDG_DATA_HOME/talking-clock/sounds/`, normally + `~/.local/share/talking-clock/sounds/`. +- Each `$XDG_DATA_DIRS` entry followed by `/talking-clock/sounds/`, normally + `/usr/local/share/talking-clock/sounds/` and + `/usr/share/talking-clock/sounds/`. + +Each pack has its own directory, such as `sounds/default/` or `sounds/bells/`. +A user pack takes priority over a system pack with the same name. The checkout's +`sounds/` folder is also searched when running from source. The Sounds tab can +select a discovered name or an absolute path to another pack directory. + +A pack contains Ogg files: + +- `1.ogg` through `12.ogg` for recordings of each hour, or `bell.ogg` repeated + the appropriate number of times when an hour recording is absent. +- `15.ogg`, `30.ogg`, and `45.ogg` for quarter/half-hour chimes. +- Optional `prepend.ogg`, played before hourly chimes. + +Missing sounds are reported to stderr. Speech runs before chimes, matching the +original clock. Hourly bell counts always use 1–12, including in 24-hour mode. + +## Migrating from the Bash version + +The old Bash executable and separate Yad GUI have been replaced. There is no +built-in crontab management and no one-shot announcement mode. Remove old +recurring talking-clock cron entries before switching to the persistent clock; +old entries now invoke a GUI rather than a one-shot announcement. The installer +and clock do not modify your crontab. + +If no JSON settings exist, the clock reads known assignments from +`$XDG_CONFIG_HOME/talking-clock/talking-clockrc`, or `/etc/talking-clockrc` as a +fallback. It imports speech on/off, chime, format, audio command, soundpack, +and weather preferences as data without executing the file. Old engine-specific +voice commands and earlier Python engine settings are discarded in favor of +Speech Dispatcher defaults; choose module/voice and tuning in the Speech tab. +Shell expressions in old settings are not evaluated. Saving in the GUI writes +the new JSON file and +leaves the old file untouched. Old CLI options are removed; set those options +in Settings instead. The old cron schedule is not imported; choose the desired +interval checkboxes in General. Existing external soundpack directories can +still be used by their absolute paths. + +## Headless startup with systemd + +For a user service, create `~/.config/systemd/user/talking-clock.service`: + +```ini +[Unit] +Description=Talking Clock + +[Service] +ExecStart=/usr/bin/talking-clock -H +Restart=on-failure + +[Install] +WantedBy=default.target +``` + +Then enable it with `systemctl --user enable --now talking-clock.service`. +Use your installed executable path if a different prefix was chosen. Run it +under your own user so it can access your audio session. Configure the clock +before starting the service. A normal `talking-clock` launch replaces the +headless instance with the GUI. The example's `Restart=on-failure` leaves an +intentional stop stopped; using `Restart=always` would restart headless mode +after takeover or `-k`. View headless errors with +`journalctl --user -u talking-clock.service`. + +## Development checks + +```sh +python3 -m unittest discover -s tests -v +shellcheck INSTALL +``` + +For opt-in GTK checks on a working desktop: + +```sh +TALKING_CLOCK_GUI_TESTS=1 python3 -m unittest discover -s tests -v +``` + +Tests disable or mock clock speech and sound playback. Desktop checks cover +widget focus, live display updates, settings, and window shutdown; actual +screen-reader speech and visual usability still need human testing. diff --git a/src/15.ogg b/sounds/default/15.ogg similarity index 100% rename from src/15.ogg rename to sounds/default/15.ogg diff --git a/src/30.ogg b/sounds/default/30.ogg similarity index 100% rename from src/30.ogg rename to sounds/default/30.ogg diff --git a/src/45.ogg b/sounds/default/45.ogg similarity index 100% rename from src/45.ogg rename to sounds/default/45.ogg diff --git a/src/bell.ogg b/sounds/default/bell.ogg similarity index 100% rename from src/bell.ogg rename to sounds/default/bell.ogg diff --git a/src/prepend.ogg b/sounds/default/prepend.ogg similarity index 100% rename from src/prepend.ogg rename to sounds/default/prepend.ogg diff --git a/src/.testrc b/src/.testrc deleted file mode 100644 index abcb743..0000000 --- a/src/.testrc +++ /dev/null @@ -1,4 +0,0 @@ -setting1=abc -setting2=-v en-us -s 1280 -setting3-testing - diff --git a/src/talking-clock b/src/talking-clock deleted file mode 100755 index 56746d5..0000000 --- a/src/talking-clock +++ /dev/null @@ -1,469 +0,0 @@ -#!/bin/bash - -#talking-clock written by Storm Dragon -#project first created on Wednesday, March 23, 2011 -#license WTFPL: http://wtfpl.net - -display_help() -{ -cat << EOF -Talking-clock by Storm Dragon -Talking-clock accepts the following arguments: ---cron number: -If you use --cron the only valid entry is a number following it. 1 will cause the clock to chime every hour, 2 every -half-hour, 4 every quarter, and anything else will delete your existing settings if they exist. --a --audio Command for playing sound. The default is play -q provided from the sox package. --c --nochime Turn off chimes. --f --format: 12 or 24 hour format. Default is 12 hour time. --n --nospeak turn off spoken time. --s --soundpack Set path to soundpack. Sound packs should be in ogg format and contain 1.ogg, 2.ogg, ... 11.ogg, 12.ogg and - 15, 30, and 45.oggfor the quarter-hour chimes. --v --voice Select voice. Default is espeak other options are -cepstral, espeak, festival, gama_tts, rhvoice-(voicename), sam, speech-dispatcher and custom. -To set a custom voice enter the command as in: --v 'espeak -v en-us+klatt2' -rhvoice is a special case, you must include the name of the voice you want as in rhvoice-bdl --z --zipcode postal code Used for current temperature, may not be available inn all areas. -For complete information read the README located at /usr/share/talking-clock/README" -EOF -} - -number_to_text() -{ - # If the first argument is not numeric, then it is the return variable. - if ! [[ "$1" =~ ^[0-9]+$ ]]; then - local __numberVariable=$1 - shift - fi - local number=$1 - local digit - # Check for negative numbers. - if [ "${number:0:1}" = "-" ]; then - local textNumber="negative " - number="${number:1}" - else - local textNumber="" - fi - # This loop processes the numbers, only up to 999 for now, but can be expanded easily, I think. - while [ ${#number} -gt 0 ]; do - # To expand, add or to the if, 2 or 5 or 8, etc - if [ ${#number} -eq 2 ]; then - case $number in - 0[1-9]) - textNumber="${textNumber}and " - number="${number:1}" - ;; - 10) - textNumber="${textNumber}ten" - break - ;; - 11) - textNumber="${textNumber}eleven" - break - ;; - 12) - textNumber="${textNumber}twelve" - break - ;; - 13) - textNumber="${textNumber}thirteen" - break - ;; - 14) - textNumber="${textNumber}fourteen" - break - ;; - 15) - textNumber="${textNumber}fifteen" - break - ;; - 16) - textNumber="${textNumber}sixteen" - break - ;; - 17) - textNumber="${textNumber}seventeen" - break - ;; - 18) - textNumber="${textNumber}eightteen" - break - ;; - 19) - textNumber="${textNumber}nineteen" - break - ;; - 2*) - textNumber="${textNumber}twenty" - ;; - 3*) - textNumber="${textNumber}thirty" - ;; - 4*) - textNumber="${textNumber}forty" - ;; - 5*) - textNumber="${textNumber}fifty" - ;; - 6*) - textNumber="${textNumber}sixty" - ;; - 7*) - textNumber="${textNumber}seventy" - ;; - 8*) - textNumber="${textNumber}eighty" - ;; - 9*) - textNumber="${textNumber}ninety" - ;; - esac - fi - # This stops the loop if the final digit is 0 otherwise we get a repeat of the digit before, 90 becomes 99 etc. - if [ ${#number} -eq 2 -a ${number:$((${#number} - 1)):1} -eq 0 ]; then - break - fi - # Put dashes in the proper places. - if [ ${#number} -eq 2 -a $number -gt 20 ]; then - if [ $(($number % 10)) -ne 0 ]; then - textNumber="${textNumber}-" - fi - fi - # Process the correct digit based on number length. - case ${#number} in - 3) - digit=${number:0:1} - ;; - 2) - digit=${number:$((${#number} - 1)):1} - ;; - 1) - digit=${number:$((${#number} - 1)):1} - esac - case $digit in - 1) - textNumber="${textNumber}one" - ;; - 2) - textNumber="${textNumber}two" - ;; - 3) - textNumber="${textNumber}three" - ;; - 4) - textNumber="${textNumber}four" - ;; - 5) - textNumber="${textNumber}five" - ;; - 6) - textNumber="${textNumber}six" - ;; - 7) - textNumber="${textNumber}seven" - ;; - 8) - textNumber="${textNumber}eight" - ;; - 9) - textNumber="${textNumber}nine" - esac - # Add the correct bit to the text number, million, thousand, hundred, etc. Trim off already processed digits. - case ${#number} in - 3) - if [ $(($number % 100)) -eq 0 ]; then - textNumber="${textNumber} hundred" - else - textNumber="${textNumber} hundred " - fi - number="${number:1}" - ;; - 2) - number="${number:2}" - ;; - *) - number="${number:1}" - esac - done - # If we have a return variable set its value to the text. - if [ -n "$__numberVariable" ]; then - eval $__numberVariable="'$textNumber'" - # Else just echo the text. - else - echo "$textNumber" - fi -} - -#Create or modify cron jobs -if [ "$1" == "--cron" ] ; then - if [ "$#" -ne "2" ] ; then - echo -e "Invalid cron option:\n"\ - "to set a cron enter:\n"\ - "talking-clock --cron chimes-per-hour\n"\ - "If you would like cron to chime 4 times per hour (every quarter hour) you would enter:\n"\ - "talking-clock --cron 4\n"\ - "Valid entries are 1, 2, and 4. Anything else causes the cron to be removed if it exists.\n"\ - "After you have made changes you can use the command:\n"\ - "crontab -l\n"\ - "to view your settings." - exit 1 - fi - crontabFile="$(mktemp)" - crontab -l > "$crontabFile" - read -n1 -p "You are about to modify talking-clock settings. Are you sure you want to do this? (Only y or Y confirms, anything else cancels) " answer - echo - if [ "${answer^}" != "Y" ]; then - echo "talking-clock settings unchanged." - rm "$crontabFile" - exit 0 - fi - sed -i -r '/^.*talking-clock.*$/d' "$crontabFile" - case "$2" in - "1") - #Chime once per hour. - echo "# talking-clock settings" >> "$crontabFile" - echo "0 * * * * XDG_RUNTIME_DIR=/run/user/$UID /usr/bin/talking-clock &> /dev/null" >> "$crontabFile" - ;; - "2") - #Chime twice per hour. - echo "# talking-clock settings" >> "$crontabFile" - echo "0,30 * * * * XDG_RUNTIME_DIR=/run/user/$UID talking-clock &> /dev/null" >> "$crontabFile" - ;; - "4") - #Chime four times per hour. - echo "# talking-clock settings" >> "$crontabFile" - echo "0,15,30,45 * * * * XDG_RUNTIME_DIR=/run/user/$UID talking-clock &> /dev/null" >> "$crontabFile" - esac - #install the new cron file. - if [ -f/"$crontabFile" ]; then - if crontab "$crontabFile" ; then - echo "talking-clock settings updated." - rm "$crontabFile" - exit 0 - else - echo "There was an error installing the new crontab file." - rm "$crontabFile" - exit 1 - fi - else - echo "Couldn't create new crontab." - exit 1 - fi - exit 0 -fi - -#initialize variables -xdgPath="${XDG_CONFIG_HOME:-$HOME/.config}" -#Check for settings files in order of importants -if [ -f "$xdgPath/talking-clock/talking-clockrc" ] ; then - #Read from local settings - source "$xdgPath/talking-clock/talking-clockrc" -elif [ -f "/etc/talking-clockrc" ] ; then - #Read from global settings - source "/etc/talking-clockrc" -fi -hour=$(date +'%-l') -minute=$(date +'%-M') -timeOfDay=$(date +'%p' | sed 's/AM/A M/') -#play chimes? -chime="${chime:-true}" -#command used to play sounds -soundCommand="${sound:-play -qV0}" -#default voice for speaking time is espeak -voice="${voice:-espeak -v en-us -a 150}" -#should the time be spoken? -speakTime="${speak:-true}" -# Load soundpack. -soundPack="${soundpack:-/usr/share/talking-clock}" -# Time format -format="${format:-12}" - -#Get and process commandline args which override all other settings. -while [ $# -gt 0 ] ; do - case "$1" in - "-a" | "--audio") - shift - soundCommand="$1" - ;; - "-f" | "--format") - shift - if [ "$1" == "12" -o "$1" == "24" ] ; then - if [ $1 -eq 24 ] ; then - format="24" - fi - else - echo "Invalid time format: Valid options are 12 and 24." - exit 1 - fi - ;; - "-s" | "--soundpack") - shift - if [ -d "$1" ] ; then - soundPack="$1" - else - echo "Directory $1 does not exist." - exit 1 - fi - ;; - "-n" | "--nospeak") - speakTime="false" - ;; - "-c" | "--nochime") - chime="false" - ;; - "-v" | "--voice") - shift - voice="$1" - ;; - "-z" | "--zipcode") - shift - zipcode="$1" - ;; - *) - display_help - exit 0 - esac - shift -done - -#Speak the time -#Safely create voice files for tts who write to file -voiceFile="$(mktemp --tmpdir tlkclkXXXX.wav)" -if [ "$speakTime" == "true" ] ; then - if [ "$minute" -eq "0" ] ; then - timeString="$hour o clock $timeOfDay" - elif [ "$minute" -lt "10" ] ; then - timeString="$(number_to_text $hour) o $(number_to_text $minute) $timeOfDay" - else - timeString="$(number_to_text $hour) $(number_to_text $minute) $timeOfDay" - fi - #if 24 time is set, override the above with correct settings. - if [ "$format" = "24" ] ; then - #Make it read purdy for speech synthesizers. - timeStringHour="$(date +'%-H')" - if [ $timeStringHour -eq "0" ]; then - timeString="zero" - else - timeString="$(number_to_text $timeStringHour)" - fi - if [ $minute -eq "0" ]; then - timeString="${timeString} hundred hours" - elif [ $minute -lt 10 ]; then - timeString="${timeString} O $(number_to_text $minute)" - else - timeString="${timeString} $(number_to_text $minute)" - fi - fi - #Add temperature if zipcode is set - if [ -n "$zipcode" ] ; then - zipcode="${zipcode//[[:space:]]/+}" - zipcode="${zipcode#ip}" - temperature="$(curl -s "https://wttr.in/${zipcode}?format=%t%20degrees%20and%20%C")" - temp="${temperature//[^-[:digit:]]/}" - temperature="${temp} ${temperature#* }" - temperature="${temperature/-/Negative }" - if [ "${#temperature}" -ge 3 ] ; then - timeString="$timeString. It is currently $temperature." - fi - fi - case "$voice" in - "cepstral") - swift -o $voiceFile "$timeString" - #If the default sound command is used write time to file and normalize it before playing, cause Cepstral is kind of low volume. - if [ "$soundCommand" == "play -qV0" ] ; then - $soundCommand $voiceFile norm - else - $soundCommand $voiceFile - fi - ;; - "espeak") - espeak -v en-us -a 175 "$timeString" - ;; - "festival") - #If the default sound command is used write time to file and normalize it before playing, cause festival is kind of low volume. - if [ "$soundCommand" == "play -qV0" ] ; then - echo "$timeString" | text2wave -o $voiceFile - $soundCommand $voiceFile norm - else - echo "$timeString" | festival --tts - fi - ;; - "flite") - #For some reason flite has trouble with ##:## format. - echo "$timeString" | flite - ;; - "gama_tts") - gama_tts_wav="$(mktemp --suffix gama_talking_clock.wav)" - echo "$timeString" | gama_tts tts "${gama_tts_path:-/usr/local/share/gama_tts/data/english/vtm5}" $gama_tts_wav &> /dev/null - eval "$soundCommand $gama_tts_wav" - rm $gama_tts_wav - ;; - "googletts") - /usr/bin/trans -speak -b "$timeString" - ;; - "flite_time") - flite_time $(date +'%H:%M') &> /dev/null - ;; - "sam") - sam "$(echo "$timeString" | sed -e 's/A M/ AY EM/' -e 's/PM/ PEE EM/' -e 's/10/ ten /g' -e 's/11/ eleven /g' -e 's/12/ twelve /g' -e 's/0*//g')" - ;; - "speech-dispatcher") - spd-say -w -P important "$timeString" - ;; - "rhvoice-"*) - echo "$timeString" | RHVoice-test -p "${voice##*-}" 2> /dev/null - ;; - *) - $voice "$timeString" - esac - rm $voiceFile -fi - -#Play the prepended sound if one is selected -#There will be a slight gap between the prepended sound and the actual chiming. -#This is to simulate real clocks based on my experience. -if [ "$minute" -eq "0" ]; then - if [[ -f "$soundPack/prepend.ogg" && "$chime" = "true" ]]; then - $soundCommand "$soundPack/prepend.ogg" - fi -fi - -#chime for quarter hour -if [[ "$minute" -eq "15" || "$minute" -eq "30" || "$minute" -eq "45" ]] ; then - #Play correct sound pack file - if [ -f "$soundPack/$minute.ogg" ] ; then - if [ $chime != "false" ] ; then - $soundCommand "$soundPack/$minute.ogg" - fi - fi -fi - -#Chime on the hour -if [ "$minute" -eq "0" ] ; then - #Check if soundpack has hour chimes or uses the bell sound. - if ! [ -f "$soundPack/$hour.ogg" ] ; then - if [ $chime != "false" ] ; then - i=0 - #create chime string for sound players that can handle more than one sound argument. - soundString="" - while [ "$i" -lt "$hour" ] ; do - if [[ "$soundCommand" == "play" || "$soundCommand" == "play -q" || "$soundCommand" == "ogg123" || "$soundCommand" == "ogg123 -q" ]] ; then - soundString="$soundString $soundPack/bell.ogg" - else - $soundCommand "$soundPack/bell.ogg" - fi - i=$(($i + 1)) - done - if [ -n "$soundString" ] ; then - #No quotes around soundString here because it will not work. - $soundCommand $soundString - fi - fi - else - if [ $chime != "false" ] ; then - $soundCommand "$soundPack/$hour.ogg" - fi - fi -fi -exit 0 - diff --git a/src/talking_clock/__init__.py b/src/talking_clock/__init__.py new file mode 100644 index 0000000..577903a --- /dev/null +++ b/src/talking_clock/__init__.py @@ -0,0 +1 @@ +"""Talking Clock: an accessible desktop and headless chiming clock.""" diff --git a/src/talking_clock/__main__.py b/src/talking_clock/__main__.py new file mode 100644 index 0000000..500038c --- /dev/null +++ b/src/talking_clock/__main__.py @@ -0,0 +1,76 @@ +import argparse +import logging +import signal +import sys +import time + +from .config import Config +from .runtime import Clock, Instance, runtime_directory, send_command + + +def main(argv=None): + parser = argparse.ArgumentParser( + prog="talking-clock", add_help=False, + description="An accessible clock with scheduled speech and chimes.", + epilog="Without arguments, open the clock window. Closing it stops the clock. " + "Settings: $XDG_CONFIG_HOME/talking-clock/settings.json " + "(normally ~/.config/talking-clock/settings.json).") + actions = parser.add_mutually_exclusive_group() + actions.add_argument("-h", action="help", help="show this help message and exit") + actions.add_argument("-H", action="store_true", dest="headless", + help="run without a window, using saved settings") + actions.add_argument("-k", action="store_true", dest="kill", help="stop the running clock") + args = parser.parse_args(argv) + logging.basicConfig(level=logging.INFO, format="talking-clock: %(message)s") + instance = None + clock = None + try: + directory = runtime_directory() + instance = Instance(directory) + if args.kill: + # Wait briefly for a just-started instance to finish creating its socket. + for _ in range(10): + if send_command(directory, "stop"): + return 0 + if instance.acquire(): + return 0 + time.sleep(0.1) + raise OSError("The running clock is not responding.") + if not instance.acquire(): + for _ in range(50): + response = send_command(directory, "status" if args.headless else "present") + if response == "headless" and not args.headless: + # Verify this launch can display a window before stopping the daemon. + from .gui import check_display + check_display() + send_command(directory, "stop") + elif response: + print("Talking Clock is already running.") + return 0 + if instance.acquire(): + break + time.sleep(0.1) + else: + raise OSError("The running clock is not responding.") + config = Config() + clock = Clock(config, config.load(), instance, headless=args.headless) + signal.signal(signal.SIGTERM, lambda *_: clock.stop()) + signal.signal(signal.SIGINT, lambda *_: clock.stop()) + if args.headless: + clock.run_headless() + else: + from .gui import run + run(clock) + return 0 + except (OSError, ValueError, ImportError, RuntimeError) as error: + logging.error("%s", error) + return 1 + finally: + if clock: + clock.stop() + if instance: + instance.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/talking_clock/audio.py b/src/talking_clock/audio.py new file mode 100644 index 0000000..dc4edc1 --- /dev/null +++ b/src/talking_clock/audio.py @@ -0,0 +1,141 @@ +"""Interruptible speech and chimes, without shell evaluation.""" +import logging +import os +import shlex +import signal +import subprocess +import threading + +from .config import resolve_soundpack +from .weather import WeatherClient +from .speech import system_language + +log = logging.getLogger(__name__) + + +def spoken_time(now, time_format): + if time_format == "24": + hour = str(now.hour) if now.hour else "zero" + if now.minute == 0: + return f"{hour} hundred hours" + minute = f"oh {now.minute}" if now.minute < 10 else str(now.minute) + return f"{hour} {minute}" + hour = now.hour % 12 or 12 + minute = "o'clock" if now.minute == 0 else ( + f"oh {now.minute}" if now.minute < 10 else str(now.minute)) + return f"{hour} {minute} {'A M' if now.hour < 12 else 'P M'}" + + +class Audio: + def __init__(self): + self.stopped = threading.Event() + self.lock = threading.Lock() + self.worker = None + self.process = None + self.weather = WeatherClient() + + def announce(self, now, settings): + # Do not build a backlog if a slow voice/chime outlasts its interval. + if self.stopped.is_set() or (self.worker and self.worker.is_alive()): + return + self.worker = threading.Thread(target=self._announce, args=(now, settings), daemon=True) + self.worker.start() + + def run(self, command, text=None, timeout=120): + with self.lock: + if self.stopped.is_set(): + return False + try: + process = subprocess.Popen(command, stdin=subprocess.PIPE if text is not None + else subprocess.DEVNULL, start_new_session=True, + stdout=subprocess.DEVNULL) + except OSError as error: + log.error("Cannot run %s: %s", command[0] if command else "command", error) + return False + self.process = process + try: + process.communicate(None if text is None else text.encode(), timeout=timeout) + if process.returncode and not self.stopped.is_set(): + log.error("%s exited with status %s", command[0], process.returncode) + return process.returncode == 0 + except subprocess.TimeoutExpired: + self.terminate(process) + return False + finally: + with self.lock: + self.process = None + + @staticmethod + def terminate(process): + try: + os.killpg(process.pid, signal.SIGTERM) + process.wait(timeout=0.5) + except subprocess.TimeoutExpired: + pass + except ProcessLookupError: + pass + finally: + # A player may spawn children which outlive its immediate process. + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + + def stop(self): + self.stopped.set() + with self.lock: + process = self.process + if process is not None: + self.terminate(process) + + def _announce(self, now, settings): + try: + if settings.speak: + text = spoken_time(now, settings.time_format) + if settings.weather: + weather = self.weather.report(settings) + if weather: + text += f". It is {weather}." + self.speak(text, now, settings) + if settings.chime: + self.chime(now, settings) + except (OSError, ValueError) as error: + log.error("Announcement failed: %s", error) + + def speak(self, text, now, settings): + command = ["spd-say", "-w", "-P", "important", "-N", "talking-clock", + "-l", system_language(), "-r", str(settings.rate), + "-p", str(settings.pitch), "-i", str(settings.volume)] + if settings.module: + command.extend(["-o", settings.module]) + if settings.voice: + command.extend(["-y", settings.voice]) + self.run(command + ["--", text]) + + def chime(self, now, settings): + pack = resolve_soundpack(settings.soundpack) + if pack is None: + log.error("Soundpack not found: %s", settings.soundpack) + return + command = shlex.split(settings.player) + if not command: + return + def play(path): + if path.is_file(): + self.run(command + [str(path)]) + else: + log.warning("Sound missing: %s", path) + if now.minute: + play(pack / f"{now.minute}.ogg") + return + if (pack / "prepend.ogg").is_file(): + play(pack / "prepend.ogg") + hour = now.hour % 12 or 12 + if (pack / f"{hour}.ogg").is_file(): + play(pack / f"{hour}.ogg") + else: + for _ in range(hour): + if self.stopped.is_set(): + break + play(pack / "bell.ogg") diff --git a/src/talking_clock/config.py b/src/talking_clock/config.py new file mode 100644 index 0000000..6a44235 --- /dev/null +++ b/src/talking_clock/config.py @@ -0,0 +1,157 @@ +"""XDG settings and soundpack discovery. Configuration is data, never shell code.""" +from dataclasses import asdict, dataclass, fields +import json +import math +import os +from pathlib import Path +import shlex +import tempfile + + +def config_directory(): + return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "talking-clock" + + +def sound_roots(): + user = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share") + system = os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share" + roots = [user / "talking-clock/sounds"] + roots.extend(Path(p) / "talking-clock/sounds" for p in system.split(":") if p) + checkout = Path(__file__).resolve().parents[2] / "sounds" + if checkout.is_dir(): + roots.append(checkout) + return roots + + +def soundpacks(): + packs = {} + for root in sound_roots(): + if root.is_dir(): + for child in sorted(root.iterdir()): + if child.is_dir(): + packs.setdefault(child.name, child) + return packs + + +def resolve_soundpack(name): + path = Path(name).expanduser() + if path.is_absolute(): + return path + return soundpacks().get(name) + + +@dataclass +class Settings: + hourly: bool = True + half_hour: bool = False + quarter_hour: bool = False + chime: bool = True + speak: bool = True + soundpack: str = "default" + player: str = "play -qV0" + module: str = "" + voice: str = "" + rate: int = 0 + pitch: int = 0 + volume: int = 0 + location: str = "" + latitude: str = "" + longitude: str = "" + temperature_unit: str = "F" + weather: bool = False + weather_on_space: bool = False + time_format: str = "12" + seconds: bool = True + font_size: int = 72 + high_contrast: bool = False + + def validate(self): + for field in fields(self): + value = getattr(self, field.name) + if type(value) is not field.type: + raise ValueError(f"Invalid value for {field.name}.") + if self.time_format not in ("12", "24"): + raise ValueError("Time format must be 12 or 24.") + if self.temperature_unit not in ("F", "C"): + raise ValueError("Temperature unit must be F or C.") + if bool(self.latitude.strip()) != bool(self.longitude.strip()): + raise ValueError("Enter both latitude and longitude, or leave both blank.") + for value, limit in ((self.latitude, 90), (self.longitude, 180)): + if value.strip() and (not math.isfinite(float(value)) or not -limit <= float(value) <= limit): + raise ValueError("Weather coordinates are out of range.") + if not 18 <= self.font_size <= 240: + raise ValueError("Font size must be between 18 and 240 points.") + for key in ("rate", "pitch", "volume"): + if not -100 <= getattr(self, key) <= 100: + raise ValueError(f"Speech {key} must be between -100 and 100.") + for value in (self.module, self.voice): + if any(character in value for character in ("\n", "\r", "\x00")): + raise ValueError("Speech module and voice must be single-line names.") + shlex.split(self.player) # Reject unmatched quotes; never execute a shell. + if self.chime and (not self.player.strip() or resolve_soundpack(self.soundpack) is None + or not resolve_soundpack(self.soundpack).is_dir()): + raise ValueError("Select an existing soundpack and enter an audio player command.") + return self + + +def read_legacy(path): + """Import known assignments without sourcing executable legacy shell settings.""" + values = {} + for line in path.read_text().splitlines(): + key, separator, value = line.strip().partition("=") + if not separator or key.startswith("#"): + continue + try: + parts = shlex.split(value, comments=True) + except ValueError: + continue + values[key] = " ".join(parts) + settings = Settings() + for old, new in (("chime", "chime"), ("speak", "speak")): + if values.get(old) in ("true", "false"): + setattr(settings, new, values[old] == "true") + for old, new in (("sound", "player"), ("soundpack", "soundpack"), + ("zipcode", "location")): + if values.get(old): + setattr(settings, new, values[old]) + settings.weather = bool(settings.location) + settings.time_format = values.get("format", "12") + return settings + + +class Config: + def __init__(self, directory=None): + self.directory = Path(directory) if directory else config_directory() + self.path = self.directory / "settings.json" + + def load(self): + if self.path.exists(): + data = json.loads(self.path.read_text()) + if not isinstance(data, dict): + raise ValueError("Settings must be a JSON object.") + if any(key in data for key in ("voice_command", "rhvoice", "gama_path")): + # Earlier Python versions used 'voice' for an engine, not a synthesis voice. + data.pop("voice", None) + known = {f.name for f in fields(Settings)} + settings = Settings(**{k: v for k, v in data.items() if k in known}) + return settings.validate() + for legacy in (self.directory / "talking-clockrc", Path("/etc/talking-clockrc")): + if legacy.is_file(): + return read_legacy(legacy).validate() + return Settings() + + def save(self, settings): + settings.validate() + self.directory.mkdir(mode=0o700, parents=True, exist_ok=True) + name = None + try: + with tempfile.NamedTemporaryFile(mode="w", dir=self.directory, delete=False) as stream: + name = stream.name + json.dump(asdict(settings), stream, indent=2) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, self.path) + finally: + if name and os.path.exists(name): + os.unlink(name) diff --git a/src/talking_clock/gui.py b/src/talking_clock/gui.py new file mode 100644 index 0000000..c039873 --- /dev/null +++ b/src/talking_clock/gui.py @@ -0,0 +1,404 @@ +"""GTK is imported only for graphical operation.""" +from dataclasses import replace +from datetime import datetime +import threading + +import gi +gi.require_version("Gtk", "3.0") +gi.require_version("Gdk", "3.0") +gi.require_version("Atk", "1.0") +from gi.repository import Atk, Gdk, GLib, Gtk + +from .config import soundpacks +from .speech import discover, system_language + + +class SettingsDialog(Gtk.Dialog): + def __init__(self, window, clock): + super().__init__(title="Talking Clock Settings", transient_for=window, modal=True, + destroy_with_parent=True) + self.clock = clock + self.alive = True + self.connect("destroy", lambda *_: setattr(self, "alive", False)) + self.controls = {} + self.speech_generation = 0 + self.updating_speech = False + self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, "_Save", Gtk.ResponseType.OK) + self.set_default_size(620, 540) + self.notebook = Gtk.Notebook() + self.notebook.set_border_width(12) + self.get_content_area().add(self.notebook) + + grid = self.page("General") + self.check(grid, "hourly", "Announce on the _hour (:00)") + self.check(grid, "half_hour", "Announce on the _half hour (:30)") + self.check(grid, "quarter_hour", "Announce on the _quarter hours (:15 and :45)") + self.check(grid, "chime", "Play _chimes") + self.check(grid, "speak", "_Speak the time") + self.note(grid, "Each selected time can play chimes, speak the time, or both.\n" + "Closing the clock window stops the entire program.\n" + "Use talking-clock -H to run without a window.") + + grid = self.page("Sounds") + self.combo(grid, "soundpack", "Sound_pack", list(soundpacks()), editable=True) + self.entry(grid, "player", "_Audio player command") + self.note(grid, "Choose a discovered pack, or enter an absolute directory path.\n" + "User packs: $XDG_DATA_HOME/talking-clock/sounds/\n" + "Normally: ~/.local/share/talking-clock/sounds/\n" + "System packs: /usr/local/share/talking-clock/sounds/\n" + "and /usr/share/talking-clock/sounds/\n\n" + "A user pack takes priority over a system pack of the same name.") + + grid = self.page("Speech") + for key, label in (("module", "_Module"), ("voice", "_Voice")): + saved = getattr(clock.settings, key) + options = [("", "System default")] + ([(saved, saved)] if saved else []) + self.speech_combo(grid, key, label, options) + for key, label in (("rate", "_Rate"), ("pitch", "_Pitch"), ("volume", "Vo_lume")): + values = sorted(set(range(-100, 101, 10)) | {getattr(clock.settings, key)}) + self.speech_combo(grid, key, label, + [(str(value), "Normal (0)" if value == 0 else f"{value:+d}") for value in values]) + self.controls["module"].connect("changed", self.module_changed) + self.test_speech_button = Gtk.Button.new_with_mnemonic("_Test speech") + self.test_speech_button.connect("clicked", self.test_speech) + grid.attach(self.test_speech_button, 0, len(grid.get_children()), 2, 1) + self.speech_status = Gtk.Label(xalign=0) + self.speech_status.set_line_wrap(True) + grid.attach(self.speech_status, 0, len(grid.get_children()), 2, 1) + self.note(grid, "Uses Speech Dispatcher. Voices match the system language\n" + "and region, with a broader language fallback when needed.\n" + "Changing module reloads voices.\n" + "Rate, pitch and volume range from -100 to +100; 0 is normal.\n" + "Some modules may not support every adjustment.") + + grid = self.page("Display") + self.combo(grid, "time_format", "Time _format", ("12", "24")) + self.check(grid, "seconds", "Show _seconds") + font = Gtk.SpinButton.new_with_range(18, 240, 2) + font.set_value(clock.settings.font_size) + self.row(grid, "Font size in _points", font) + self.controls["font_size"] = font + self.check(grid, "high_contrast", "_White text on black background") + + grid = self.page("Weather") + self.check(grid, "weather", "Include _weather in scheduled announcements") + self.check(grid, "weather_on_space", "Include weather in _time announcement (Space)") + self.entry(grid, "location", "Weather _location") + self.entry(grid, "latitude", "_Latitude") + self.entry(grid, "longitude", "L_ongitude") + self.combo(grid, "temperature_unit", "Temperature _unit", ("F", "C")) + find = Gtk.Button.new_with_mnemonic("_Find city") + find.connect("clicked", self.find_city) + grid.attach(find, 0, len(grid.get_children()), 2, 1) + self.weather_status = Gtk.Label(xalign=0) + self.weather_status.set_line_wrap(True) + grid.attach(self.weather_status, 0, len(grid.get_children()), 2, 1) + self.location_for_coords = clock.settings.location + self.note(grid, "IPinfo prefills an approximate city when location fields are empty.\n" + "Correct it with a city and region, then choose Find city.\n" + "You can also enter latitude and longitude directly.\n" + "Weather by Open-Meteo.com (CC BY 4.0); city search by GeoNames.\n" + "Only temperature and conditions are requested, cached until the\n" + "next quarter hour. Location is cached for a day. Internet required.") + self.show_all() + self.load_speech_voices(clock.settings.voice) + if not any((clock.settings.location, clock.settings.latitude, clock.settings.longitude)): + self.lookup_location() + + def speech_combo(self, grid, key, text, options): + control = Gtk.ComboBoxText() + for value, label in options: + control.append(value, label) + control.set_active_id(str(getattr(self.clock.settings, key))) + self.row(grid, text, control) + self.controls[key] = control + + def module_changed(self, _control): + if not self.updating_speech: + self.controls["voice"].remove_all() + self.controls["voice"].append("", "System default") + self.controls["voice"].set_active_id("") + self.load_speech_voices("") + + def speech_values(self): + values = {key: self.controls[key].get_active_id() or "" + for key in ("module", "voice", "rate", "pitch", "volume")} + for key in ("rate", "pitch", "volume"): + values[key] = int(values[key]) + return values + + def test_speech(self, _button): + settings = replace(self.clock.settings, **self.speech_values(), + speak=True, chime=False, weather=False) + self.clock.audio.announce(datetime.now().astimezone(), settings) + + def load_speech_voices(self, preferred_voice): + self.speech_generation += 1 + generation = self.speech_generation + module = self.controls["module"].get_active_id() or "" + self.controls["voice"].set_sensitive(False) + self.speech_status.set_text(f"Loading voices for {system_language()}…") + + def finished(catalog, error): + if not self.alive or generation != self.speech_generation: + return False + self.controls["voice"].set_sensitive(True) + if error: + self.speech_status.set_text(error + ". Saved selections are preserved.") + return False + self.updating_speech = True + try: + modules = self.controls["module"] + modules.remove_all() + modules.append("", f"System default ({catalog.module or 'automatic'})") + for name in catalog.modules: + modules.append(name, name) + modules.set_active_id(module) + voices = self.controls["voice"] + voices.remove_all() + voices.append("", "System default") + for name in catalog.voices: + voices.append(name, name) + voices.set_active_id(preferred_voice if preferred_voice in catalog.voices else "") + status = f"{len(catalog.voices)} voices for {catalog.language}." + if preferred_voice and preferred_voice not in catalog.voices: + status += " Saved voice is unavailable; system default selected." + self.speech_status.set_text(status) + finally: + self.updating_speech = False + return False + + def worker(): + catalog, error = None, None + try: + catalog = discover(module) + except RuntimeError as exception: + error = str(exception) + GLib.idle_add(finished, catalog, error) + + threading.Thread(target=worker, daemon=True).start() + + def lookup_location(self, name=None): + before = tuple(self.controls[key].get_text() for key in ("location", "latitude", "longitude")) + self.weather_status.set_text("Looking up location…") + + def finished(result): + if not self.alive: + return False + current = tuple(self.controls[key].get_text() for key in ("location", "latitude", "longitude")) + if result and current == before: + for key in ("location", "latitude", "longitude"): + self.controls[key].set_text(result[key]) + self.location_for_coords = result["location"] + self.weather_status.set_text("Location found. Check it before saving.") + elif current != before: + self.weather_status.set_text("Your edits were preserved.") + else: + self.weather_status.set_text("Lookup unavailable. Enter coordinates or try again later.") + return False + + def worker(): + client = self.clock.audio.weather + result = client.detect_location() if name is None else client.find_location(name) + GLib.idle_add(finished, result) + + threading.Thread(target=worker, daemon=True).start() + + def find_city(self, _button): + self.lookup_location(self.controls["location"].get_text()) + + def page(self, title): + grid = Gtk.Grid(column_spacing=12, row_spacing=12, margin=16) + self.notebook.append_page(grid, Gtk.Label(label=title)) + return grid + + def row(self, grid, text, control): + row = len(grid.get_children()) + label = Gtk.Label.new_with_mnemonic(text) + label.set_xalign(0) + label.set_mnemonic_widget(control) + # Explicit names also cover combo boxes whose ATK name otherwise comes + # from their selected value, and the entry inside an editable combo. + targets = [control] + if isinstance(control, Gtk.ComboBox) and control.get_has_entry(): + targets.append(control.get_child()) + for target in targets: + accessible = target.get_accessible() + accessible.set_name(label.get_text()) + accessible.add_relationship(Atk.RelationType.LABELLED_BY, label.get_accessible()) + label.get_accessible().add_relationship(Atk.RelationType.LABEL_FOR, accessible) + grid.attach(label, 0, row, 1, 1) + control.set_hexpand(True) + grid.attach(control, 1, row, 1, 1) + + def check(self, grid, key, text): + control = Gtk.CheckButton.new_with_mnemonic(text) + control.set_active(getattr(self.clock.settings, key)) + grid.attach(control, 0, len(grid.get_children()), 2, 1) + self.controls[key] = control + + def entry(self, grid, key, text): + control = Gtk.Entry() + control.set_text(getattr(self.clock.settings, key)) + self.row(grid, text, control) + self.controls[key] = control + + def combo(self, grid, key, text, options, editable=False): + control = Gtk.ComboBoxText.new_with_entry() if editable else Gtk.ComboBoxText() + for option in options: + control.append(option, option) + value = getattr(self.clock.settings, key) + if editable: + control.get_child().set_text(value) + else: + control.set_active_id(value) + self.row(grid, text, control) + self.controls[key] = control + + @staticmethod + def note(grid, text): + label = Gtk.Label(label=text, xalign=0) + label.set_line_wrap(True) + grid.attach(label, 0, len(grid.get_children()), 2, 1) + + def save(self): + values = {} + speech_values = self.speech_values() + for key, control in self.controls.items(): + if isinstance(control, Gtk.CheckButton): + value = control.get_active() + elif isinstance(control, Gtk.SpinButton): + value = control.get_value_as_int() + elif isinstance(control, Gtk.ComboBoxText): + if key in ("module", "voice", "rate", "pitch", "volume"): + value = speech_values[key] + else: + value = control.get_active_text() + else: + value = control.get_text() + values[key] = value + if (values["location"] != self.location_for_coords + and values["latitude"] == self.clock.settings.latitude + and values["longitude"] == self.clock.settings.longitude): + # A typed city must not keep the previous city's coordinates. + values["latitude"] = values["longitude"] = "" + self.clock.apply(replace(self.clock.settings, **values)) + + +class ClockWindow(Gtk.Window): + def __init__(self, clock): + super().__init__(title="Talking Clock") + self.clock = clock + self.dialog = None + self.set_name("talking-clock-window") + self.set_border_width(20) + self.connect("destroy", self.closed) + self.accelerators = Gtk.AccelGroup() + self.accelerators.connect(Gdk.KEY_q, Gdk.ModifierType.CONTROL_MASK, + Gtk.AccelFlags.VISIBLE, self.quit_activated) + self.add_accel_group(self.accelerators) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16) + self.add(box) + self.time = Gtk.Entry() + self.time.set_name("clock-time") + self.time.set_editable(False) + self.time.set_has_frame(False) + self.time.set_alignment(0.5) + self.time.set_width_chars(11) + self.time.connect("key-press-event", self.time_key_pressed) + box.pack_start(self.time, True, True, 0) + settings = Gtk.Button.new_with_mnemonic("_Settings") + settings.connect("clicked", self.open_settings) + box.pack_start(settings, False, False, 0) + self.css = Gtk.CssProvider() + Gtk.StyleContext.add_provider_for_screen(self.get_screen(), self.css, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + clock.changed = self.refresh_style + clock.present = self.present + self.refresh_style() + self.show_all() + self.time.grab_focus() + self.time.select_region(0, 0) + + def time_key_pressed(self, _entry, event): + modifiers = Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.MOD1_MASK | Gdk.ModifierType.SUPER_MASK + if event.keyval not in (Gdk.KEY_space, Gdk.KEY_KP_Space) or event.state & modifiers: + return False + self.clock.audio.announce(datetime.now().astimezone(), + replace(self.clock.settings, speak=True, chime=False, + weather=self.clock.settings.weather_on_space)) + return True + + def quit_activated(self, *_args): + self.destroy() + return True + + def refresh_style(self): + contrast = "background: black; color: white;" if self.clock.settings.high_contrast else "" + self.css.load_from_data(( + f"#clock-time {{ font-size: {self.clock.settings.font_size}pt; {contrast} }}" + f"#talking-clock-window {{ {contrast} }}").encode()) + self.update_time() + + def update_time(self): + now = datetime.now().astimezone() + seconds = ":%S" if self.clock.settings.seconds else "" + pattern = "%H:%M" + seconds if self.clock.settings.time_format == "24" else "%I:%M" + seconds + " %p" + text = now.strftime(pattern) + if self.clock.settings.time_format == "12": + text = text.lstrip("0") + if self.time.get_text() != text: + self.time.set_text(text) + + def open_settings(self, _button): + if self.dialog: + self.dialog.present() + return + self.dialog = SettingsDialog(self, self.clock) + self.dialog.add_accel_group(self.accelerators) + self.dialog.connect("response", self.settings_response) + + def settings_response(self, dialog, response): + if response == Gtk.ResponseType.OK: + try: + dialog.save() + except (OSError, ValueError) as error: + message = Gtk.MessageDialog(transient_for=dialog, modal=True, + destroy_with_parent=True, + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.CLOSE, + text="Could not save settings") + message.format_secondary_text(str(error)) + message.connect("response", lambda widget, _response: widget.destroy()) + message.show_all() + return + dialog.destroy() + self.dialog = None + + def closed(self, _window): + self.clock.stop() + if self.dialog: + self.dialog.destroy() + self.dialog = None + if Gtk.main_level(): + Gtk.main_quit() + + +def check_display(): + if not Gtk.init_check()[0]: + raise RuntimeError("Cannot open a display. Use -H to run without a window.") + + +def run(clock): + check_display() + window = ClockWindow(clock) + + def tick(): + if not clock.tick(): + window.destroy() + return False + window.update_time() + return True + + GLib.timeout_add(200, tick) + Gtk.main() diff --git a/src/talking_clock/runtime.py b/src/talking_clock/runtime.py new file mode 100644 index 0000000..ff62902 --- /dev/null +++ b/src/talking_clock/runtime.py @@ -0,0 +1,160 @@ +"""One instance per user and scheduling against local wall-clock time.""" +from collections import deque +from datetime import datetime +import errno +import fcntl +import logging +import os +from pathlib import Path +import socket +import stat +import threading +import time + +from .audio import Audio + +log = logging.getLogger(__name__) + + +def runtime_directory(): + base = os.environ.get("XDG_RUNTIME_DIR") + directory = Path(base) / "talking-clock" if base else Path(f"/tmp/talking-clock-{os.getuid()}") + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + info = directory.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077: + raise OSError(f"Runtime directory must be private and owned by you: {directory}") + return directory + + +def send_command(directory, command): + with socket.socket(socket.AF_UNIX) as connection: + connection.settimeout(1) + try: + connection.connect(str(directory / "control.sock")) + connection.sendall(command.encode()) + return connection.recv(32).decode() + except OSError as error: + if error.errno in (errno.ENOENT, errno.ECONNREFUSED): + return None + raise + + +class Instance: + def __init__(self, directory): + self.directory = directory + self.lock = None + self.server = None + + def acquire(self): + self.lock = open(self.directory / "instance.lock", "a+") + try: + fcntl.flock(self.lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + self.lock.close() + self.lock = None + return False + try: + path = self.directory / "control.sock" + path.unlink(missing_ok=True) + self.server = socket.socket(socket.AF_UNIX) + self.server.bind(str(path)) + self.server.listen(4) + self.server.setblocking(False) + return True + except Exception: + self.close() + raise + + def poll(self, stop, present, headless=False): + try: + connection, _ = self.server.accept() + except BlockingIOError: + return + with connection: + connection.settimeout(0.2) + try: + command = connection.recv(32).decode() + if command == "stop": + stop() + elif command == "present": + present() + connection.sendall(("headless" if headless else "gui").encode() + if command in ("present", "status") else b"ok") + except (OSError, UnicodeError): + pass + + def close(self): + if self.server: + self.server.close() + self.server = None + (self.directory / "control.sock").unlink(missing_ok=True) + if self.lock: + self.lock.close() + self.lock = None + # Keep the lock file: removing it could let two processes lock different inodes. + + +def due(now, settings): + return ((now.minute == 0 and settings.hourly) + or (now.minute == 30 and settings.half_hour) + or (now.minute in (15, 45) and settings.quarter_hour)) + + +class Scheduler: + def __init__(self, audio): + self.audio = audio + self.previous = None + self.announced = deque(maxlen=192) + + def tick(self, now, settings): + slot = (now.date(), now.hour, now.minute) + previous, self.previous = self.previous, slot + if previous == slot or (previous is None and now.second != 0): + return + if due(now, settings) and slot not in self.announced: + self.announced.append(slot) + self.audio.announce(now, settings) + + +class Clock: + def __init__(self, config, settings, instance, headless=False): + self.config, self.settings, self.instance = config, settings, instance + self.headless = headless + self.stopped = threading.Event() + self.audio = Audio() + self.scheduler = Scheduler(self.audio) + self.last_check = 0 + self.stamp = self.config.path.stat().st_mtime_ns if self.config.path.exists() else None + self.changed = lambda: None + self.present = lambda: None + + def apply(self, settings): + self.config.save(settings) + self.settings = settings + self.stamp = self.config.path.stat().st_mtime_ns + self.changed() + + def tick(self): + self.instance.poll(self.stop, self.present, self.headless) + if self.stopped.is_set(): + return False + if time.monotonic() - self.last_check >= 1: + self.last_check = time.monotonic() + try: + stamp = self.config.path.stat().st_mtime_ns if self.config.path.exists() else None + if stamp != self.stamp: + self.stamp = stamp + self.settings = self.config.load() + self.changed() + except (OSError, ValueError) as error: + log.error("Cannot reload settings: %s", error) + self.scheduler.tick(datetime.now().astimezone(), self.settings) + return True + + def stop(self): + self.stopped.set() + self.audio.stop() + + def run_headless(self): + while self.tick(): + self.stopped.wait(0.2) diff --git a/src/talking_clock/speech.py b/src/talking_clock/speech.py new file mode 100644 index 0000000..da4286c --- /dev/null +++ b/src/talking_clock/speech.py @@ -0,0 +1,59 @@ +"""Read-only Speech Dispatcher discovery and locale-based voice filtering.""" +from dataclasses import dataclass +import os + + +def system_language(environment=None): + environment = os.environ if environment is None else environment + for key in ("LC_ALL", "LANGUAGE", "LC_MESSAGES", "LANG"): + for value in environment.get(key, "").split(":"): + value = value.split(".")[0].split("@")[0].replace("_", "-") + if value and value.upper() not in ("C", "POSIX"): + return value.lower() + return "en" + + +def matching_voices(voices, language): + language = language.replace("_", "-").lower() + base = language.split("-")[0] + candidates = [(name, lang.replace("_", "-").lower()) + for name, lang, _variant in voices if lang] + exact = {name for name, lang in candidates if lang == language} + matches = exact or {name for name, lang in candidates if lang.split("-")[0] == base} + return tuple(sorted(matches, key=str.casefold)) + + +@dataclass(frozen=True) +class Catalog: + modules: tuple + module: str + voices: tuple + language: str + + +def connect(): + import speechd + return speechd.SSIPClient("talking-clock", component="settings", autospawn=False) + + +def discover(module=""): + client = None + try: + client = connect() + modules = tuple(sorted(client.list_output_modules())) + if module: + if module not in modules: + raise ValueError(f"Speech module is unavailable: {module}") + client.set_output_module(module) + active = module or client.get_output_module() + language = system_language() + voices = matching_voices(client.list_synthesis_voices(), language) + return Catalog(modules, active, voices, language) + except Exception as error: + raise RuntimeError(f"Cannot list Speech Dispatcher voices: {error}") from error + finally: + if client is not None: + try: + client.close() + except Exception: + pass diff --git a/src/talking_clock/weather.py b/src/talking_clock/weather.py new file mode 100644 index 0000000..5241b41 --- /dev/null +++ b/src/talking_clock/weather.py @@ -0,0 +1,186 @@ +"""Small Open-Meteo client with a persistent, process-safe request budget.""" +from email.utils import parsedate_to_datetime +import fcntl +import json +import logging +import math +import os +from pathlib import Path +import tempfile +import time +from urllib.error import HTTPError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +log = logging.getLogger(__name__) +QUARTER = 900 +USER_AGENT = "TalkingClock/2 (https://git.stormux.org/storm/talking-clock)" + +# WMO interpretation codes documented by Open-Meteo. +CONDITIONS = { + 0: "clear", 1: "mainly clear", 2: "partly cloudy", 3: "cloudy", + 45: "foggy", 48: "freezing fog", 51: "light drizzle", 53: "drizzle", + 55: "heavy drizzle", 56: "light freezing drizzle", 57: "freezing drizzle", + 61: "light rain", 63: "rainy", 65: "heavy rain", 66: "light freezing rain", + 67: "heavy freezing rain", 71: "light snow", 73: "snowy", 75: "heavy snow", + 77: "snow grains", 80: "light rain showers", 81: "rain showers", + 82: "heavy rain showers", 85: "light snow showers", 86: "heavy snow showers", + 95: "thunderstorms", 96: "thunderstorms with hail", 99: "thunderstorms with heavy hail", +} + + +def coordinates(latitude, longitude): + latitude, longitude = float(latitude), float(longitude) + if not (math.isfinite(latitude) and math.isfinite(longitude) + and -90 <= latitude <= 90 and -180 <= longitude <= 180): + raise ValueError("Invalid weather coordinates") + return {"latitude": str(latitude), "longitude": str(longitude)} + + +def location_result(data): + if not isinstance(data, dict): + raise ValueError("Invalid location response") + if "loc" in data: + result = coordinates(*data["loc"].split(",")) + city = data.get("city") + parts = [city, data.get("region"), data.get("country")] + else: + result = coordinates(data["latitude"], data["longitude"]) + city = data.get("name") + parts = [city, data.get("admin1"), data.get("country")] + if not isinstance(city, str) or not city.strip(): + raise ValueError("Location lookup did not return a city") + result["location"] = ", ".join(p for p in parts if isinstance(p, str) and p) + return result + + +def current_result(data): + current = data["current"] + temperature, code = current["temperature_2m"], current["weather_code"] + if (type(temperature) not in (int, float) or not math.isfinite(temperature) + or not -100 <= temperature <= 70 or type(code) is not int or code not in CONDITIONS): + raise ValueError("Weather response has no valid temperature or condition") + if data.get("current_units", {}).get("temperature_2m") != "°C": + raise ValueError("Weather response has unexpected temperature units") + return {"celsius": temperature, "condition": CONDITIONS[code]} + + +class WeatherClient: + def __init__(self, directory=None): + self.directory = Path(directory) if directory else Path( + os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "talking-clock" + + def _request(self, url): + request = Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"}) + with urlopen(request, timeout=5) as response: + return json.loads(response.read(65536)) + + @staticmethod + def _read(path): + try: + state = json.loads(path.read_text()) + if (not isinstance(state, dict) + or any(type(state.get(key)) not in (int, float) or not math.isfinite(state[key]) + for key in ("expires", "next_attempt", "failures"))): + return {} + return state + except (OSError, ValueError): + return {} + + def _write(self, path, state): + name = None + try: + with tempfile.NamedTemporaryFile(mode="w", dir=self.directory, delete=False) as stream: + name = stream.name + json.dump(state, stream) + os.replace(name, path) + finally: + if name and os.path.exists(name): + os.unlink(name) + + def _cached(self, service, key, url, parser, lifetime, quarter=False): + try: + self.directory.mkdir(mode=0o700, parents=True, exist_ok=True) + # Serialize threads and processes, including during headless-to-GUI takeover. + with open(self.directory / (service + ".lock"), "a+") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + path = self.directory / (service + ".json") + state, now = self._read(path), time.time() + if state.get("key") == key and now < state.get("expires", 0): + return state.get("data") + if now < state.get("next_attempt", 0): + return None + expires = (int(now) // QUARTER + 1) * QUARTER if quarter else now + lifetime + failure_count = state.get("failures", 0) + # Record a request budget before contacting the server, even if interrupted. + state = {"key": key, "data": None, "expires": 0, + "next_attempt": expires if quarter else now + (10 if service == "geocoding" else QUARTER), + "failures": failure_count} + self._write(path, state) + try: + data = parser(self._request(url)) + except (OSError, ValueError, KeyError, TypeError, AttributeError, IndexError) as error: + delay = min(86400, QUARTER * 2 ** min(failure_count, 7)) + if isinstance(error, HTTPError): + if error.code == 429: + delay = max(delay, 3600) + retry = error.headers.get("Retry-After") if error.headers else None + if retry: + try: + delay = max(delay, float(retry)) + except ValueError: + try: + delay = max(delay, parsedate_to_datetime(retry).timestamp() - now) + except (ValueError, TypeError, OverflowError): + pass + state.update(next_attempt=max(state["next_attempt"], now + delay), + failures=failure_count + 1) + self._write(path, state) + log.warning("%s unavailable; retry deferred: %s", service, error) + return None + state.update(data=data, expires=expires, failures=0) + if service == "location": + state["next_attempt"] = expires + self._write(path, state) + return data + except OSError as error: + # Fail closed if a persistent request budget cannot be recorded. + log.warning("Weather cache unavailable: %s", error) + return None + + def detect_location(self): + return self._cached("location", "ip", "https://ipinfo.io/json", location_result, 86400) + + def find_location(self, name): + name = name.strip() + if not name: + return None + url = "https://geocoding-api.open-meteo.com/v1/search?" + urlencode( + {"name": name, "count": 1, "language": "en", "format": "json"}) + return self._cached("geocoding", name.casefold(), url, + lambda data: location_result(data["results"][0]), 30 * 86400) + + def report(self, settings): + try: + if settings.latitude.strip() and settings.longitude.strip(): + place = coordinates(settings.latitude, settings.longitude) + elif settings.location.strip(): + place = self.find_location(settings.location) + else: + place = self.detect_location() + if not place: + return None + query = {"latitude": place["latitude"], "longitude": place["longitude"], + "current": "temperature_2m,weather_code", "temperature_unit": "celsius"} + url = "https://api.open-meteo.com/v1/forecast?" + urlencode(query) + data = self._cached("forecast", str((place["latitude"], place["longitude"])), + url, current_result, QUARTER, quarter=True) + if data is None: + return None + temperature = data["celsius"] + if settings.temperature_unit == "F": + temperature = temperature * 9 / 5 + 32 + unit = "Fahrenheit" if settings.temperature_unit == "F" else "Celsius" + return f"{round(temperature)} degrees {unit} and {data['condition']}" + except (ValueError, KeyError, TypeError, IndexError): + return None diff --git a/talking-clock b/talking-clock new file mode 100755 index 0000000..7811c9f --- /dev/null +++ b/talking-clock @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +"""Launch from a checkout or an installed copy.""" +from pathlib import Path +import sys + +root = Path(__file__).resolve().parent +sys.path.insert(0, str(root / "src" if (root / "src/talking_clock").is_dir() + else root.parent / "share/talking-clock/python")) +from talking_clock.__main__ import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/talking-clock-gui/README b/talking-clock-gui/README deleted file mode 100644 index 24189ac..0000000 --- a/talking-clock-gui/README +++ /dev/null @@ -1,4 +0,0 @@ -talking-clock-gui is a graphical interface to talking-clock. It is released under the same license as talking-clock, the GPL. -Requirements are talking-clock and yad http://code.google.com/p/yad/ -If you need to contact me I am on Identica and Twitter as stormdragon2976. You can also use the contact form on my blog: -http://www.stormdragon.us/ diff --git a/talking-clock-gui/talking-clock-gui b/talking-clock-gui/talking-clock-gui deleted file mode 100755 index fa08147..0000000 --- a/talking-clock-gui/talking-clock-gui +++ /dev/null @@ -1,199 +0,0 @@ -#!/bin/bash -#GUI for the talking-clock program -#Written by Storm Dragon -#Tuesday, August 28, 2012 -#license GPL - -chime_settings() -{ - #get how often the clock should chime. - chimeInterval=$(yad --form --field="Chime:cb" --separator="" --title="Talking Clock" --text="Please select how often you would like talking-clock to chime:" 'Disable!Hourly!Every Half Hour!Every Quarter Hour') - case "$chimeInterval" in - 'Disable') - chimeInterval=0 - ;; - 'Hourly') - chimeInterval=1 - ;; - 'Every Half Hour') - chimeInterval=2 - ;; - 'Every Quarter Hour') - chimeInterval=4 - ;; - esac - #if [[ $chimeInterval -ge 1 && $chimeInterval -le 4 ]] ; then - echo "talking-clock --cron $chimeInterval" - #fi -} - -read_settings() -{ - #Initialize variables to defaults before reading existing settings if any - chime="true" - speak="true" - voice=espeak - if [ -e "$confFile" ] ; then - if [ -w "$confFile" ] ; then - bell="$(grep -i 'bell=' "$confFile" | cut -d = -f 2)" - prepend="$(grep -i 'prepend=' "$confFile" | cut -d = -f 2)" - quarter="$(grep -i 'quarter=' "$confFile" | cut -d = -f 2)" - sound="$(grep -i 'sound=' "$confFile" | cut -d = -f 2)" - soundpack="$(grep -i 'soundpack=' "$confFile" | cut -d = -f 2)" - voice="$(grep -i 'voice=' "$confFile" | cut -d = -f 2)" - zipcode="$(grep -i 'zipcode=' "$confFile" | cut -d = -f 2)" - else - yad --error --title="Talking Clock" --text="There was an error reading the settings file. Exiting." - exit 1 - fi - fi -} - -sound_settings() -{ - #Initialize soundMenu variable - soundMenu=0 - while [ $soundMenu -ne 7 ] ; do - #Set soundMenu variable e so it will exit properly if nothing is chosen. - soundMenu=7 - #get current setting of speak and chime options to make menu display correctly. - if [ "$chime" == "true" ] ; then - chimeSetting="Disable" - else - chimeSetting="Enable" - fi - if [ "$speak" == "true" ] ; then - speakSetting="Disable" - else - speakSetting="Enable" - fi - #Generate the menu for all sound options - yad --title="Talking Clock Sound Settings" --text="select an option:" --button="$chimeSetting _Chimes:1" --button="$speakSetting _Speech:2" --button="_Bell:3" --button="_Quarter Hour:4" --button="_Prepend:5" --button="_Soundpack:6" --button="_Main Menu:7" - soundMenu=$? - #adjust sounds preferences based on the above menu option. - case $soundMenu in - 1) - if [ "$chime" == "true" ] ; then - chime="false" - else - chime="true" - fi - ;; - 2) - if [ -n "$zipcode" ] ; then - yad --error --title="Talking Clock" --text="You must clear the postal code before disabling speech. To do so, select weather from the main menu, and leave the postal code field blank." - fi - if [ "$speak" == "true" -a -z "$zipcode" ] ; then - speak="false" - else - speak="true" - fi - ;; - 3) - bell="$(yad --file-selection --title="Select An Hourly chime sound" --file-filter="*.ogg")" - if [ -n "$bell" ] ; then - soundpack="" - fi - ;; - 4) - quarter="$(yad --file-selection --title="Select A Quarter Hour chime sound" --file-filter="*.ogg")" - if [ -n "$quarter" ] ; then - soundpack="" - fi - ;; - 5) - prepend="$(yad --file-selection --title="Select a sound to play before each chime" --file-filter="*.ogg")" - ;; - 6) - soundpack="$(yad --file-selection --title="Select the directory that contains the soundpack" --directory)" - if [ -n "$soundpack" ] ; then - bell="" - quarter="" - fi - ;; - esac - done -} - -voice_settings() -{ - voice="$(yad --form --separator="" --field="voice:cb" --title="Talking Clock" --text="Please select a voice for Talking Clock to use:" 'cepstral!espeak!festival!pico!custom')" - echo "$voice" - if [ "$voice" == "custom" ] ; then - voice="$(yad --entry --title="Talking Clock" --text="Please enter custom voice command:")" - fi -} - -weather_settings() -{ - zipcode="$(yad --entry --title="Talking Clock Weather Settings" --text="Please enter your postal code:")" - if [ -n "$zipcode" ] ; then - speak="true" - fi -} - -write_settings() -{ - #Make sure the file either doesn't exist, or we have write permissions - if [ ! -e "$confFile" -o -r "$confFile" ] ; then - echo -e "chime=$chime\nspeak=$speak" > $confFile - if [ -n "$bell" ] ; then - echo "bell=$bell" >> $confFile - fi - if [ -n "$prepend" ] ; then - echo -e "prepend=$prepend" >> $confFile - fi - if [ -n "$quarter" ] ; then - echo -e "quarter=$quarter" >> $confFile - fi - if [ -n "$sound" ] ; then - echo -e "sound=$sound" >> $confFile - fi - if [ -n "$soundpack" ] ; then - echo -e "soundpack=$soundpack" >> $confFile - fi - if [ -n "$voice" ] ; then - echo -e "voice=$voice" >> $confFile - fi - if [ -n "$zipcode" ] ; then - echo -e "zipcode=$zipcode" >> $confFile - fi - fi -} - -#Main section loop -#find out if changes are system wide, or fo local user. -if [ "$(whoami)" == "root" ] ; then -confFile="/etc/talking-clockrc" -else -confFile="$HOME/.talking-clockrc" -fi -#initialize settings -read_settings -#initialize menu variable -menu=0 -while [ $menu -lt 5 ] ; do -#Set menu to exit code incase the window is closed without an option being selected -menu=5 -yad --title="Talking Clock" --text="Select an option:" --button="_Chime Settings:1" --button="_Sounds and Soundpacks:2" --button="_Voice:3" --button="_Weather:4" --button="Cance_l:5" --button="S_ave Settings:6" -menu=$? -case "$menu" in - 1) - chime_settings - ;; - 2) - sound_settings - ;; - 3) - voice_settings - ;; - 4) - weather_settings - ;; - 6) - write_settings - ;; - *) - exit 0 -esac -done diff --git a/talking-clock.desktop b/talking-clock.desktop new file mode 100644 index 0000000..50453c8 --- /dev/null +++ b/talking-clock.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=Talking Clock +Comment=Show the time with scheduled speech and chimes +Exec=talking-clock +Icon=preferences-system-time +Terminal=false +Categories=Utility;Clock;Accessibility; diff --git a/tests/test_clock.py b/tests/test_clock.py new file mode 100644 index 0000000..4558484 --- /dev/null +++ b/tests/test_clock.py @@ -0,0 +1,253 @@ +"""Focused behavior checks; audio commands are mocked so tests stay silent.""" +from dataclasses import replace +from datetime import datetime +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) +from talking_clock.audio import Audio, spoken_time +from talking_clock.config import Config, Settings, read_legacy, resolve_soundpack, soundpacks +from talking_clock.runtime import Clock, Instance, Scheduler, due, runtime_directory + +ROOT = Path(__file__).resolve().parents[1] + + +class SettingsTests(unittest.TestCase): + def test_round_trip_and_atomic_replacement(self): + with tempfile.TemporaryDirectory() as directory: + config = Config(directory) + settings = Settings(module="espeak-ng", voice="English (America)", rate=37, + font_size=100, weather_on_space=True) + config.save(settings) + self.assertEqual(config.load(), settings) + config.save(replace(settings, font_size=120)) + self.assertEqual(config.load().font_size, 120) + self.assertEqual(list(Path(directory).iterdir()), [config.path]) + self.assertEqual(config.path.stat().st_mode & 0o777, 0o600) + + def test_invalid_settings_preserve_existing_file(self): + with tempfile.TemporaryDirectory() as directory: + config = Config(directory) + config.save(Settings()) + with self.assertRaises(ValueError): + config.save(Settings(font_size=0)) + self.assertEqual(config.load().font_size, 72) + for data in ({"hourly": "false"}, {"font_size": True}, {"rate": 101}, {"volume": "10"}, []): + config.path.write_text(json.dumps(data)) + with self.assertRaises(ValueError): + config.load() + + def test_legacy_import_is_data_and_preserves_location(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "talking-clockrc" + path.write_text('format=24\nspeak=false\nvoice="rhvoice-alan"\n' + 'zipcode="Raleigh North Carolina"\nsound=play -q\n' + 'ignored=$(touch /tmp/this-must-never-run)\n') + settings = read_legacy(path) + self.assertEqual(settings.voice, "") + self.assertEqual(settings.module, "") + self.assertFalse(settings.speak) + self.assertTrue(settings.weather) + self.assertEqual(settings.location, "Raleigh North Carolina") + self.assertEqual(Config(directory).load(), settings) + + def test_user_soundpack_wins_and_xdg_is_respected(self): + with tempfile.TemporaryDirectory() as directory: + user, system = Path(directory) / "user", Path(directory) / "system" + for root in (user, system): + (root / "talking-clock/sounds/bells").mkdir(parents=True) + with patch.dict(os.environ, {"XDG_DATA_HOME": str(user), "XDG_DATA_DIRS": str(system)}): + self.assertEqual(soundpacks()["bells"], user / "talking-clock/sounds/bells") + self.assertEqual(resolve_soundpack("bells"), user / "talking-clock/sounds/bells") + self.assertTrue(resolve_soundpack("default").is_dir()) + self.assertEqual(resolve_soundpack(str(system)), system) + + def test_live_reload_keeps_previous_settings_on_error(self): + with tempfile.TemporaryDirectory() as directory: + config = Config(directory) + config.save(Settings(speak=False, chime=False)) + instance = Mock() + clock = Clock(config, config.load(), instance) + config.save(Settings(speak=False, chime=False, font_size=100)) + clock.tick() + self.assertEqual(clock.settings.font_size, 100) + config.path.write_text('{"font_size": "bad"}') + clock.last_check = 0 + with self.assertLogs("talking_clock.runtime", level="ERROR"): + clock.tick() + self.assertEqual(clock.settings.font_size, 100) + clock.stop() + + +class SchedulingTests(unittest.TestCase): + def test_independent_intervals(self): + for hourly in (False, True): + for half in (False, True): + for quarter in (False, True): + settings = Settings(hourly=hourly, half_hour=half, quarter_hour=quarter) + for minute, expected in ((0, hourly), (15, quarter), (30, half), (45, quarter), (17, False)): + self.assertEqual(due(datetime(2026, 10, 23, 12, minute), settings), expected) + + def test_no_repeat_startup_burst_or_catch_up(self): + audio = Mock() + scheduler = Scheduler(audio) + settings = Settings(hourly=True, half_hour=True, quarter_hour=True) + scheduler.tick(datetime(2026, 10, 23, 12, 0, 40), settings) + audio.announce.assert_not_called() + scheduler.tick(datetime(2026, 10, 23, 12, 1), settings) + scheduler.tick(datetime(2026, 10, 23, 12, 15), settings) + scheduler.tick(datetime(2026, 10, 23, 12, 15, 1), settings) + self.assertEqual(audio.announce.call_count, 1) + scheduler.tick(datetime(2026, 10, 23, 12, 5), settings) # wall clock moves back + scheduler.tick(datetime(2026, 10, 23, 12, 15), settings) + self.assertEqual(audio.announce.call_count, 1) + scheduler.tick(datetime(2026, 10, 23, 16, 2), settings) # resume after sleep + self.assertEqual(audio.announce.call_count, 1) + + def test_midnight_and_time_formats(self): + self.assertEqual(spoken_time(datetime(2026, 10, 23, 0, 0), "12"), "12 o'clock A M") + self.assertEqual(spoken_time(datetime(2026, 10, 23, 0, 0), "24"), "zero hundred hours") + self.assertEqual(spoken_time(datetime(2026, 10, 23, 13, 5), "24"), "13 oh 5") + + +class AudioTests(unittest.TestCase): + def test_default_hour_and_quarter_chimes(self): + audio = Audio() + audio.run = Mock(return_value=True) + audio.chime(datetime(2026, 10, 23, 3, 0), Settings()) + self.assertEqual([Path(call.args[0][-1]).name for call in audio.run.call_args_list], + ["prepend.ogg", "bell.ogg", "bell.ogg", "bell.ogg"]) + audio.run.reset_mock() + audio.chime(datetime(2026, 10, 23, 3, 30), Settings()) + self.assertEqual(Path(audio.run.call_args.args[0][-1]).name, "30.ogg") + + def test_recorded_hour_replaces_repeated_bell(self): + with tempfile.TemporaryDirectory() as directory: + (Path(directory) / "12.ogg").touch() + audio = Audio() + audio.run = Mock(return_value=True) + audio.chime(datetime(2026, 10, 23, 0, 0), Settings(soundpack=directory)) + self.assertEqual(audio.run.call_count, 1) + self.assertEqual(audio.run.call_args.args[0][-1], str(Path(directory) / "12.ogg")) + + def test_speech_dispatcher_receives_all_controls_and_text_as_one_argument(self): + audio = Audio() + audio.run = Mock(return_value=True) + with patch("talking_clock.audio.system_language", return_value="en-us"): + audio.speak("12 o'clock A M", datetime(2026, 10, 23), + Settings(module="espeak-ng", voice="English (America)", rate=30, pitch=-20, volume=40)) + audio.run.assert_called_once_with([ + "spd-say", "-w", "-P", "important", "-N", "talking-clock", "-l", "en-us", + "-r", "30", "-p", "-20", "-i", "40", "-o", "espeak-ng", + "-y", "English (America)", "--", "12 o'clock A M"]) + + def test_stop_terminates_active_process_and_blocks_new_audio(self): + audio = Audio() + process = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], + start_new_session=True) + try: + audio.process = process + audio.stop() + self.assertIsNotNone(process.poll()) + with patch("talking_clock.audio.subprocess.Popen") as launch: + self.assertFalse(audio.run(["must-not-run"])) + launch.assert_not_called() + finally: + if process.poll() is None: + process.kill() + process.wait() + + +class LifecycleTests(unittest.TestCase): + def test_staged_installer_launcher_and_soundpack(self): + with tempfile.TemporaryDirectory() as directory: + environment = dict(os.environ, DESTDIR=directory, PREFIX="/usr/local") + subprocess.run(["bash", str(ROOT / "INSTALL")], env=environment, + check=True, capture_output=True, timeout=10) + prefix = Path(directory) / "usr/local" + result = subprocess.run([str(prefix / "bin/talking-clock"), "-h"], + capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("-H", result.stdout) + environment.update(PYTHONPATH=str(prefix / "share/talking-clock/python"), + XDG_DATA_HOME=str(Path(directory) / "empty-user"), + XDG_DATA_DIRS=str(Path(directory) / "empty-system")) + result = subprocess.run([sys.executable, "-c", + "from talking_clock.config import resolve_soundpack; " + "print(resolve_soundpack('default'))"], + env=environment, capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), str(prefix / "share/talking-clock/sounds/default")) + + def test_single_instance_lock_and_stale_socket_recovery(self): + with tempfile.TemporaryDirectory() as directory: + first, second = Instance(Path(directory)), Instance(Path(directory)) + try: + self.assertTrue(first.acquire()) + self.assertFalse(second.acquire()) + first.close() + self.assertTrue(second.acquire()) + finally: + first.close() + second.close() + + def test_reject_non_private_runtime_directory(self): + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / "talking-clock" + target.mkdir(mode=0o755) + target.chmod(0o755) + with patch.dict(os.environ, {"XDG_RUNTIME_DIR": directory}): + with self.assertRaises(OSError): + runtime_directory() + + def test_headless_duplicate_kill_and_restart_without_gtk(self): + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + config = Config(base / "config/talking-clock") + config.save(Settings(chime=False, speak=False)) + environment = dict(os.environ, XDG_CONFIG_HOME=str(base / "config"), + XDG_RUNTIME_DIR=directory, DISPLAY="", WAYLAND_DISPLAY="") + command = [sys.executable, str(ROOT / "talking-clock")] + for _ in range(2): + process = subprocess.Popen(command + ["-H"], env=environment, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + socket_path = base / "talking-clock/control.sock" + for _ in range(100): + if socket_path.exists() or process.poll() is not None: + break + time.sleep(0.02) + self.assertIsNone(process.poll()) + result = subprocess.run(command + ["-H"], env=environment, + capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("already running", result.stdout) + # A failed attempt to open a display must leave headless mode alive. + result = subprocess.run(command, env=environment, + capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIsNone(process.poll()) + result = subprocess.run(command + ["-k"], env=environment, + capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + _, errors = process.communicate(timeout=5) + self.assertEqual(process.returncode, 0, errors.decode()) + self.assertFalse(socket_path.exists()) + finally: + if process.poll() is None: + process.terminate() + process.communicate(timeout=5) + result = subprocess.run(command + ["-k"], env=environment, + capture_output=True, timeout=5) + self.assertEqual(result.returncode, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gui.py b/tests/test_gui.py new file mode 100644 index 0000000..146799f --- /dev/null +++ b/tests/test_gui.py @@ -0,0 +1,360 @@ +"""Opt-in desktop checks: TALKING_CLOCK_GUI_TESTS=1 python3 -m unittest discover -s tests.""" +from dataclasses import replace +from datetime import datetime +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + + +@unittest.skipUnless(os.environ.get("TALKING_CLOCK_GUI_TESTS") == "1", "requires an opted-in GTK display") +class GuiTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + from talking_clock.gui import Gtk + if not Gtk.init_check()[0]: + raise RuntimeError("GUI tests require an accessible display") + + def setUp(self): + from talking_clock.config import Config, Settings + from talking_clock.runtime import Clock + from talking_clock.gui import ClockWindow + from talking_clock.speech import Catalog + self.discovery = patch("talking_clock.gui.discover", + return_value=Catalog(("espeak-ng", "rhvoice"), "espeak-ng", ("English (America)",), "en-us")) + self.mock_discover = self.discovery.start() + self.addCleanup(self.discovery.stop) + self.directory = tempfile.TemporaryDirectory() + self.clock = Clock(Config(self.directory.name), Settings(chime=False, speak=False), Mock()) + self.clock.audio.weather.detect_location = Mock(return_value=None) + self.window = ClockWindow(self.clock) + + def tearDown(self): + self.window.destroy() + self.directory.cleanup() + + def wait_until(self, predicate): + from talking_clock.gui import GLib + loop = GLib.MainLoop() + deadline = time.monotonic() + 2 + def check(): + if predicate() or time.monotonic() >= deadline: + loop.quit() + return False + return True + GLib.timeout_add(10, check) + loop.run() + self.assertTrue(predicate()) + + def test_initial_focus_tab_and_shift_tab(self): + from talking_clock.gui import Gtk + self.assertIs(self.window.get_focus(), self.window.time) + self.assertFalse(self.window.time.get_accessible().get_name()) + self.assertFalse(self.window.time.get_accessible().get_description()) + self.window.child_focus(Gtk.DirectionType.TAB_FORWARD) + self.assertIsInstance(self.window.get_focus(), Gtk.Button) + self.assertEqual(self.window.get_focus().get_label(), "_Settings") + self.window.time.grab_focus() + self.window.child_focus(Gtk.DirectionType.TAB_BACKWARD) + # Toplevel Tab traversal wraps after leaving its container. + if self.window.get_focus() is None: + self.window.child_focus(Gtk.DirectionType.TAB_BACKWARD) + self.assertIsInstance(self.window.get_focus(), Gtk.Button) + + def test_display_updates_and_supports_24_hour_and_hidden_seconds(self): + with patch("talking_clock.gui.datetime") as clock_time: + clock_time.now.return_value = datetime(2026, 10, 23, 14, 5, 6).astimezone() + self.window.update_time() + self.assertEqual(self.window.time.get_text(), "2:05:06 PM") + clock_time.now.return_value = datetime(2026, 10, 23, 14, 5, 7).astimezone() + self.window.update_time() + self.assertEqual(self.window.time.get_text(), "2:05:07 PM") + self.clock.settings = replace(self.clock.settings, time_format="24", seconds=False) + self.window.update_time() + self.assertEqual(self.window.time.get_text(), "14:05") + + def test_space_speaks_only_time_even_if_scheduled_speech_is_disabled(self): + from talking_clock.gui import Gdk + self.clock.settings = replace(self.clock.settings, speak=False, chime=True, weather=True) + original = replace(self.clock.settings) + self.clock.audio.speak = Mock() + self.clock.audio.chime = Mock() + self.clock.audio.weather.report = Mock() + event = Gdk.Event.new(Gdk.EventType.KEY_PRESS) + event.keyval = Gdk.KEY_space + event.state = Gdk.ModifierType(0) + with patch("talking_clock.gui.datetime") as clock_time: + clock_time.now.return_value = datetime(2026, 10, 23, 14, 5, 6).astimezone() + self.assertTrue(self.window.time.emit("key-press-event", event)) + self.clock.audio.worker.join(timeout=2) + self.assertFalse(self.clock.audio.worker.is_alive()) + self.assertEqual(self.clock.audio.speak.call_args.args[0], "2 oh 5 P M") + self.clock.audio.chime.assert_not_called() + self.clock.audio.weather.report.assert_not_called() + self.assertEqual(self.clock.settings, original) + + def test_modified_space_and_other_keys_do_not_trigger_speech(self): + from talking_clock.gui import Gdk + self.clock.audio.announce = Mock() + event = Gdk.Event.new(Gdk.EventType.KEY_PRESS) + event.keyval = Gdk.KEY_space + event.state = Gdk.ModifierType.CONTROL_MASK + self.assertFalse(self.window.time_key_pressed(self.window.time, event)) + event.keyval = Gdk.KEY_Tab + event.state = Gdk.ModifierType(0) + self.assertFalse(self.window.time_key_pressed(self.window.time, event)) + self.clock.audio.announce.assert_not_called() + + def test_space_weather_is_independent_of_scheduled_weather(self): + from talking_clock.gui import Gdk + event = Gdk.Event.new(Gdk.EventType.KEY_PRESS) + event.keyval = Gdk.KEY_space + event.state = Gdk.ModifierType(0) + for scheduled in (False, True): + for on_space in (False, True): + with self.subTest(scheduled=scheduled, on_space=on_space): + self.clock.settings = replace(self.clock.settings, weather=scheduled, + weather_on_space=on_space) + original = replace(self.clock.settings) + self.clock.audio.speak = Mock() + self.clock.audio.weather.report = Mock(return_value="77 degrees Fahrenheit and cloudy") + self.assertTrue(self.window.time_key_pressed(self.window.time, event)) + self.clock.audio.worker.join(timeout=2) + self.assertFalse(self.clock.audio.worker.is_alive()) + text = self.clock.audio.speak.call_args.args[0] + self.assertEqual("77 degrees Fahrenheit and cloudy" in text, on_space) + self.assertEqual(self.clock.audio.weather.report.call_count, int(on_space)) + self.assertEqual(self.clock.settings, original) + + def test_settings_tabs_save_and_apply_immediately(self): + from talking_clock.gui import SettingsDialog + dialog = SettingsDialog(self.window, self.clock) + try: + self.assertEqual(dialog.notebook.get_n_pages(), 5) + self.assertEqual(set(dialog.controls), set(vars(self.clock.settings))) + dialog.controls["font_size"].set_value(100) + dialog.controls["high_contrast"].set_active(True) + dialog.controls["half_hour"].set_active(True) + dialog.controls["time_format"].set_active_id("24") + dialog.controls["weather_on_space"].set_active(True) + dialog.save() + self.assertEqual(self.clock.settings.font_size, 100) + self.assertTrue(self.clock.settings.half_hour) + self.assertTrue(self.clock.settings.high_contrast) + self.assertTrue(self.clock.settings.weather_on_space) + self.assertEqual(self.clock.config.load(), self.clock.settings) + finally: + dialog.destroy() + + def test_combo_accessible_names_and_label_relations(self): + from talking_clock.gui import Atk, SettingsDialog + names = {"soundpack": "Soundpack", "module": "Module", "voice": "Voice", + "rate": "Rate", "pitch": "Pitch", "volume": "Volume", + "time_format": "Time format", "temperature_unit": "Temperature unit"} + dialog = SettingsDialog(self.window, self.clock) + try: + self.wait_until(lambda: dialog.controls["voice"].get_sensitive()) + for key, name in names.items(): + control = dialog.controls[key] + targets = [control, control.get_child()] if control.get_has_entry() else [control] + for target in targets: + with self.subTest(control=key, widget=type(target).__name__): + accessible = target.get_accessible() + self.assertEqual(accessible.get_name(), name) + relation = accessible.ref_relation_set().get_relation_by_type(Atk.RelationType.LABELLED_BY) + self.assertIsNotNone(relation) + self.assertEqual(relation.get_target()[0].get_name(), name) + finally: + dialog.destroy() + + def test_speech_test_uses_unsaved_controls_without_saving(self): + from talking_clock.gui import SettingsDialog + dialog = SettingsDialog(self.window, self.clock) + try: + self.wait_until(lambda: dialog.controls["voice"].get_sensitive()) + original = replace(self.clock.settings) + dialog.controls["module"].set_active_id("espeak-ng") + self.wait_until(lambda: dialog.controls["voice"].get_sensitive()) + dialog.controls["voice"].set_active_id("English (America)") + for key, value in (("rate", "30"), ("pitch", "-20"), ("volume", "50")): + dialog.controls[key].set_active_id(value) + self.clock.audio.speak = Mock() + self.clock.audio.chime = Mock() + self.clock.audio.weather.report = Mock() + dialog.test_speech_button.clicked() + self.clock.audio.worker.join(timeout=2) + self.assertFalse(self.clock.audio.worker.is_alive()) + preview = self.clock.audio.speak.call_args.args[2] + self.assertEqual((preview.module, preview.voice, preview.rate, preview.pitch, preview.volume), + ("espeak-ng", "English (America)", 30, -20, 50)) + self.assertTrue(preview.speak) + self.clock.audio.chime.assert_not_called() + self.clock.audio.weather.report.assert_not_called() + self.assertEqual(self.clock.settings, original) + self.assertFalse(self.clock.config.path.exists()) + finally: + dialog.destroy() + + def test_speech_controls_are_combos_and_reload_voices_for_selected_module(self): + from talking_clock.gui import Gtk, SettingsDialog + from talking_clock.speech import Catalog + self.clock.settings = replace(self.clock.settings, rate=37) + dialog = SettingsDialog(self.window, self.clock) + try: + self.wait_until(lambda: dialog.controls["voice"].get_sensitive()) + for key in ("module", "voice", "rate", "pitch", "volume"): + self.assertIsInstance(dialog.controls[key], Gtk.ComboBoxText) + self.assertEqual(dialog.controls["rate"].get_active_id(), "37") + self.mock_discover.return_value = Catalog(("espeak-ng", "rhvoice"), "rhvoice", ("Alan",), "en-us") + dialog.controls["module"].set_active_id("rhvoice") + self.wait_until(lambda: dialog.controls["voice"].get_sensitive()) + self.mock_discover.assert_called_with("rhvoice") + self.assertTrue(dialog.controls["voice"].set_active_id("Alan")) + self.assertFalse(dialog.controls["voice"].set_active_id("English (America)")) + dialog.controls["pitch"].set_active_id("-20") + dialog.controls["volume"].set_active_id("50") + dialog.save() + self.assertEqual(self.clock.settings.module, "rhvoice") + self.assertEqual(self.clock.settings.voice, "Alan") + self.assertEqual(self.clock.settings.rate, 37) + self.assertEqual(self.clock.settings.pitch, -20) + self.assertEqual(self.clock.settings.volume, 50) + finally: + dialog.destroy() + + def test_dispatcher_failure_preserves_saved_speech_controls(self): + from talking_clock.gui import SettingsDialog + self.clock.settings = replace(self.clock.settings, module="offline", voice="Saved voice") + self.mock_discover.side_effect = RuntimeError("Dispatcher unavailable") + dialog = SettingsDialog(self.window, self.clock) + try: + self.wait_until(lambda: dialog.controls["voice"].get_sensitive()) + self.assertIn("Saved selections are preserved", dialog.speech_status.get_text()) + dialog.save() + self.assertEqual(self.clock.settings.module, "offline") + self.assertEqual(self.clock.settings.voice, "Saved voice") + finally: + dialog.destroy() + + def test_window_close_stops_audio_and_scheduler(self): + self.clock.audio.stop = Mock() + self.window.destroy() + self.assertTrue(self.clock.stopped.is_set()) + self.clock.audio.stop.assert_called_once() + + def test_control_q_closes_clock_from_settings_button(self): + from talking_clock.gui import Gdk, Gtk + self.clock.audio.stop = Mock() + self.window.child_focus(Gtk.DirectionType.TAB_FORWARD) + self.assertIsInstance(self.window.get_focus(), Gtk.Button) + self.assertTrue(Gtk.accel_groups_activate(self.window, Gdk.KEY_q, Gdk.ModifierType.CONTROL_MASK)) + self.assertTrue(self.clock.stopped.is_set()) + self.clock.audio.stop.assert_called_once() + + def test_control_q_from_settings_stops_program_without_saving(self): + from talking_clock.gui import Gdk, Gtk + self.window.open_settings(None) + self.window.dialog.controls["font_size"].set_value(100) + self.assertTrue(Gtk.accel_groups_activate(self.window.dialog, Gdk.KEY_q, + Gdk.ModifierType.CONTROL_MASK)) + self.assertTrue(self.clock.stopped.is_set()) + self.assertIsNone(self.window.dialog) + self.assertEqual(self.clock.settings.font_size, 72) + self.assertFalse(self.clock.config.path.exists()) + + def test_shutdown_with_settings_open_exits_main_loop(self): + from talking_clock.gui import GLib, Gtk + self.window.open_settings(None) + self.assertIsNotNone(self.window.dialog) + GLib.idle_add(lambda: self.window.destroy() or False) + Gtk.main() + self.assertIsNone(self.window.dialog) + self.assertTrue(self.clock.stopped.is_set()) + + def test_detected_city_prefills_weather_fields(self): + from talking_clock.gui import GLib, SettingsDialog + self.clock.audio.weather.detect_location.return_value = { + "location": "Raleigh, North Carolina, US", "latitude": "35.78", "longitude": "-78.64"} + dialog = SettingsDialog(self.window, self.clock) + try: + deadline = time.monotonic() + 2 + loop = GLib.MainLoop() + def ready(): + if dialog.controls["location"].get_text() or time.monotonic() >= deadline: + loop.quit() + return False + return True + GLib.timeout_add(10, ready) + loop.run() + self.assertEqual(dialog.controls["location"].get_text(), "Raleigh, North Carolina, US") + self.assertEqual(dialog.controls["latitude"].get_text(), "35.78") + self.assertEqual(dialog.controls["longitude"].get_text(), "-78.64") + dialog.save() + self.assertEqual(self.clock.config.load().latitude, "35.78") + finally: + dialog.destroy() + + def test_editing_city_does_not_reuse_old_coordinates(self): + from talking_clock.gui import SettingsDialog + self.clock.settings = replace(self.clock.settings, location="Raleigh", latitude="35.78", longitude="-78.64") + dialog = SettingsDialog(self.window, self.clock) + try: + dialog.controls["location"].set_text("Berlin, Germany") + dialog.save() + self.assertEqual(self.clock.settings.location, "Berlin, Germany") + self.assertEqual(self.clock.settings.latitude, "") + self.assertEqual(self.clock.settings.longitude, "") + finally: + dialog.destroy() + + def test_gui_process_replaces_headless_process_and_remains_single_instance(self): + from talking_clock.config import Config, Settings + from talking_clock.runtime import send_command + root = Path(__file__).resolve().parents[1] + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + Config(base / "config/talking-clock").save(Settings(chime=False, speak=False)) + environment = dict(os.environ, XDG_RUNTIME_DIR=directory, XDG_CONFIG_HOME=str(base / "config")) + command = [sys.executable, str(root / "talking-clock")] + processes = [] + try: + headless = subprocess.Popen(command + ["-H"], env=environment, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + processes.append(headless) + deadline = time.monotonic() + 5 + while not (base / "talking-clock/control.sock").exists() and time.monotonic() < deadline: + time.sleep(0.02) + self.assertIsNone(headless.poll()) + gui = subprocess.Popen(command, env=environment, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + processes.append(gui) + headless.communicate(timeout=5) + self.assertEqual(headless.returncode, 0) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if send_command(base / "talking-clock", "status") == "gui": + break + time.sleep(0.02) + else: + self.fail("Replacement GUI never became ready") + self.assertIsNone(gui.poll()) + duplicate = subprocess.run(command, env=environment, capture_output=True, timeout=5) + self.assertEqual(duplicate.returncode, 0, duplicate.stderr) + self.assertIsNone(gui.poll()) + subprocess.run(command + ["-k"], env=environment, check=True, capture_output=True, timeout=5) + gui.communicate(timeout=5) + self.assertEqual(gui.returncode, 0) + finally: + for process in processes: + if process.poll() is None: + process.terminate() + process.communicate(timeout=5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_speech.py b/tests/test_speech.py new file mode 100644 index 0000000..f92d7bc --- /dev/null +++ b/tests/test_speech.py @@ -0,0 +1,97 @@ +"""Speech discovery is read-only; all dispatcher connections here are mocked.""" +import json +from pathlib import Path +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) +from talking_clock.config import Config, Settings +from talking_clock.speech import connect, discover, matching_voices, system_language + + +class SpeechTests(unittest.TestCase): + def test_locale_precedence_encoding_and_c_fallback(self): + self.assertEqual(system_language({"LC_ALL": "de_DE.UTF-8", "LANG": "en_US.UTF-8"}), "de-de") + self.assertEqual(system_language({"LC_ALL": "C.UTF-8", "LANGUAGE": "es_MX:en"}), "es-mx") + self.assertEqual(system_language({"LC_MESSAGES": "fr_CA.UTF-8@modifier", "LANG": "en_US.UTF-8"}), "fr-ca") + self.assertEqual(system_language({"LANG": "pt_BR.UTF-8"}), "pt-br") + self.assertEqual(system_language({"LC_ALL": "POSIX", "LANG": "C"}), "en") + + def test_voice_filter_prefers_region_and_falls_back_to_language(self): + voices = (("British", "en-gb", ""), ("American", "en_US", ""), + ("French", "fr", ""), ("Unknown", None, None), + ("British", "en-gb", "other"), ("English", "EN", "")) + self.assertEqual(matching_voices(voices, "en-us"), ("American",)) + self.assertEqual(matching_voices(voices, "en-au"), ("American", "British", "English")) + self.assertEqual(matching_voices(voices, "en"), ("English",)) + self.assertEqual(matching_voices(voices, "fr-fr"), ("French",)) + self.assertEqual(matching_voices(voices, "es"), ()) + + def test_discovery_queries_selected_module_and_closes_without_speaking(self): + client = Mock() + client.list_output_modules.return_value = ("rhvoice", "espeak-ng") + client.list_synthesis_voices.return_value = (("American", "en-us", ""), ("French", "fr", "")) + with patch("talking_clock.speech.connect", return_value=client), patch( + "talking_clock.speech.system_language", return_value="en-us"): + catalog = discover("espeak-ng") + self.assertEqual(catalog.modules, ("espeak-ng", "rhvoice")) + self.assertEqual(catalog.voices, ("American",)) + client.set_output_module.assert_called_once_with("espeak-ng") + client.close.assert_called_once() + client.speak.assert_not_called() + + def test_system_default_module_is_queried_without_overriding_it(self): + client = Mock() + client.list_output_modules.return_value = ("espeak-ng",) + client.get_output_module.return_value = "espeak-ng" + client.list_synthesis_voices.return_value = () + with patch("talking_clock.speech.connect", return_value=client): + self.assertEqual(discover().module, "espeak-ng") + client.set_output_module.assert_not_called() + client.speak.assert_not_called() + + def test_connection_does_not_autostart_dispatcher(self): + module = SimpleNamespace(SSIPClient=Mock()) + with patch.dict(sys.modules, {"speechd": module}): + connect() + module.SSIPClient.assert_called_once_with("talking-clock", component="settings", autospawn=False) + + def test_unavailable_dispatcher_and_module_are_reported(self): + with patch("talking_clock.speech.connect", side_effect=OSError("offline")): + with self.assertRaisesRegex(RuntimeError, "offline"): + discover() + client = Mock() + client.list_output_modules.return_value = () + with patch("talking_clock.speech.connect", return_value=client): + with self.assertRaisesRegex(RuntimeError, "unavailable"): + discover("missing") + client.close.assert_called_once() + + def test_numeric_limits_and_saved_intermediate_values(self): + for key in ("rate", "pitch", "volume"): + for value in (-100, -37, 0, 37, 100): + settings = Settings(**{key: value}) + self.assertIs(settings.validate(), settings) + for value in (-101, 101, "10", True): + with self.assertRaises(ValueError): + Settings(**{key: value}).validate() + + def test_old_python_speech_preferences_migrate_to_dispatcher_defaults(self): + with tempfile.TemporaryDirectory() as directory: + config = Config(directory) + config.path.write_text(json.dumps({"voice": "custom", "voice_command": "some old command", + "rhvoice": "alan", "gama_path": "/old", "font_size": 100})) + settings = config.load() + self.assertEqual(settings.module, "") + self.assertEqual(settings.voice, "") + self.assertEqual(settings.font_size, 100) + config.save(settings) + data = json.loads(config.path.read_text()) + self.assertFalse(set(data) & {"voice_command", "rhvoice", "gama_path"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_weather.py b/tests/test_weather.py new file mode 100644 index 0000000..f794efb --- /dev/null +++ b/tests/test_weather.py @@ -0,0 +1,151 @@ +"""Weather checks use mocked JSON and an isolated cache, without network access.""" +from dataclasses import replace +from datetime import datetime, timezone +from email.message import Message +from email.utils import format_datetime +from pathlib import Path +import sys +import tempfile +import threading +import unittest +from unittest.mock import Mock, patch +from urllib.error import HTTPError + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) +from talking_clock.audio import Audio +from talking_clock.config import Settings +from talking_clock.weather import WeatherClient + +CURRENT = {"current": {"temperature_2m": 25, "weather_code": 3}, + "current_units": {"temperature_2m": "°C"}} +PLACE = {"city": "Raleigh", "region": "North Carolina", "country": "US", "loc": "35.78,-78.64"} + + +class WeatherTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.client = WeatherClient(self.temporary.name) + self.client._request = Mock(return_value=CURRENT) + self.settings = Settings(latitude="35.78", longitude="-78.64") + + def test_units_share_cache_and_conditions_are_plain_text(self): + with patch("talking_clock.weather.time.time", return_value=1000): + self.assertEqual(self.client.report(self.settings), "77 degrees Fahrenheit and cloudy") + self.assertEqual(self.client.report(replace(self.settings, temperature_unit="C")), + "25 degrees Celsius and cloudy") + self.client._request.assert_called_once() + self.assertIn("current=temperature_2m%2Cweather_code", self.client._request.call_args.args[0]) + + def test_negative_temperature_and_clear_condition(self): + self.client._request.return_value = {"current": {"temperature_2m": -10, "weather_code": 0}, + "current_units": {"temperature_2m": "°C"}} + self.assertEqual(self.client.report(self.settings), "14 degrees Fahrenheit and clear") + + def test_every_second_checks_and_restart_only_fetch_at_next_quarter(self): + for now in range(1000, 1800): + with patch("talking_clock.weather.time.time", return_value=now): + self.assertIsNotNone(self.client.report(self.settings)) + self.assertEqual(self.client._request.call_count, 1) + restarted = WeatherClient(self.temporary.name) + restarted._request = self.client._request + with patch("talking_clock.weather.time.time", return_value=1799): + self.assertIsNotNone(restarted.report(self.settings)) + self.assertEqual(self.client._request.call_count, 1) + with patch("talking_clock.weather.time.time", return_value=1800): + self.assertIsNotNone(restarted.report(self.settings)) + self.assertEqual(self.client._request.call_count, 2) + + def test_changed_coordinates_do_not_bypass_request_budget(self): + with patch("talking_clock.weather.time.time", return_value=1000): + self.assertIsNotNone(self.client.report(self.settings)) + self.assertIsNone(self.client.report(replace(self.settings, latitude="40"))) + self.client._request.assert_called_once() + + def test_parallel_requests_share_disk_lock(self): + other = WeatherClient(self.temporary.name) + other._request = self.client._request + with patch("talking_clock.weather.time.time", return_value=1000): + threads = [threading.Thread(target=client.report, args=(self.settings,)) + for client in (self.client, other, self.client, other)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(5) + self.assertFalse(thread.is_alive()) + self.client._request.assert_called_once() + + def test_ip_location_is_cached_for_a_day_and_used_without_geocoding(self): + self.client._request.side_effect = [PLACE, CURRENT, PLACE] + with patch("talking_clock.weather.time.time", return_value=1000): + place = self.client.detect_location() + self.assertEqual(place["location"], "Raleigh, North Carolina, US") + self.assertIsNotNone(self.client.report(Settings())) + self.assertEqual(self.client._request.call_count, 2) + with patch("talking_clock.weather.time.time", return_value=87399): + self.assertEqual(self.client.detect_location(), place) + self.assertEqual(self.client._request.call_count, 2) + with patch("talking_clock.weather.time.time", return_value=87400): + self.assertEqual(self.client.detect_location(), place) + self.assertEqual(self.client._request.call_count, 3) + + def test_manual_city_uses_geocoding(self): + self.client._request.side_effect = [ + {"results": [{"name": "Berlin", "country": "Germany", "latitude": 52.52, "longitude": 13.41}]}, + CURRENT] + self.assertIsNotNone(self.client.report(Settings(location="Berlin, Germany"))) + self.assertIn("geocoding-api.open-meteo.com", self.client._request.call_args_list[0].args[0]) + self.assertIn("latitude=52.52", self.client._request.call_args_list[1].args[0]) + + def test_invalid_responses_are_not_spoken_or_retried_each_second(self): + for invalid in ({}, {"current": {"temperature_2m": None, "weather_code": 3}}, + {"current": {"temperature_2m": 25, "weather_code": None}}, + {"results": []}): + with self.subTest(invalid=invalid), tempfile.TemporaryDirectory() as directory: + client = WeatherClient(directory) + client._request = Mock(return_value=invalid) + with patch("talking_clock.weather.time.time", return_value=1000), self.assertLogs(level="WARNING"): + self.assertIsNone(client.report(self.settings)) + with patch("talking_clock.weather.time.time", return_value=1001): + self.assertIsNone(client.report(self.settings)) + client._request.assert_called_once() + + def test_http_429_honors_retry_after_and_persists_failure_backoff(self): + for retry in ("7200", format_datetime(datetime.fromtimestamp(8200, timezone.utc))): + with self.subTest(retry=retry), tempfile.TemporaryDirectory() as directory: + client = WeatherClient(directory) + headers = Message() + headers["Retry-After"] = retry + error = HTTPError("https://example.test", 429, "limit", headers, None) + self.addCleanup(error.close) + client._request = Mock(side_effect=error) + with patch("talking_clock.weather.time.time", return_value=1000), self.assertLogs(level="WARNING"): + self.assertIsNone(client.report(self.settings)) + restarted = WeatherClient(directory) + restarted._request = client._request + with patch("talking_clock.weather.time.time", return_value=8199): + self.assertIsNone(restarted.report(self.settings)) + client._request.assert_called_once() + with patch("talking_clock.weather.time.time", return_value=8200), self.assertLogs(level="WARNING"): + self.assertIsNone(restarted.report(self.settings)) + self.assertEqual(client._request.call_count, 2) + + def test_cache_failure_does_not_contact_server(self): + blocker = Path(self.temporary.name) / "not-a-directory" + blocker.touch() + client = WeatherClient(blocker) + client._request = Mock() + with self.assertLogs(level="WARNING"): + self.assertIsNone(client.report(self.settings)) + client._request.assert_not_called() + + def test_weather_failure_still_speaks_the_time(self): + audio = Audio() + audio.weather.report = Mock(return_value=None) + audio.speak = Mock() + audio._announce(datetime(2026, 10, 23, 12), Settings(weather=True, chime=False)) + self.assertEqual(audio.speak.call_args.args[0], "12 o'clock P M") + + +if __name__ == "__main__": + unittest.main()