Last active
January 14, 2026 08:01
-
-
Save felipealfonsog/5f6e3786daa5aabd34b5ebf620fd5528 to your computer and use it in GitHub Desktop.
TimeOps / WatchOps — atomic time drift analytics
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| nano watchops-v2.sh | |
| chmod +x watchops-v2.sh | |
| ./watchops-v2.sh menu | |
| sudo pacman -S libnewt dialog | |
| brew install newt dialog | |
| ./watchops-v2.sh add-watch "LivingRoom-Japan" quartz 5 when_exceeds "Reloj grande living" | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| APP="WatchOps" | |
| VERSION="2.0" | |
| DB="${WATCHOPS_DB:-$HOME/.watchops_measurements.csv}" | |
| WATCHES="${WATCHOPS_WATCHES:-$HOME/.watchops_watches.csv}" | |
| # ----------------------------- | |
| # UI detection | |
| # ----------------------------- | |
| HAS_WHIPTAIL=0 | |
| HAS_DIALOG=0 | |
| command -v whiptail >/dev/null 2>&1 && HAS_WHIPTAIL=1 | |
| command -v dialog >/dev/null 2>&1 && HAS_DIALOG=1 | |
| is_macos() { [[ "$(uname -s)" == "Darwin" ]]; } | |
| epoch_now() { date +%s; } | |
| fmt_date() { | |
| local epoch="$1" | |
| if is_macos; then date -r "$epoch" "+%Y-%m-%d %H:%M:%S" | |
| else date -d "@$epoch" "+%Y-%m-%d %H:%M:%S" | |
| fi | |
| } | |
| # ----------------------------- | |
| # Notifications | |
| # ----------------------------- | |
| notify() { | |
| local title="$1" | |
| local body="$2" | |
| if is_macos && command -v osascript >/dev/null 2>&1; then | |
| osascript -e "display notification \"${body//\"/\\\"}\" with title \"${title//\"/\\\"}\"" >/dev/null 2>&1 || true | |
| return 0 | |
| fi | |
| if command -v notify-send >/dev/null 2>&1; then | |
| notify-send "$title" "$body" >/dev/null 2>&1 || true | |
| return 0 | |
| fi | |
| # no notifier available (silent) | |
| return 0 | |
| } | |
| # ----------------------------- | |
| # Init files | |
| # ----------------------------- | |
| init_files() { | |
| if [[ ! -f "$DB" ]]; then | |
| echo "epoch,iso,watch,offset_s,source,notes" > "$DB" | |
| fi | |
| if [[ ! -f "$WATCHES" ]]; then | |
| echo "watch,type,tol_s,policy,notes" > "$WATCHES" | |
| echo "Chronobike-Daily,quartz,5,when_exceeds,Daily Festina Chronobike" >> "$WATCHES" | |
| echo "Chronobike-Special,quartz,10,scheduled,Special Festina Chronobike" >> "$WATCHES" | |
| echo "Ruhla,automatic,30,manual,Vintage automatic (keepsake)" >> "$WATCHES" | |
| fi | |
| } | |
| # ----------------------------- | |
| # DB write | |
| # ----------------------------- | |
| add_measurement() { | |
| local watch="$1" | |
| local offset="$2" | |
| local source="$3" | |
| local notes="${4:-}" | |
| local epoch iso | |
| epoch="$(epoch_now)" | |
| iso="$(fmt_date "$epoch")" | |
| printf "%s,%s,%s,%s,%s,%s\n" \ | |
| "$epoch" "$iso" "$watch" "$offset" "$source" "${notes//,/;}" >> "$DB" | |
| } | |
| # ----------------------------- | |
| # Watches config | |
| # ----------------------------- | |
| watch_exists() { | |
| local watch="$1" | |
| awk -F',' -v w="$watch" 'NR>1 && $1==w {found=1} END{exit(found?0:1)}' "$WATCHES" | |
| } | |
| add_watch() { | |
| local watch="$1" | |
| local type="$2" # quartz|automatic | |
| local tol="$3" # seconds | |
| local policy="$4" # when_exceeds|scheduled|manual | |
| local notes="${5:-}" | |
| if watch_exists "$watch"; then | |
| echo "Watch already exists: $watch" | |
| return 1 | |
| fi | |
| printf "%s,%s,%s,%s,%s\n" "$watch" "$type" "$tol" "$policy" "${notes//,/;}" >> "$WATCHES" | |
| } | |
| list_watches() { | |
| awk -F',' 'NR>1 {print $1}' "$WATCHES" | |
| } | |
| # ----------------------------- | |
| # Reference clock report (NTP offsets) | |
| # ----------------------------- | |
| ntp_offset_seconds() { | |
| local host="$1" | |
| if command -v chronyc >/dev/null 2>&1; then | |
| chronyc tracking 2>/dev/null | awk -F': ' ' | |
| /System time/ { | |
| gsub(" seconds fast of NTP time","",$2); | |
| gsub(" seconds slow of NTP time","",$2); | |
| print $2; exit | |
| }' || true | |
| return 0 | |
| fi | |
| if is_macos && command -v sntp >/dev/null 2>&1; then | |
| sntp -sS "$host" 2>/dev/null | awk ' | |
| /offset/ { | |
| for (i=1;i<=NF;i++) if ($i ~ /^[-+0-9.]+$/) {print $i; exit} | |
| }' || true | |
| return 0 | |
| fi | |
| if command -v ntpdate >/dev/null 2>&1; then | |
| ntpdate -q "$host" 2>/dev/null | awk ' | |
| /offset/ {for (i=1;i<=NF;i++) if ($i=="offset") {print $(i+1); exit}}' || true | |
| return 0 | |
| fi | |
| echo "" | |
| } | |
| ref_report() { | |
| local now local_iso | |
| now="$(epoch_now)" | |
| local_iso="$(fmt_date "$now")" | |
| local nist apple pool | |
| nist="$(ntp_offset_seconds time.nist.gov || true)" | |
| apple="$(ntp_offset_seconds time.apple.com || true)" | |
| pool="$(ntp_offset_seconds pool.ntp.org || true)" | |
| cat <<EOF | |
| [$APP $VERSION] Reference Clock Report | |
| Local epoch: $now | |
| Local time : $local_iso | |
| System vs NTP offsets: | |
| - NIST (time.nist.gov): ${nist:-N/A} | |
| - Apple (time.apple.com): ${apple:-N/A} | |
| - Pool (pool.ntp.org) : ${pool:-N/A} | |
| Note: time.is is a visual/manual reference here (no stable public API). | |
| EOF | |
| } | |
| # ----------------------------- | |
| # Analytics: regression drift + predictions | |
| # ----------------------------- | |
| analyze_watch_raw() { | |
| local watch="$1" | |
| awk -F',' -v w="$watch" ' | |
| function abs(x){return x<0?-x:x} | |
| NR>1 && $3==w { | |
| t = $1/86400.0 | |
| o = $4+0.0 | |
| sum_t += t | |
| sum_o += o | |
| sum_tt += (t*t) | |
| sum_to += (t*o) | |
| if (n==0) {epoch0=$1; o0=o} | |
| epoch1=$1; o1=o | |
| n++ | |
| } | |
| END{ | |
| if (n < 2) { print "ERR|Need>=2"; exit 0 } | |
| denom = (n*sum_tt - sum_t*sum_t) | |
| if (denom == 0) { print "ERR|Degenerate"; exit 0 } | |
| b = (n*sum_to - sum_t*sum_o) / denom # drift s/day | |
| a = (sum_o - b*sum_t)/n | |
| # last known | |
| drift=b; cur=o1; last=epoch1; first=epoch0; firsto=o0 | |
| printf("OK|samples=%d|first=%d|firsto=%+.3f|last=%d|lasto=%+.3f|drift=%+.6f\n", | |
| n, first, firsto, last, cur, drift) | |
| # thresholds based on current offset and drift (approx) | |
| ad = abs(drift) | |
| ac = abs(cur) | |
| if (ad < 0.00001) { | |
| print "THR|5|days=INF|epoch=0" | |
| print "THR|10|days=INF|epoch=0" | |
| print "THR|30|days=INF|epoch=0" | |
| print "THR|60|days=INF|epoch=0" | |
| exit 0 | |
| } | |
| thr[1]=5; thr[2]=10; thr[3]=30; thr[4]=60 | |
| for (i=1;i<=4;i++) { | |
| d = (thr[i]-ac)/ad | |
| if (d < 0) d = 0 | |
| hit = last + d*86400.0 | |
| printf("THR|%d|days=%.3f|epoch=%d\n", thr[i], d, int(hit)) | |
| } | |
| } | |
| ' "$DB" | |
| } | |
| # ----------------------------- | |
| # Dashboard (table) | |
| # ----------------------------- | |
| dashboard() { | |
| # Prints a table for all watches in WATCHES. | |
| # Columns: Watch, Type, Tol, Policy, LastOffset, Drift, HitTol, LastSync | |
| local now | |
| now="$(epoch_now)" | |
| printf "%-18s %-9s %-6s %-12s %-10s %-12s %-19s %-19s\n" \ | |
| "WATCH" "TYPE" "TOL" "POLICY" "OFFSET" "DRIFT(s/d)" "HIT|TOL|" "LAST_SYNC" | |
| printf "%-18s %-9s %-6s %-12s %-10s %-12s %-19s %-19s\n" \ | |
| "------------------" "---------" "------" "------------" "----------" "------------" "-------------------" "-------------------" | |
| awk -F',' ' | |
| NR>1 {print $1 "," $2 "," $3 "," $4} | |
| ' "$WATCHES" | while IFS=',' read -r w type tol policy; do | |
| # default values | |
| local offset="N/A" drift="N/A" hit="N/A" lastsync="N/A" samples="0" | |
| local hit_epoch="0" | |
| local raw | |
| raw="$(analyze_watch_raw "$w" 2>/dev/null || true)" | |
| if echo "$raw" | grep -q '^OK|'; then | |
| samples="$(echo "$raw" | awk -F'|' 'NR==1{for(i=1;i<=NF;i++) if($i~"^samples="){sub("samples=","",$i); print $i}}')" | |
| lastsync="$(echo "$raw" | awk -F'|' 'NR==1{for(i=1;i<=NF;i++) if($i~"^last="){sub("last=","",$i); print $i}}')" | |
| offset="$(echo "$raw" | awk -F'|' 'NR==1{for(i=1;i<=NF;i++) if($i~"^lasto="){sub("lasto=","",$i); print $i}}')" | |
| drift="$(echo "$raw" | awk -F'|' 'NR==1{for(i=1;i<=NF;i++) if($i~"^drift="){sub("drift=","",$i); print $i}}')" | |
| # determine time to hit tol threshold (abs(offset) -> tol) | |
| hit_epoch="$(echo "$raw" | awk -F'|' -v T="$tol" ' | |
| /^THR\|/ { | |
| thr=$2 | |
| for(i=1;i<=NF;i++){ | |
| if($i~"^epoch="){sub("epoch=","",$i); e=$i} | |
| if($i~"^days="){sub("days=","",$i); d=$i} | |
| } | |
| if(thr==T){ print e "|" d; exit } | |
| }')" | |
| if [[ -n "$hit_epoch" ]]; then | |
| local e d | |
| e="${hit_epoch%%|*}" | |
| d="${hit_epoch##*|}" | |
| if [[ "$e" == "0" || "$d" == "INF" ]]; then | |
| hit="stable" | |
| else | |
| hit="$(fmt_date "$e")" | |
| fi | |
| else | |
| hit="(no data)" | |
| fi | |
| lastsync="$(fmt_date "$lastsync")" | |
| else | |
| samples="0" | |
| lastsync="(no data)" | |
| fi | |
| printf "%-18s %-9s %-6s %-12s %-10s %-12s %-19s %-19s\n" \ | |
| "$w" "$type" "${tol}s" "$policy" "$offset" "$drift" "$hit" "$lastsync" | |
| done | |
| } | |
| # ----------------------------- | |
| # Due checks + notify | |
| # ----------------------------- | |
| due_check() { | |
| # For each watch, evaluate if abs(offset) >= tol or within horizon days. | |
| # Horizon default 3 days (can override WATCHOPS_HORIZON_DAYS) | |
| local horizon="${WATCHOPS_HORIZON_DAYS:-3}" | |
| local now | |
| now="$(epoch_now)" | |
| local any=0 | |
| while IFS=',' read -r w type tol policy notes; do | |
| [[ "$w" == "watch" ]] && continue | |
| local raw | |
| raw="$(analyze_watch_raw "$w" 2>/dev/null || true)" | |
| if ! echo "$raw" | grep -q '^OK|'; then | |
| continue | |
| fi | |
| local offset drift last_epoch | |
| offset="$(echo "$raw" | awk -F'|' 'NR==1{for(i=1;i<=NF;i++) if($i~"^lasto="){sub("lasto=","",$i); print $i}}')" | |
| drift="$(echo "$raw" | awk -F'|' 'NR==1{for(i=1;i<=NF;i++) if($i~"^drift="){sub("drift=","",$i); print $i}}')" | |
| last_epoch="$(echo "$raw" | awk -F'|' 'NR==1{for(i=1;i<=NF;i++) if($i~"^last="){sub("last=","",$i); print $i}}')" | |
| # abs(offset) >= tol ? | |
| local exceed | |
| exceed="$(awk -v o="$offset" -v t="$tol" 'BEGIN{if((o<0?-o:o) >= t) print 1; else print 0}')" | |
| if [[ "$exceed" == "1" && "$policy" != "manual" ]]; then | |
| any=1 | |
| echo "DUE: $w offset=$offset tol=${tol}s policy=$policy" | |
| notify "$APP: Sync due" "$w offset $offset s (tol ${tol}s)" | |
| continue | |
| fi | |
| # Also warn if predicted within horizon days | |
| # Find THR|tol | |
| local d | |
| d="$(echo "$raw" | awk -F'|' -v T="$tol" ' | |
| /^THR\|/ { | |
| thr=$2 | |
| for(i=1;i<=NF;i++){ | |
| if($i~"^days="){sub("days=","",$i); days=$i} | |
| } | |
| if(thr==T){ print days; exit } | |
| }')" | |
| if [[ -n "$d" && "$d" != "INF" ]]; then | |
| local soon | |
| soon="$(awk -v days="$d" -v h="$horizon" 'BEGIN{if(days <= h) print 1; else print 0}')" | |
| if [[ "$soon" == "1" && "$policy" != "manual" ]]; then | |
| any=1 | |
| echo "SOON: $w reaches |tol| in ~${d} days" | |
| notify "$APP: Sync soon" "$w hits tol in ~${d} days" | |
| fi | |
| fi | |
| done < "$WATCHES" | |
| if [[ "$any" == "0" ]]; then | |
| echo "All good. No watches due/soon within ${horizon} days." | |
| fi | |
| } | |
| # ----------------------------- | |
| # Export markdown | |
| # ----------------------------- | |
| export_md() { | |
| local out="${1:-$HOME/watchops_report.md}" | |
| { | |
| echo "# WatchOps Report" | |
| echo | |
| echo "- Generated: $(fmt_date "$(epoch_now)")" | |
| echo "- DB: \`$DB\`" | |
| echo "- Watches: \`$WATCHES\`" | |
| echo | |
| echo "## Dashboard" | |
| echo | |
| echo '```' | |
| dashboard | |
| echo '```' | |
| echo | |
| echo "## Reference Clock" | |
| echo | |
| echo '```' | |
| ref_report | |
| echo '```' | |
| } > "$out" | |
| echo "Wrote: $out" | |
| } | |
| # ----------------------------- | |
| # Minimal TUI helpers | |
| # ----------------------------- | |
| ui_msg() { | |
| local msg="$1" | |
| if [[ $HAS_WHIPTAIL -eq 1 ]]; then | |
| whiptail --title "$APP" --msgbox "$msg" 20 86 | |
| elif [[ $HAS_DIALOG -eq 1 ]]; then | |
| dialog --title "$APP" --msgbox "$msg" 20 86 | |
| clear | |
| else | |
| echo "$msg" | |
| echo | |
| read -r -p "Enter..." | |
| fi | |
| } | |
| ui_input() { | |
| local title="$1" prompt="$2" def="${3:-}" | |
| local v="" | |
| if [[ $HAS_WHIPTAIL -eq 1 ]]; then | |
| v="$(whiptail --title "$title" --inputbox "$prompt" 12 86 "$def" 3>&1 1>&2 2>&3 || true)" | |
| elif [[ $HAS_DIALOG -eq 1 ]]; then | |
| v="$(dialog --title "$title" --inputbox "$prompt" 12 86 "$def" 3>&1 1>&2 2>&3 || true)" | |
| clear | |
| else | |
| read -r -p "$prompt [$def]: " v | |
| v="${v:-$def}" | |
| fi | |
| echo "$v" | |
| } | |
| ui_menu() { | |
| while true; do | |
| local choice="" | |
| if [[ $HAS_WHIPTAIL -eq 1 ]]; then | |
| choice="$(whiptail --title "$APP v$VERSION" --menu "Time Control Center" 20 86 10 \ | |
| "1" "Dashboard (multi-watch)" \ | |
| "2" "Add measurement" \ | |
| "3" "Add watch/profile" \ | |
| "4" "Due check + notify" \ | |
| "5" "Reference clock report" \ | |
| "6" "Export Markdown report" \ | |
| "0" "Exit" 3>&1 1>&2 2>&3 || true)" | |
| elif [[ $HAS_DIALOG -eq 1 ]]; then | |
| choice="$(dialog --clear --title "$APP v$VERSION" --menu "Time Control Center" 20 86 10 \ | |
| "1" "Dashboard (multi-watch)" \ | |
| "2" "Add measurement" \ | |
| "3" "Add watch/profile" \ | |
| "4" "Due check + notify" \ | |
| "5" "Reference clock report" \ | |
| "6" "Export Markdown report" \ | |
| "0" "Exit" 3>&1 1>&2 2>&3 || true)" | |
| clear | |
| else | |
| echo "===== $APP v$VERSION =====" | |
| echo "1) Dashboard" | |
| echo "2) Add measurement" | |
| echo "3) Add watch/profile" | |
| echo "4) Due check + notify" | |
| echo "5) Reference clock report" | |
| echo "6) Export Markdown report" | |
| echo "0) Exit" | |
| read -r -p "> " choice | |
| fi | |
| case "${choice:-}" in | |
| 1) ui_msg "$(dashboard)" ;; | |
| 2) ui_add_measurement ;; | |
| 3) ui_add_watch ;; | |
| 4) ui_msg "$(due_check)" ;; | |
| 5) ui_msg "$(ref_report)" ;; | |
| 6) | |
| local out | |
| out="$(ui_input "$APP" "Output path for Markdown report" "$HOME/watchops_report.md")" | |
| local res | |
| res="$(export_md "$out")" | |
| ui_msg "$res" | |
| ;; | |
| 0|"") break ;; | |
| *) ui_msg "Invalid option." ;; | |
| esac | |
| done | |
| } | |
| ui_add_measurement() { | |
| local w offset source notes | |
| w="$(ui_input "$APP" "Watch name (must exist in watches CSV)" "Chronobike-Daily")" | |
| if ! watch_exists "$w"; then | |
| ui_msg "Watch '$w' not found in $WATCHES.\nAdd it first (menu: Add watch/profile)." | |
| return | |
| fi | |
| offset="$(ui_input "$APP" "Offset in seconds. Example: +2.3 or -1.0" "+0.0")" | |
| # numeric check | |
| if ! awk "BEGIN{exit($offset==($offset+0)?0:1)}" 2>/dev/null; then | |
| ui_msg "Invalid offset value." | |
| return | |
| fi | |
| source="$(ui_input "$APP" "Source label (time.is / NIST / AppleNTP / PoolNTP / Manual)" "time.is")" | |
| notes="$(ui_input "$APP" "Notes (optional)" "")" | |
| add_measurement "$w" "$offset" "$source" "$notes" | |
| ui_msg "Saved.\nWatch: $w\nOffset: ${offset}s\nSource: $source\nDB: $DB" | |
| } | |
| ui_add_watch() { | |
| local w type tol policy notes | |
| w="$(ui_input "$APP" "New watch name" "MyWatch")" | |
| type="$(ui_input "$APP" "Type (quartz|automatic)" "quartz")" | |
| tol="$(ui_input "$APP" "Tolerance seconds (e.g. 5, 10, 30)" "5")" | |
| policy="$(ui_input "$APP" "Policy (when_exceeds|scheduled|manual)" "when_exceeds")" | |
| notes="$(ui_input "$APP" "Notes (optional)" "")" | |
| if [[ "$type" != "quartz" && "$type" != "automatic" ]]; then | |
| ui_msg "Type must be quartz or automatic." | |
| return | |
| fi | |
| if ! awk "BEGIN{exit($tol==int($tol) && $tol>0?0:1)}" 2>/dev/null; then | |
| ui_msg "Tolerance must be a positive integer." | |
| return | |
| fi | |
| if add_watch "$w" "$type" "$tol" "$policy" "$notes" 2>/dev/null; then | |
| ui_msg "Added watch:\n$w ($type) tol=${tol}s policy=$policy\nFile: $WATCHES" | |
| else | |
| ui_msg "Could not add watch. Maybe it already exists." | |
| fi | |
| } | |
| # ----------------------------- | |
| # CLI | |
| # ----------------------------- | |
| usage() { | |
| cat <<EOF | |
| $APP $VERSION | |
| Files: | |
| Measurements DB: $DB | |
| Watches config : $WATCHES | |
| Usage: | |
| $0 menu | |
| $0 dashboard | |
| $0 add-measure <watch> <offset_s> <source> [notes] | |
| $0 add-watch <watch> <type> <tol_s> <policy> [notes] | |
| $0 due | |
| $0 ref | |
| $0 export-md [output.md] | |
| Env: | |
| WATCHOPS_DB, WATCHOPS_WATCHES, WATCHOPS_HORIZON_DAYS | |
| Examples: | |
| $0 menu | |
| $0 dashboard | |
| $0 add-measure Chronobike-Daily +2.3 time.is "living room sync" | |
| $0 due | |
| $0 export-md ~/watchops_report.md | |
| EOF | |
| } | |
| main() { | |
| init_files | |
| local cmd="${1:-menu}" | |
| case "$cmd" in | |
| menu) ui_menu ;; | |
| dashboard) dashboard ;; | |
| add-measure) | |
| [[ $# -ge 4 ]] || { usage; exit 1; } | |
| add_measurement "$2" "$3" "$4" "${5:-}" | |
| echo "OK" | |
| ;; | |
| add-watch) | |
| [[ $# -ge 5 ]] || { usage; exit 1; } | |
| add_watch "$2" "$3" "$4" "$5" "${6:-}" | |
| echo "OK" | |
| ;; | |
| due) due_check ;; | |
| ref) ref_report ;; | |
| export-md) export_md "${2:-$HOME/watchops_report.md}" ;; | |
| *) usage; exit 1 ;; | |
| esac | |
| } | |
| main "$@" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| # ============================================================ | |
| # watchops.sh — Watch Drift Operator Console (macOS + Linux) | |
| # Felipe edition: atomic time discipline | |
| # ============================================================ | |
| APP="WatchOps" | |
| VERSION="1.0" | |
| DB="${WATCHOPS_DB:-$HOME/.watchops_measurements.csv}" | |
| DEFAULT_WATCH="${WATCHOPS_WATCH:-Chronobike-Daily}" | |
| # UI helpers | |
| HAS_WHIPTAIL=0 | |
| HAS_DIALOG=0 | |
| command -v whiptail >/dev/null 2>&1 && HAS_WHIPTAIL=1 | |
| command -v dialog >/dev/null 2>&1 && HAS_DIALOG=1 | |
| # ------------------------------------------------------------ | |
| # Platform / date helpers | |
| # ------------------------------------------------------------ | |
| is_macos() { [[ "$(uname -s)" == "Darwin" ]]; } | |
| epoch_now() { date +%s; } | |
| fmt_date() { | |
| local epoch="$1" | |
| if is_macos; then | |
| date -r "$epoch" "+%Y-%m-%d %H:%M:%S" | |
| else | |
| date -d "@$epoch" "+%Y-%m-%d %H:%M:%S" | |
| fi | |
| } | |
| # ------------------------------------------------------------ | |
| # DB | |
| # ------------------------------------------------------------ | |
| init_db() { | |
| if [[ ! -f "$DB" ]]; then | |
| printf "epoch,iso,watch,offset_s,source,notes\n" > "$DB" | |
| fi | |
| } | |
| db_add() { | |
| local watch="$1" | |
| local offset="$2" | |
| local source="$3" | |
| local notes="${4:-}" | |
| local epoch iso | |
| epoch="$(epoch_now)" | |
| iso="$(fmt_date "$epoch")" | |
| printf "%s,%s,%s,%s,%s,%s\n" \ | |
| "$epoch" "$iso" "$watch" "$offset" "$source" "${notes//,/;}" >> "$DB" | |
| } | |
| db_count_watch() { | |
| local watch="$1" | |
| awk -F',' -v w="$watch" 'NR>1 && $3==w {c++} END{print c+0}' "$DB" | |
| } | |
| db_list_watches() { | |
| awk -F',' 'NR>1 {print $3}' "$DB" | sort -u | |
| } | |
| # ------------------------------------------------------------ | |
| # NTP time checks (reference comparisons) | |
| # ------------------------------------------------------------ | |
| ntp_offset_seconds() { | |
| # Returns offset in seconds as float-ish. | |
| # macOS: sntp is usually present. | |
| # Linux: try chronyc tracking (if available), else ntpdate -q, else sntp. | |
| local host="$1" | |
| if command -v chronyc >/dev/null 2>&1; then | |
| # Not host-specific, but still useful: local clock vs reference | |
| # We'll use it only if system is already configured | |
| chronyc tracking 2>/dev/null | awk -F': ' ' | |
| /System time/ {gsub(" seconds fast of NTP time","",$2); gsub(" seconds slow of NTP time","",$2); print $2; exit} | |
| ' || true | |
| return 0 | |
| fi | |
| if is_macos && command -v sntp >/dev/null 2>&1; then | |
| # sntp output example includes: "+/- offset" | |
| # We'll parse a line that contains "offset" | |
| sntp -sS "$host" 2>/dev/null | awk ' | |
| /offset/ { | |
| # try to capture a numeric token | |
| for (i=1;i<=NF;i++) if ($i ~ /^[-+0-9.]+$/) {print $i; exit} | |
| } | |
| ' || true | |
| return 0 | |
| fi | |
| if command -v ntpdate >/dev/null 2>&1; then | |
| ntpdate -q "$host" 2>/dev/null | awk ' | |
| /offset/ { | |
| for (i=1;i<=NF;i++) if ($i=="offset") {print $(i+1); exit} | |
| } | |
| ' || true | |
| return 0 | |
| fi | |
| # fallback: no tool | |
| echo "" | |
| } | |
| show_reference_clock_report() { | |
| local now local_iso | |
| now="$(epoch_now)" | |
| local_iso="$(fmt_date "$now")" | |
| local nist apple pool | |
| nist="$(ntp_offset_seconds time.nist.gov || true)" | |
| apple="$(ntp_offset_seconds time.apple.com || true)" | |
| pool="$(ntp_offset_seconds pool.ntp.org || true)" | |
| cat <<EOF | |
| [$APP $VERSION] Reference Clock Report | |
| Local epoch: $now | |
| Local time : $local_iso | |
| NTP offsets (system vs reference): | |
| - NIST (time.nist.gov): ${nist:-N/A} | |
| - Apple (time.apple.com): ${apple:-N/A} | |
| - Pool (pool.ntp.org) : ${pool:-N/A} | |
| Note: | |
| - This does NOT read time.is (no stable public API). | |
| - Use this report to validate your device time chain. | |
| EOF | |
| } | |
| # ------------------------------------------------------------ | |
| # Analytics | |
| # ------------------------------------------------------------ | |
| analyze_watch() { | |
| local watch="$1" | |
| local n | |
| n="$(db_count_watch "$watch")" | |
| if [[ "$n" -lt 2 ]]; then | |
| echo "Need >= 2 measurements for '$watch'. Current: $n" | |
| return 0 | |
| fi | |
| # We will run linear regression: | |
| # offset = a + b * tdays | |
| # b = drift (s/day) | |
| # Also compute latest offset and predictions. | |
| awk -F',' -v w="$watch" ' | |
| function abs(x){return x<0?-x:x} | |
| NR>1 && $3==w { | |
| t = $1/86400.0 | |
| o = $4+0.0 | |
| sum_t += t | |
| sum_o += o | |
| sum_tt += (t*t) | |
| sum_to += (t*o) | |
| if (n==0) {t0=t; o0=o; epoch0=$1} | |
| t1=t; o1=o; epoch1=$1 | |
| n++ | |
| } | |
| END { | |
| if (n < 2) { print "Not enough data"; exit 0 } | |
| denom = (n*sum_tt - sum_t*sum_t) | |
| if (denom == 0) { print "Degenerate dataset"; exit 0 } | |
| b = (n*sum_to - sum_t*sum_o) / denom # s/day | |
| a = (sum_o - b*sum_t) / n | |
| # last known | |
| drift = b | |
| cur = o1 | |
| epoch_last = epoch1 | |
| printf("Watch: %s\n", w) | |
| printf("Samples: %d\n", n) | |
| printf("First: t=%s offset=%+.3fs\n", epoch0, o0) | |
| printf("Last: t=%s offset=%+.3fs\n", epoch_last, cur) | |
| printf("Estimated drift: %+0.6f s/day\n", drift) | |
| # Predictions for thresholds | |
| # Solve for |a + b*t| = thr near future relative to last point: | |
| # We use current offset + drift*D as approximation. | |
| if (abs(drift) < 0.00001) { | |
| print "Drift ~ 0 => stable. Threshold predictions meaningless." | |
| exit 0 | |
| } | |
| ad = abs(drift) | |
| ac = abs(cur) | |
| thr[1]=5 | |
| thr[2]=10 | |
| thr[3]=30 | |
| thr[4]=60 | |
| for (i=1;i<=4;i++) { | |
| d = (thr[i] - ac)/ad | |
| if (d < 0) d = 0 | |
| hit_epoch = epoch_last + d*86400.0 | |
| printf("THR|%ds|DAYS=%.3f|EPOCH=%d\n", thr[i], d, int(hit_epoch)) | |
| } | |
| } | |
| ' "$DB" | |
| } | |
| pretty_analyze() { | |
| local watch="$1" | |
| local out line | |
| out="$(analyze_watch "$watch")" | |
| echo "$out" | while IFS= read -r line; do | |
| if [[ "$line" =~ ^THR\|([0-9]+)s\|DAYS=([0-9.]+)\|EPOCH=([0-9]+)$ ]]; then | |
| local thr days epoch hit | |
| thr="${BASH_REMATCH[1]}" | |
| days="${BASH_REMATCH[2]}" | |
| epoch="${BASH_REMATCH[3]}" | |
| hit="$(fmt_date "$epoch")" | |
| printf "Predicted to reach |offset|=%ss in ~%s days -> %s\n" "$thr" "$days" "$hit" | |
| else | |
| echo "$line" | |
| fi | |
| done | |
| } | |
| # ------------------------------------------------------------ | |
| # TUI / menu | |
| # ------------------------------------------------------------ | |
| ui_menu() { | |
| local title="${APP} v${VERSION}" | |
| local watch="${1:-$DEFAULT_WATCH}" | |
| while true; do | |
| local choice | |
| if [[ $HAS_WHIPTAIL -eq 1 ]]; then | |
| choice="$(whiptail --title "$title" --menu "Watch: $watch" 20 78 10 \ | |
| "1" "Add measurement (manual offset)" \ | |
| "2" "Analyze drift + predictions" \ | |
| "3" "Show reference clock report (NIST/Apple/Pool)" \ | |
| "4" "Show last measurements" \ | |
| "5" "Switch watch (or create new)" \ | |
| "6" "Open DB location" \ | |
| "0" "Exit" \ | |
| 3>&1 1>&2 2>&3 || true)" | |
| elif [[ $HAS_DIALOG -eq 1 ]]; then | |
| choice="$(dialog --clear --title "$title" --menu "Watch: $watch" 20 78 10 \ | |
| "1" "Add measurement (manual offset)" \ | |
| "2" "Analyze drift + predictions" \ | |
| "3" "Show reference clock report (NIST/Apple/Pool)" \ | |
| "4" "Show last measurements" \ | |
| "5" "Switch watch (or create new)" \ | |
| "6" "Open DB location" \ | |
| "0" "Exit" \ | |
| 3>&1 1>&2 2>&3 || true)" | |
| clear | |
| else | |
| echo "===== $title =====" | |
| echo "Watch: $watch" | |
| echo "1) Add measurement" | |
| echo "2) Analyze drift + predictions" | |
| echo "3) Reference clock report" | |
| echo "4) Show last measurements" | |
| echo "5) Switch watch" | |
| echo "6) Open DB location" | |
| echo "0) Exit" | |
| read -r -p "> " choice | |
| fi | |
| case "${choice:-}" in | |
| 1) ui_add_measurement "$watch" ;; | |
| 2) ui_analyze "$watch" ;; | |
| 3) ui_ref_report ;; | |
| 4) ui_show_last "$watch" ;; | |
| 5) watch="$(ui_switch_watch "$watch")" ;; | |
| 6) ui_open_db ;; | |
| 0|"") break ;; | |
| *) ui_msg "Invalid option." ;; | |
| esac | |
| done | |
| } | |
| ui_msg() { | |
| local msg="$1" | |
| if [[ $HAS_WHIPTAIL -eq 1 ]]; then | |
| whiptail --title "$APP" --msgbox "$msg" 15 80 | |
| elif [[ $HAS_DIALOG -eq 1 ]]; then | |
| dialog --title "$APP" --msgbox "$msg" 15 80 | |
| clear | |
| else | |
| echo "$msg" | |
| echo | |
| read -r -p "Press Enter..." | |
| fi | |
| } | |
| ui_input() { | |
| local title="$1" | |
| local prompt="$2" | |
| local default="${3:-}" | |
| local value | |
| if [[ $HAS_WHIPTAIL -eq 1 ]]; then | |
| value="$(whiptail --title "$title" --inputbox "$prompt" 12 78 "$default" 3>&1 1>&2 2>&3 || true)" | |
| elif [[ $HAS_DIALOG -eq 1 ]]; then | |
| value="$(dialog --title "$title" --inputbox "$prompt" 12 78 "$default" 3>&1 1>&2 2>&3 || true)" | |
| clear | |
| else | |
| read -r -p "$prompt [$default]: " value | |
| value="${value:-$default}" | |
| fi | |
| echo "$value" | |
| } | |
| ui_add_measurement() { | |
| local watch="$1" | |
| local offset source notes | |
| offset="$(ui_input "$APP" "Enter offset (seconds). Example: +2.3 or -1.0" "+0.0")" | |
| source="$(ui_input "$APP" "Source label (time.is / NIST / AppleNTP / PoolNTP / Manual)" "time.is")" | |
| notes="$(ui_input "$APP" "Notes (optional)" "")" | |
| # basic validation for numeric | |
| if ! awk "BEGIN{exit($offset==($offset+0)?0:1)}" 2>/dev/null; then | |
| ui_msg "Invalid offset value. Must be numeric, like +2.3 or -1.0" | |
| return | |
| fi | |
| db_add "$watch" "$offset" "$source" "$notes" | |
| ui_msg "Saved measurement:\n\nWatch: $watch\nOffset: ${offset}s\nSource: $source\nDB: $DB" | |
| } | |
| ui_analyze() { | |
| local watch="$1" | |
| local report | |
| report="$(pretty_analyze "$watch")" | |
| ui_msg "$report" | |
| } | |
| ui_ref_report() { | |
| local report | |
| report="$(show_reference_clock_report)" | |
| ui_msg "$report" | |
| } | |
| ui_show_last() { | |
| local watch="$1" | |
| local report | |
| report="$(awk -F',' -v w="$watch" ' | |
| NR==1 {next} | |
| $3==w {print $0} | |
| ' "$DB" | tail -n 20)" | |
| if [[ -z "$report" ]]; then | |
| ui_msg "No measurements found for watch '$watch'." | |
| return | |
| fi | |
| ui_msg "Last measurements (up to 20):\n\n$report" | |
| } | |
| ui_switch_watch() { | |
| local current="$1" | |
| local list choice | |
| list="$(db_list_watches | sed '/^\s*$/d' | head -n 30)" | |
| if [[ -z "$list" ]]; then | |
| choice="$(ui_input "$APP" "No watches in DB yet. Enter watch name" "$current")" | |
| echo "$choice" | |
| return | |
| fi | |
| # If we have whiptail/dialog use selection list | |
| if [[ $HAS_WHIPTAIL -eq 1 ]]; then | |
| local opts=() | |
| while IFS= read -r w; do | |
| opts+=("$w" "") | |
| done <<< "$list" | |
| opts+=("NEW" "Create new watch name") | |
| choice="$(whiptail --title "$APP" --menu "Select watch" 20 78 12 \ | |
| "${opts[@]}" 3>&1 1>&2 2>&3 || true)" | |
| [[ "$choice" == "NEW" || -z "$choice" ]] && choice="$(ui_input "$APP" "Enter new watch name" "$current")" | |
| echo "$choice" | |
| return | |
| fi | |
| if [[ $HAS_DIALOG -eq 1 ]]; then | |
| local opts=() | |
| while IFS= read -r w; do | |
| opts+=("$w" "") | |
| done <<< "$list" | |
| opts+=("NEW" "Create new watch name") | |
| choice="$(dialog --clear --title "$APP" --menu "Select watch" 20 78 12 \ | |
| "${opts[@]}" 3>&1 1>&2 2>&3 || true)" | |
| clear | |
| [[ "$choice" == "NEW" || -z "$choice" ]] && choice="$(ui_input "$APP" "Enter new watch name" "$current")" | |
| echo "$choice" | |
| return | |
| fi | |
| choice="$(ui_input "$APP" "Enter watch name (existing or new). Existing watches:\n$list" "$current")" | |
| echo "$choice" | |
| } | |
| ui_open_db() { | |
| if is_macos; then | |
| ui_msg "DB path:\n$DB\n\n(macOS) To open folder:\nopen \"$(dirname "$DB")\"" | |
| else | |
| ui_msg "DB path:\n$DB\n\n(Linux) To open folder:\nxdg-open \"$(dirname "$DB")\"" | |
| fi | |
| } | |
| # ------------------------------------------------------------ | |
| # CLI mode | |
| # ------------------------------------------------------------ | |
| usage() { | |
| cat <<EOF | |
| $APP $VERSION | |
| Usage: | |
| $0 menu | |
| $0 add <WatchName> <offset_s> <source> [notes] | |
| $0 analyze <WatchName> | |
| $0 ref | |
| $0 last <WatchName> | |
| Environment: | |
| WATCHOPS_DB Path to CSV database (default: ~/.watchops_measurements.csv) | |
| WATCHOPS_WATCH Default watch name (default: Chronobike-Daily) | |
| Examples: | |
| $0 menu | |
| $0 add Chronobike-Daily +2.3 time.is "after work" | |
| $0 analyze Chronobike-Daily | |
| $0 ref | |
| EOF | |
| } | |
| main() { | |
| init_db | |
| local cmd="${1:-menu}" | |
| case "$cmd" in | |
| menu) | |
| ui_menu "$DEFAULT_WATCH" | |
| ;; | |
| add) | |
| [[ $# -ge 4 ]] || { usage; exit 1; } | |
| db_add "$2" "$3" "$4" "${5:-}" | |
| echo "OK: saved to $DB" | |
| ;; | |
| analyze) | |
| [[ $# -ge 2 ]] || { usage; exit 1; } | |
| pretty_analyze "$2" | |
| ;; | |
| ref) | |
| show_reference_clock_report | |
| ;; | |
| last) | |
| [[ $# -ge 2 ]] || { usage; exit 1; } | |
| awk -F',' -v w="$2" 'NR==1{next} $3==w {print $0}' "$DB" | tail -n 20 | |
| ;; | |
| *) | |
| usage | |
| exit 1 | |
| ;; | |
| esac | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment