#!/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:-}" local terminal_size="" detected_columns="" if [[ -z "$columns" ]] && command -v stty >/dev/null 2>&1; then if [[ -r /dev/tty ]]; then terminal_size="$(stty size 2>/dev/null /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 rule rule="$(repeat_character '=' "$columns")" printf '%s%s%s\n' "$GREEN" "$rule" "$RESET" printf '\n' center_line "ALL CLEAR" "$columns" center_line "No current or upcoming outages." "$columns" printf '\n' printf '%s%s%s\n' "$GREEN" "$rule" "$RESET" } render_unavailable() { local columns="${1:-}" local rule color_setup if [[ -z "$columns" ]]; then columns="$(terminal_columns)" fi rule="$(repeat_character '=' "$columns")" printf '%s%s%s\n\n' "$RED" "$rule" "$RESET" center_line "STATE UNAVAILABLE" "$columns" center_line "Waiting for the next refresh." "$columns" 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="${2:-}" local rule phase start_epoch stop_epoch record remaining banner local schedule_line ignored color_setup if [[ -z "$columns" ]]; then columns="$(terminal_columns)" fi 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 local display_columns 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}= 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 display_columns="$(terminal_columns)" frame="$( if ((state_ready)); then render_dashboard "$now_epoch" "$display_columns" else render_unavailable "$display_columns" 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