Command-line hacking: countdown timer
This is another in my series of articles on doing off-beat and (I hope) interesting things with standard Linux command-line tools. Although it's simple, the script I describe here demonstrates Bash arithmetic expansion, date calculations, and handling terminal interrupts in a script.
Suppose you want to display a running countdown to some significant
event -- a vacation, perhaps. Or your retirement. I'm sure there are
many utilities to do this, but in this article I'll use a simple
Bash script, with some commonplace Linux utilities like date
.
The display should be in meaningful units: days, hours, minutes,
seconds, not just seconds. We also want the target date to be configurable,
so it is passed as an argument to the script. The program will continue
running until the target time is reached, or the user hits ctrl+c.
If the user does hit ctrl+c, we'll need to tidy up the terminal.
Why do we need to tidy up? It turns out that the terminal cursor (usually a flashing block) just looks really ugly and distracting in a continuously-updating display. So we'll need to turn it off -- and put it back when the script finishes.
Basic principle
It isn't easy to parse a date like "March 24 2025 6:13PM", nor to
perform calculations on dates. Happily, it turns out that the
GNU date
utility can do both these things.
First, we can use the format argument +%s
to make date
write its output in seconds. This will be the number of seconds since
'the epoch' -- a date about fifty years in the past, with no particular
significance here. We're only interested in time differences, so
when we subtract two dates, the 'epoch' will cancel, and we'll be
left with a time difference in seconds.
We can use the --date
switch to have the utility parse a
particular date and time. date
will accept a variety of
formats, that might depend on the particular platform.
So to get a particular point in time as value in seconds since the epoch, we can do this:
TARGET_EPOCH=`date --date "$TARGET_DATE" +%s`
If TARGET_DATE
has come from user input, we'll need to
check that it's a valid date. I don't know a better way to do that,
than to check that date
writes a number to stdout
.
Any error messages from date
go to stderr
, so
if we assign stdout
to a shell variable, it will end up
either as a numeric string, or something that won't parse as a number.
If it won't parse, it will evaluate arithmetically with zero, so
we can test like this:
if (( TARGET_EPOCH == 0 )); then exit 1 fi
Incidentally, this test will fail if the target date is, in fact, the epoch date, since the correct numeric valus is zero. But since that's at date in the past, this should never happen.
Now we can calculate the number of seconds until the target date using a simple subtraction.
NOW_EPOCH=`date +%s` EPOCH_DIFF=$((TARGET_EPOCH - NOW_EPOCH))
If now, or at any future time, EPOCH_DIFF
ends up negative,
the target date is in the past, and we need to stop the script.
Formatting the date
We need to break the time difference in seconds down into hours, minutes,
etc. This is just a matter of arithmetic. Conventionally we did this
using the expr
utility, but the Bash script has decent
(integer) arithmetic support built in. Here is the relevant math:
DAYS=$((EPOCH_DIFF / 86400)) # 86400 = seconds in one day DAY_SECS=$((DAYS * 86400)) REM=$((EPOCH_DIFF - DAY_SECS)) HOURS=$((REM / 3600)) HOUR_SECS=$((HOURS * 3600)) REM=$((EPOCH_DIFF - DAY_SECS - HOUR_SECS)) MINS=$((REM / 60)) REM=$((EPOCH_DIFF - DAY_SECS - HOUR_SECS - MINS * 60)) SECS=$REM
We end up with the relevant time components in DAYS
,
HOURS
, MINS
, and SECS
.
Note:
Within an arithmetic expression, we can refer to shell variables with or without the dollar-sign prefix. In most places in a script, however, we need the dollar sign to denote a variable
The slight complication with displaying this data is that it looks ugly to print '1 days', or even, '1 day(s)'; we want an 's' on the end where it's appropriate. I don't know a more elegant way to do this than a construction like this:
printf "%d day%.*s, " $DAYS $((DAYS != 1)) "s"
One final point concerning formatting: I want to print the remaining time every second, but I don't want to print many lines. Rather, I want each new line to over-write the previous one. This is easily accomplished by printing a carriage return character (ASCII 10) before each line of output, and no line-feed. On the Linux console, a carriage return does not imply a line feed. While this solution is simple, it might not work properly with, say, a serial terminal that conflates carriage return and line feed.
tput cr el
is probably a more robust way to over-write
lines but, of course, this
would mean spawning a tput
process every second.
Handling terminal interrupts
Since we're outputing onto a single line, we need to do something
with the terminal cursor. We could park it somewhere, but it's nicer
just to turn it off. We can do that using tput civis
.
However, most likely the script will stop by the user hitting
ctrl+c before the target time is reached. So we need to be able to
reverse the terminal change in that situation. And, of course, we
need to reverse it if the script does, in fact, exit because the target
time has been reached.
We can do this by attaching a function to the relevant console interrupts like this:
function cleanup_exit() { stty echoctl # Show ctrl+c when pressed tput cnorm # Show the cursor exit 0 } # Start program trap cleanup_exit INT TERM # Catch the INT and TERM signals stty -echoctl # Prevent ctrl+c being printed when pressed tput civis # Turn off the cursor ...
Further work
There are various ways in which the display might be improved. With a full console available, we could use block graphics to fill the console with large numbers, for example. Or make them scroll. We probably don't need to display seconds, or even update the display every second, if the target date is months or years in the future.
Download
The complete script is available in my GitHub repository.