899 lines
20 KiB
Bash
Executable File
899 lines
20 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
set -euo pipefail
|
|
|
|
readonly STATE_URL="https://mahgit.ir/MeghdadFadaee/daily-blackout-check/raw/branch/main/state/outages.json"
|
|
readonly TEHRAN_TZ="Asia/Tehran"
|
|
|
|
STATE_PATH=""
|
|
DOWNLOAD_PATH=""
|
|
REFRESH_STATUS_PATH=""
|
|
REFRESH_PID=""
|
|
declare -a SCHEDULE=()
|
|
HERO=""
|
|
REFRESH_ERROR=""
|
|
TERMINAL_ACTIVE=0
|
|
|
|
cleanup() {
|
|
if ((TERMINAL_ACTIVE)); then
|
|
printf '\033[?25h\033[?1049l'
|
|
fi
|
|
if [[ -n "$REFRESH_PID" ]]; then
|
|
kill "$REFRESH_PID" 2>/dev/null || true
|
|
wait "$REFRESH_PID" 2>/dev/null || true
|
|
fi
|
|
if [[ -n "$STATE_PATH" && -f "$STATE_PATH" ]]; then
|
|
rm -f -- "$STATE_PATH"
|
|
fi
|
|
if [[ -n "$DOWNLOAD_PATH" && -f "$DOWNLOAD_PATH" ]]; then
|
|
rm -f -- "$DOWNLOAD_PATH"
|
|
fi
|
|
if [[ -n "$REFRESH_STATUS_PATH" && -f "$REFRESH_STATUS_PATH" ]]; then
|
|
rm -f -- "$REFRESH_STATUS_PATH"
|
|
fi
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage: ./main.sh [-i MINUTES]
|
|
|
|
Continuously display current and upcoming electricity outages from the public
|
|
repository state. The countdown updates every second.
|
|
|
|
Options:
|
|
-i, --interval MINUTES Refetch state at this interval (default: 10)
|
|
-h, --help Show this help
|
|
|
|
Environment:
|
|
NO_COLOR Disable ANSI colors when set
|
|
COLUMNS Override the detected output width
|
|
|
|
Required commands: bash, curl, jq, and GNU date
|
|
EOF
|
|
}
|
|
|
|
die() {
|
|
printf 'Error: %s\n' "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
require_command() {
|
|
local command_name="$1"
|
|
|
|
if ! command -v "$command_name" >/dev/null 2>&1; then
|
|
die "missing required command: $command_name"
|
|
fi
|
|
}
|
|
|
|
fetch_state() {
|
|
local destination="$1"
|
|
local cache_key="$2"
|
|
|
|
curl -fsSL --compressed \
|
|
--connect-timeout 10 \
|
|
--max-time 30 \
|
|
-H 'Cache-Control: no-cache' \
|
|
-o "$destination" \
|
|
"${STATE_URL}?cli=${cache_key}"
|
|
}
|
|
|
|
start_state_refresh() {
|
|
local cache_key="$1"
|
|
|
|
: >"$REFRESH_STATUS_PATH"
|
|
(
|
|
if ! fetch_state "$DOWNLOAD_PATH" "$cache_key" 2>/dev/null; then
|
|
printf 'download-error\n' >"$REFRESH_STATUS_PATH"
|
|
exit 1
|
|
fi
|
|
if ! validate_state "$DOWNLOAD_PATH"; then
|
|
printf 'validation-error\n' >"$REFRESH_STATUS_PATH"
|
|
exit 1
|
|
fi
|
|
printf 'ok\n' >"$REFRESH_STATUS_PATH"
|
|
) &
|
|
REFRESH_PID="$!"
|
|
}
|
|
|
|
finish_state_refresh() {
|
|
local result
|
|
|
|
if [[ -z "$REFRESH_PID" || ! -s "$REFRESH_STATUS_PATH" ]]; then
|
|
return 1
|
|
fi
|
|
wait "$REFRESH_PID" 2>/dev/null || true
|
|
REFRESH_PID=""
|
|
result="$(<"$REFRESH_STATUS_PATH")"
|
|
|
|
case "$result" in
|
|
ok)
|
|
if ! cp -- "$DOWNLOAD_PATH" "$STATE_PATH"; then
|
|
REFRESH_ERROR="Could not cache the downloaded state."
|
|
return 1
|
|
fi
|
|
REFRESH_ERROR=""
|
|
return 0
|
|
;;
|
|
validation-error)
|
|
REFRESH_ERROR="Downloaded state is malformed; showing the last valid state."
|
|
;;
|
|
*)
|
|
REFRESH_ERROR="State refresh failed; showing the last valid state."
|
|
;;
|
|
esac
|
|
|
|
return 1
|
|
}
|
|
|
|
validate_state() {
|
|
local path="$1"
|
|
|
|
jq -e '
|
|
type == "object"
|
|
and .schema_version == 1
|
|
and (.bills | type == "object")
|
|
and all(
|
|
.bills | to_entries[];
|
|
(.key | type == "string")
|
|
and (.value | type == "object")
|
|
and (.value.events | type == "object")
|
|
and all(
|
|
.value.events | to_entries[];
|
|
(.key | type == "string")
|
|
and (.value | type == "object")
|
|
and (.value.status == "active" or .value.status == "cancelled")
|
|
and (.value.snapshot | type == "object")
|
|
and (.value.snapshot.outage_number | type == "string")
|
|
and .value.snapshot.outage_number == .key
|
|
and (.value.snapshot.outage_date | type == "string")
|
|
and (.value.snapshot.start_time | type == "string")
|
|
and (.value.snapshot.stop_time | type == "string")
|
|
and (.value.snapshot.address | type == "string")
|
|
and (.value.snapshot.reason | type == "string")
|
|
and (.value.snapshot.is_planned | type == "boolean")
|
|
)
|
|
)
|
|
' "$path" >/dev/null 2>&1
|
|
}
|
|
|
|
# Convert a Jalali YYYY/MM/DD date to Gregorian YYYY-MM-DD. This arithmetic
|
|
# conversion covers the modern Jalali range used by the outage service.
|
|
jalali_to_gregorian() {
|
|
local input="$1"
|
|
local jy jm jd days gy gd gm leap index
|
|
local -a month_days=(0 31 28 31 30 31 30 31 31 30 31 30 31)
|
|
|
|
if [[ ! "$input" =~ ^([0-9]{4})/([0-9]{2})/([0-9]{2})$ ]]; then
|
|
return 1
|
|
fi
|
|
|
|
jy=$((10#${BASH_REMATCH[1]}))
|
|
jm=$((10#${BASH_REMATCH[2]}))
|
|
jd=$((10#${BASH_REMATCH[3]}))
|
|
if ((jy < 1 || jm < 1 || jm > 12 || jd < 1)); then
|
|
return 1
|
|
fi
|
|
if ((jm <= 6 && jd > 31)); then
|
|
return 1
|
|
fi
|
|
if ((jm >= 7 && jd > 30)); then
|
|
return 1
|
|
fi
|
|
|
|
jy=$((jy + 1595))
|
|
days=$((-355668 + (365 * jy) + ((jy / 33) * 8) + (((jy % 33) + 3) / 4) + jd))
|
|
if ((jm < 7)); then
|
|
days=$((days + ((jm - 1) * 31)))
|
|
else
|
|
days=$((days + ((jm - 7) * 30) + 186))
|
|
fi
|
|
|
|
gy=$((400 * (days / 146097)))
|
|
days=$((days % 146097))
|
|
if ((days > 36524)); then
|
|
days=$((days - 1))
|
|
gy=$((gy + (100 * (days / 36524))))
|
|
days=$((days % 36524))
|
|
if ((days >= 365)); then
|
|
days=$((days + 1))
|
|
fi
|
|
fi
|
|
|
|
gy=$((gy + (4 * (days / 1461))))
|
|
days=$((days % 1461))
|
|
if ((days > 365)); then
|
|
gy=$((gy + ((days - 1) / 365)))
|
|
days=$(((days - 1) % 365))
|
|
fi
|
|
|
|
gd=$((days + 1))
|
|
leap=0
|
|
if (((gy % 4 == 0 && gy % 100 != 0) || gy % 400 == 0)); then
|
|
leap=1
|
|
fi
|
|
month_days[2]=$((28 + leap))
|
|
|
|
gm=1
|
|
for index in {1..12}; do
|
|
if ((gd <= month_days[index])); then
|
|
gm=$index
|
|
break
|
|
fi
|
|
gd=$((gd - month_days[index]))
|
|
done
|
|
|
|
printf '%04d-%02d-%02d\n' "$gy" "$gm" "$gd"
|
|
}
|
|
|
|
event_epochs() {
|
|
local outage_date="$1"
|
|
local start_time="$2"
|
|
local stop_time="$3"
|
|
local gregorian start_epoch stop_epoch
|
|
|
|
if [[ ! "$start_time" =~ ^([0-9]{2}):([0-9]{2})$ ]] \
|
|
|| ((10#${BASH_REMATCH[1]} > 23 || 10#${BASH_REMATCH[2]} > 59)); then
|
|
return 1
|
|
fi
|
|
if [[ ! "$stop_time" =~ ^([0-9]{2}):([0-9]{2})$ ]] \
|
|
|| ((10#${BASH_REMATCH[1]} > 23 || 10#${BASH_REMATCH[2]} > 59)); then
|
|
return 1
|
|
fi
|
|
|
|
gregorian="$(jalali_to_gregorian "$outage_date")" || return 1
|
|
start_epoch="$(TZ="$TEHRAN_TZ" date -d "$gregorian $start_time:00" +%s 2>/dev/null)" \
|
|
|| return 1
|
|
stop_epoch="$(TZ="$TEHRAN_TZ" date -d "$gregorian $stop_time:00" +%s 2>/dev/null)" \
|
|
|| return 1
|
|
if ((stop_epoch <= start_epoch)); then
|
|
stop_epoch=$((stop_epoch + 86400))
|
|
fi
|
|
|
|
printf '%s %s\n' "$start_epoch" "$stop_epoch"
|
|
}
|
|
|
|
event_records() {
|
|
local path="$1"
|
|
|
|
jq -c '
|
|
.bills
|
|
| to_entries[] as $bill
|
|
| $bill.value.events
|
|
| to_entries[]
|
|
| {
|
|
bill_id: $bill.key,
|
|
outage_number: .key,
|
|
status: .value.status,
|
|
outage_date: .value.snapshot.outage_date,
|
|
start_time: .value.snapshot.start_time,
|
|
stop_time: .value.snapshot.stop_time,
|
|
address: .value.snapshot.address,
|
|
reason: .value.snapshot.reason,
|
|
is_planned: .value.snapshot.is_planned
|
|
}
|
|
' "$path"
|
|
}
|
|
|
|
record_fields() {
|
|
local record="$1"
|
|
local -n output="$2"
|
|
|
|
mapfile -d '' -t output < <(
|
|
jq -j '
|
|
[
|
|
.bill_id,
|
|
.outage_number,
|
|
.status,
|
|
.outage_date,
|
|
.start_time,
|
|
.stop_time,
|
|
.address,
|
|
.reason,
|
|
(.is_planned | tostring)
|
|
][] | ., "\u0000"
|
|
' <<<"$record"
|
|
)
|
|
}
|
|
|
|
build_schedule() {
|
|
local path="$1"
|
|
local now_epoch="$2"
|
|
local record start_epoch stop_epoch phase phase_order sort_key line hero_key=""
|
|
local -a fields sortable=()
|
|
|
|
SCHEDULE=()
|
|
HERO=""
|
|
|
|
while IFS= read -r record; do
|
|
record_fields "$record" fields
|
|
if [[ "${fields[2]}" != "active" ]]; then
|
|
continue
|
|
fi
|
|
if ! read -r start_epoch stop_epoch < <(
|
|
event_epochs "${fields[3]}" "${fields[4]}" "${fields[5]}"
|
|
); then
|
|
printf 'Invalid outage date or time for event %s\n' "${fields[1]}" >&2
|
|
return 1
|
|
fi
|
|
if ((stop_epoch <= now_epoch)); then
|
|
continue
|
|
fi
|
|
|
|
if ((start_epoch <= now_epoch)); then
|
|
phase="ONGOING"
|
|
phase_order=0
|
|
sort_key="$(printf '0:%020d:%s:%s' "$start_epoch" "${fields[0]}" "${fields[1]}")"
|
|
if [[ -z "$hero_key" || "$stop_epoch" -lt "$hero_key" ]]; then
|
|
hero_key="$stop_epoch"
|
|
HERO="$phase"$'\t'"$start_epoch"$'\t'"$stop_epoch"$'\t'"$record"
|
|
fi
|
|
else
|
|
phase="UPCOMING"
|
|
phase_order=1
|
|
sort_key="$(printf '1:%020d:%s:%s' "$start_epoch" "${fields[0]}" "${fields[1]}")"
|
|
if [[ -z "$hero_key" && -z "$HERO" ]]; then
|
|
HERO="$phase"$'\t'"$start_epoch"$'\t'"$stop_epoch"$'\t'"$record"
|
|
elif [[ "${HERO%%$'\t'*}" == "UPCOMING" ]]; then
|
|
local current_hero_start
|
|
current_hero_start="${HERO#*$'\t'}"
|
|
current_hero_start="${current_hero_start%%$'\t'*}"
|
|
if ((start_epoch < current_hero_start)); then
|
|
HERO="$phase"$'\t'"$start_epoch"$'\t'"$stop_epoch"$'\t'"$record"
|
|
fi
|
|
fi
|
|
fi
|
|
line="$sort_key"$'\t'"$phase"$'\t'"$start_epoch"$'\t'"$stop_epoch"$'\t'"$record"
|
|
sortable+=("$line")
|
|
done < <(event_records "$path")
|
|
|
|
if ((${#sortable[@]})); then
|
|
mapfile -t SCHEDULE < <(printf '%s\n' "${sortable[@]}" | LC_ALL=C sort)
|
|
fi
|
|
}
|
|
|
|
terminal_columns() {
|
|
local columns="${COLUMNS:-}"
|
|
|
|
if [[ -z "$columns" && -t 1 ]] && command -v tput >/dev/null 2>&1; then
|
|
columns="$(tput cols 2>/dev/null || true)"
|
|
fi
|
|
|
|
if [[ ! "$columns" =~ ^[0-9]+$ || "$columns" -lt 40 ]]; then
|
|
columns=80
|
|
fi
|
|
printf '%s\n' "$columns"
|
|
}
|
|
|
|
color_setup() {
|
|
RESET=""
|
|
BOLD=""
|
|
DIM=""
|
|
CYAN=""
|
|
GREEN=""
|
|
YELLOW=""
|
|
RED=""
|
|
|
|
if ((TERMINAL_ACTIVE)) && [[ -z "${NO_COLOR+x}" ]]; then
|
|
RESET=$'\033[0m'
|
|
BOLD=$'\033[1m'
|
|
DIM=$'\033[2m'
|
|
CYAN=$'\033[36m'
|
|
GREEN=$'\033[32m'
|
|
YELLOW=$'\033[33m'
|
|
RED=$'\033[31m'
|
|
fi
|
|
}
|
|
|
|
center_line() {
|
|
local text="$1"
|
|
local columns="$2"
|
|
local padding=0
|
|
|
|
if ((${#text} < columns)); then
|
|
padding=$(((columns - ${#text}) / 2))
|
|
fi
|
|
printf '%*s%s\n' "$padding" '' "$text"
|
|
}
|
|
|
|
repeat_character() {
|
|
local character="$1"
|
|
local count="$2"
|
|
local output=""
|
|
|
|
printf -v output '%*s' "$count" ''
|
|
printf '%s' "${output// /$character}"
|
|
}
|
|
|
|
large_glyph() {
|
|
case "$1" in
|
|
0)
|
|
cat <<'EOF'
|
|
,a8888a,
|
|
,8P"' `"Y8,
|
|
,8P Y8,
|
|
88 88
|
|
88 88
|
|
`8b d8'
|
|
`8ba, ,ad8'
|
|
"Y8888P"
|
|
EOF
|
|
;;
|
|
1)
|
|
cat <<'EOF'
|
|
88
|
|
,d88
|
|
888888
|
|
88
|
|
88
|
|
88
|
|
88
|
|
88
|
|
EOF
|
|
;;
|
|
2)
|
|
cat <<'EOF'
|
|
ad888888b,
|
|
d8" "88
|
|
a8P
|
|
,d8P"
|
|
a8P"
|
|
a8P'
|
|
d8"
|
|
88888888888
|
|
EOF
|
|
;;
|
|
3)
|
|
cat <<'EOF'
|
|
ad888888b,
|
|
d8" "88
|
|
a8P
|
|
aad8"
|
|
""Y8,
|
|
"8b
|
|
Y8, a88
|
|
"Y888888P'
|
|
EOF
|
|
;;
|
|
4)
|
|
cat <<'EOF'
|
|
,d8
|
|
,d888
|
|
,d8" 88
|
|
,d8" 88
|
|
,d8" 88
|
|
8888888888888
|
|
88
|
|
88
|
|
EOF
|
|
;;
|
|
5)
|
|
cat <<'EOF'
|
|
8888888888
|
|
88
|
|
88 ____
|
|
88a8PPPP8b,
|
|
PP" `8b
|
|
d8
|
|
Y8a a8P
|
|
"Y88888P"
|
|
EOF
|
|
;;
|
|
6)
|
|
cat <<'EOF'
|
|
ad8888ba,
|
|
8P' "Y8
|
|
d8
|
|
88,dd888bb,
|
|
88P' `8b
|
|
88 d8
|
|
88a a8P
|
|
"Y88888P"
|
|
EOF
|
|
;;
|
|
7)
|
|
cat <<'EOF'
|
|
888888888888
|
|
,8P'
|
|
d8"
|
|
,8P'
|
|
d8"
|
|
,8P'
|
|
d8"
|
|
8P'
|
|
EOF
|
|
;;
|
|
8)
|
|
cat <<'EOF'
|
|
ad88888ba
|
|
d8" "8b
|
|
Y8a a8P
|
|
"Y8aaa8P"
|
|
,d8"""8b,
|
|
d8" "8b
|
|
Y8a a8P
|
|
"Y88888P"
|
|
EOF
|
|
;;
|
|
9)
|
|
cat <<'EOF'
|
|
ad88888ba
|
|
d8" "88
|
|
8P 88
|
|
Y8, ,d88
|
|
"PPPPPP"88
|
|
8P
|
|
8b, a8P
|
|
`"Y8888P'
|
|
EOF
|
|
;;
|
|
D)
|
|
cat <<'EOF'
|
|
88888888ba
|
|
88 "8b
|
|
88 8b
|
|
88 88
|
|
88 88
|
|
88 8P
|
|
88 a8P
|
|
88888888P"
|
|
EOF
|
|
;;
|
|
H)
|
|
cat <<'EOF'
|
|
88 88
|
|
88 88
|
|
88 88
|
|
8888888888
|
|
88 88
|
|
88 88
|
|
88 88
|
|
88 88
|
|
EOF
|
|
;;
|
|
M)
|
|
cat <<'EOF'
|
|
88b d88
|
|
888b d888
|
|
88`8b d8'88
|
|
88 `8b d8' 88
|
|
88 `8b d8' 88
|
|
88 `8b d8' 88
|
|
88 `888' 88
|
|
88 `8' 88
|
|
EOF
|
|
;;
|
|
:)
|
|
printf '%s\n' ' ' '888' '888' ' ' ' ' '888' '888' ' '
|
|
;;
|
|
' ')
|
|
printf '%s\n' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' '
|
|
;;
|
|
*)
|
|
printf '%s\n' '???' '???' '???' '???' '???' '???' '???' '???'
|
|
;;
|
|
esac
|
|
}
|
|
|
|
print_large_countdown() {
|
|
local text="$1"
|
|
local columns="$2"
|
|
local rendered_banner
|
|
local character glyph_line glyph_width index row rendered_width=0 block_padding=0
|
|
local -a glyph_lines rows=('' '' '' '' '' '' '' '')
|
|
|
|
if command -v pyfiglet >/dev/null 2>&1; then
|
|
if rendered_banner="$(pyfiglet -f univers -w "$columns" -j center "$text" 2>/dev/null)"; then
|
|
printf '%s\n' "$rendered_banner"
|
|
return
|
|
fi
|
|
fi
|
|
if command -v figlet >/dev/null 2>&1; then
|
|
if rendered_banner="$(figlet -f univers -w "$columns" -c "$text" 2>/dev/null)"; then
|
|
printf '%s\n' "$rendered_banner"
|
|
return
|
|
fi
|
|
fi
|
|
|
|
for ((index = 0; index < ${#text}; index++)); do
|
|
character="${text:index:1}"
|
|
mapfile -t glyph_lines < <(large_glyph "$character")
|
|
glyph_width=0
|
|
for row in {0..7}; do
|
|
if ((${#glyph_lines[row]} > glyph_width)); then
|
|
glyph_width="${#glyph_lines[row]}"
|
|
fi
|
|
done
|
|
for row in {0..7}; do
|
|
printf -v glyph_line '%-*s' "$glyph_width" "${glyph_lines[row]:-}"
|
|
rows[row]+="$glyph_line "
|
|
done
|
|
done
|
|
|
|
for row in {0..7}; do
|
|
while [[ "${rows[row]}" == *' ' ]]; do
|
|
rows[row]="${rows[row]% }"
|
|
done
|
|
if ((${#rows[row]} > rendered_width)); then
|
|
rendered_width="${#rows[row]}"
|
|
fi
|
|
done
|
|
|
|
if ((rendered_width > columns)); then
|
|
center_line "$text" "$columns"
|
|
return
|
|
fi
|
|
|
|
block_padding=$(((columns - rendered_width) / 2))
|
|
for row in {0..7}; do
|
|
printf '%*s%s\n' "$block_padding" '' "${rows[row]}"
|
|
done
|
|
}
|
|
|
|
duration_parts() {
|
|
local seconds="$1"
|
|
local minutes days hours
|
|
|
|
((seconds < 0)) && seconds=0
|
|
minutes=$(((seconds + 59) / 60))
|
|
days=$((minutes / 1440))
|
|
hours=$(((minutes % 1440) / 60))
|
|
minutes=$((minutes % 60))
|
|
printf '%s %s %s\n' "$days" "$hours" "$minutes"
|
|
}
|
|
|
|
banner_duration() {
|
|
local seconds="$1"
|
|
local minutes hours
|
|
|
|
((seconds < 0)) && seconds=0
|
|
hours=$((seconds / 3600))
|
|
minutes=$(((seconds % 3600) / 60))
|
|
seconds=$((seconds % 60))
|
|
printf '%02d : %02d : %02d\n' "$hours" "$minutes" "$seconds"
|
|
}
|
|
|
|
human_duration() {
|
|
local seconds="$1"
|
|
local days hours minutes output=""
|
|
|
|
read -r days hours minutes < <(duration_parts "$seconds")
|
|
((days)) && output+="${days}d "
|
|
((hours)) && output+="${hours}h "
|
|
output+="${minutes}m"
|
|
printf '%s\n' "$output"
|
|
}
|
|
|
|
render_event() {
|
|
local line="$1"
|
|
local now_epoch="$2"
|
|
local ignored phase start_epoch stop_epoch record type_label timing
|
|
local -a fields
|
|
|
|
IFS=$'\t' read -r ignored phase start_epoch stop_epoch record <<<"$line"
|
|
record_fields "$record" fields
|
|
type_label="EMERGENCY"
|
|
[[ "${fields[8]}" == "true" ]] && type_label="PLANNED"
|
|
|
|
if [[ "$phase" == "ONGOING" ]]; then
|
|
timing="ends in $(human_duration "$((stop_epoch - now_epoch))")"
|
|
else
|
|
timing="starts in $(human_duration "$((start_epoch - now_epoch))")"
|
|
fi
|
|
|
|
printf '%s%s%-10s%s %s%-9s%s %s\n' \
|
|
"$BOLD" "$([[ "$phase" == "ONGOING" ]] && printf '%s' "$RED" || printf '%s' "$CYAN")" \
|
|
"$phase" "$RESET" "$([[ "$type_label" == "PLANNED" ]] && printf '%s' "$GREEN" || printf '%s' "$YELLOW")" \
|
|
"$type_label" "$RESET" "$timing"
|
|
printf ' Date %s %s - %s\n' "${fields[3]}" "${fields[4]}" "${fields[5]}"
|
|
printf ' Reference bill %s / outage %s\n' "${fields[0]}" "${fields[1]}"
|
|
}
|
|
|
|
render_empty() {
|
|
local columns="$1"
|
|
local width=$((columns < 64 ? columns : 64))
|
|
local rule
|
|
|
|
rule="$(repeat_character '=' "$width")"
|
|
printf '%s%s%s\n' "$GREEN" "$rule" "$RESET"
|
|
printf '\n'
|
|
center_line "ALL CLEAR" "$width"
|
|
center_line "No current or upcoming outages." "$width"
|
|
printf '\n'
|
|
printf '%s%s%s\n' "$GREEN" "$rule" "$RESET"
|
|
}
|
|
|
|
render_unavailable() {
|
|
local columns width rule
|
|
|
|
color_setup
|
|
columns="$(terminal_columns)"
|
|
width=$((columns < 64 ? columns : 64))
|
|
rule="$(repeat_character '=' "$width")"
|
|
printf '%s%s%s\n\n' "$RED" "$rule" "$RESET"
|
|
center_line "STATE UNAVAILABLE" "$width"
|
|
center_line "Waiting for the next refresh." "$width"
|
|
printf '\n%s%s%s\n' "$RED" "$rule" "$RESET"
|
|
}
|
|
|
|
start_terminal() {
|
|
if [[ -t 1 ]]; then
|
|
TERMINAL_ACTIVE=1
|
|
printf '\033[?1049h\033[?25l\033[H'
|
|
fi
|
|
}
|
|
|
|
draw_frame() {
|
|
local frame="$1"
|
|
local cleared_frame
|
|
|
|
if ((TERMINAL_ACTIVE)); then
|
|
cleared_frame="${frame//$'\n'/$'\033[K\n'}"
|
|
printf '\033[H%s\033[K\n\033[J' "$cleared_frame"
|
|
else
|
|
printf '%s\n' "$frame"
|
|
fi
|
|
}
|
|
|
|
schedule_next_transition() {
|
|
local now_epoch="$1"
|
|
local line ignored phase start_epoch stop_epoch record candidate=0
|
|
|
|
for line in "${SCHEDULE[@]}"; do
|
|
IFS=$'\t' read -r ignored phase start_epoch stop_epoch record <<<"$line"
|
|
if ((start_epoch > now_epoch)) && ((candidate == 0 || start_epoch < candidate)); then
|
|
candidate="$start_epoch"
|
|
fi
|
|
if ((stop_epoch > now_epoch)) && ((candidate == 0 || stop_epoch < candidate)); then
|
|
candidate="$stop_epoch"
|
|
fi
|
|
done
|
|
printf '%s\n' "$candidate"
|
|
}
|
|
|
|
render_dashboard() {
|
|
local now_epoch="$1"
|
|
local columns content_width rule phase start_epoch stop_epoch record remaining banner
|
|
local schedule_line ignored
|
|
|
|
color_setup
|
|
columns="$(terminal_columns)"
|
|
content_width=$((columns < 150 ? columns : 150))
|
|
|
|
if ((${#SCHEDULE[@]} == 0)); then
|
|
render_empty "$columns"
|
|
return
|
|
fi
|
|
|
|
IFS=$'\t' read -r phase start_epoch stop_epoch record <<<"$HERO"
|
|
if [[ "$phase" == "ONGOING" ]]; then
|
|
remaining=$((stop_epoch - now_epoch))
|
|
else
|
|
remaining=$((start_epoch - now_epoch))
|
|
fi
|
|
banner="$(banner_duration "$remaining")"
|
|
rule="$(repeat_character '=' "$columns")"
|
|
|
|
printf '%s%s%s\n' "$CYAN" "$rule" "$RESET"
|
|
printf '\n'
|
|
printf '%s%s' "$BOLD" "$YELLOW"
|
|
print_large_countdown "$banner" "$columns"
|
|
printf '%s' "$RESET"
|
|
printf '\n%s%s%s\n' "$CYAN" "$rule" "$RESET"
|
|
printf '\n%sOUTAGE SCHEDULE (%d)%s\n\n' "$BOLD" "${#SCHEDULE[@]}" "$RESET"
|
|
|
|
for schedule_line in "${SCHEDULE[@]}"; do
|
|
IFS=$'\t' read -r ignored phase start_epoch stop_epoch record <<<"$schedule_line"
|
|
render_event "$schedule_line" "$now_epoch"
|
|
printf '%s\n' "$DIM$(repeat_character '-' "$columns")$RESET"
|
|
done
|
|
}
|
|
|
|
main() {
|
|
local now_epoch next_refresh=0 refresh_minutes=10 refresh_seconds state_ready=0
|
|
local last_rendered=-1 next_transition=0 rebuild_schedule=0 frame key input_fd=0
|
|
|
|
while (($#)); do
|
|
case "$1" in
|
|
-h|--help)
|
|
usage
|
|
return 0
|
|
;;
|
|
-i|--interval)
|
|
if (($# < 2)); then
|
|
printf 'Error: %s requires a value\n' "$1" >&2
|
|
usage >&2
|
|
return 2
|
|
fi
|
|
refresh_minutes="$2"
|
|
shift 2
|
|
;;
|
|
--interval=*)
|
|
refresh_minutes="${1#*=}"
|
|
shift
|
|
;;
|
|
*)
|
|
printf 'Error: unknown option: %s\n' "$1" >&2
|
|
usage >&2
|
|
return 2
|
|
;;
|
|
esac
|
|
done
|
|
if [[ ! "$refresh_minutes" =~ ^[0-9]+$ ]] \
|
|
|| ((10#$refresh_minutes < 1 || 10#$refresh_minutes > 1440)); then
|
|
printf 'Error: interval must be a whole number from 1 to 1440 minutes\n' >&2
|
|
return 2
|
|
fi
|
|
refresh_seconds=$((10#$refresh_minutes * 60))
|
|
|
|
require_command curl
|
|
require_command jq
|
|
require_command date
|
|
|
|
STATE_PATH="$(mktemp "${TMPDIR:-/tmp}/blackout-state.XXXXXX")"
|
|
DOWNLOAD_PATH="$(mktemp "${TMPDIR:-/tmp}/blackout-download.XXXXXX")"
|
|
REFRESH_STATUS_PATH="$(mktemp "${TMPDIR:-/tmp}/blackout-refresh.XXXXXX")"
|
|
if [[ -r /dev/tty ]]; then
|
|
exec {input_fd}</dev/tty
|
|
fi
|
|
start_terminal
|
|
|
|
while true; do
|
|
if [[ -t "$input_fd" ]]; then
|
|
key=""
|
|
if read -u "$input_fd" -rsn 1 -t 0.1 key \
|
|
&& [[ "$key" == "q" || "$key" == "Q" ]]; then
|
|
break
|
|
fi
|
|
fi
|
|
|
|
printf -v now_epoch '%(%s)T' -1
|
|
if ((now_epoch == last_rendered)); then
|
|
if [[ ! -t "$input_fd" ]]; then
|
|
sleep 0.1
|
|
fi
|
|
continue
|
|
fi
|
|
last_rendered="$now_epoch"
|
|
|
|
if ((now_epoch >= next_refresh)) && [[ -z "$REFRESH_PID" ]]; then
|
|
start_state_refresh "$now_epoch"
|
|
next_refresh=$((now_epoch + refresh_seconds))
|
|
fi
|
|
if [[ -n "$REFRESH_PID" && -s "$REFRESH_STATUS_PATH" ]]; then
|
|
if finish_state_refresh; then
|
|
state_ready=1
|
|
rebuild_schedule=1
|
|
fi
|
|
fi
|
|
|
|
if ((state_ready)); then
|
|
if ((rebuild_schedule || (next_transition > 0 && now_epoch >= next_transition))); then
|
|
if build_schedule "$STATE_PATH" "$now_epoch"; then
|
|
next_transition="$(schedule_next_transition "$now_epoch")"
|
|
rebuild_schedule=0
|
|
else
|
|
state_ready=0
|
|
REFRESH_ERROR="The cached state contains an invalid date or time."
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
frame="$(
|
|
if ((state_ready)); then
|
|
render_dashboard "$now_epoch"
|
|
else
|
|
render_unavailable
|
|
fi
|
|
if [[ -n "$REFRESH_ERROR" ]]; then
|
|
printf '\n%s\n' "$REFRESH_ERROR"
|
|
fi
|
|
)"
|
|
draw_frame "$frame"
|
|
done
|
|
}
|
|
|
|
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
|
main "$@"
|
|
fi
|