echo chat at https://chatgpt.com/share/6aca616a-965c-83e8-b659-75af0320157b

cat > ~/bin/INSTALL-JUPITER <<'INSTALL'
#!/bin/bash

set -e

echo "========================================"
echo " INSTALL-JUPITER"
echo " Pikes Peak Jupiter pi-o'clock"
echo "========================================"

ROOT="/var/www/JUPITER-TRANSIT"
PY="$ROOT/capture.py"

# ------------------------------------------------------------
# 1. Make sure Pillow is present
# ------------------------------------------------------------

sudo apt-get update
sudo apt-get install -y python3-pil

# ------------------------------------------------------------
# 2. Create Jupiter directory
#    Do NOT remove old pictures, logs, TSV files, or .done files.
# ------------------------------------------------------------

sudo mkdir -p "$ROOT"
sudo chown -R "$(id -un):$(id -gn)" "$ROOT"

# ------------------------------------------------------------
# 3. Install capture.py
# ------------------------------------------------------------

cat > "$PY" <<'PY'
#!/usr/bin/python3

import io
import os
import csv
import urllib.request
import subprocess

from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from pathlib import Path

from PIL import Image, ImageFilter, ImageChops

from email.message import EmailMessage
from email.utils import make_msgid


# ============================================================
# PIKES PEAK JUPITER pi-O'CLOCK
#
# pi-o'clock is defined as Jupiter crossing approximately
# y = 314 pixels in the Pikes Peak EAST camera.
#
# Run once per minute.
#
# - downloads current east.jpg
# - locates Jupiter
# - logs x/y position in positions.tsv
# - captures one image per Mountain-time day near y=314
# - emails image inline
# - retains JPEG images for 90 days
# ============================================================


ROOT = Path("/var/www/JUPITER-TRANSIT")

CAMERA_URL = "https://websvc.coloradosprings.gov/ppcam/east.jpg"
CAMERA_NAME = "east.jpg"


# ------------------------------------------------------------
# Jupiter search region
# ------------------------------------------------------------

SEARCH_BOX = (
    820,    # left
    244,    # top
    1020,   # right
    384     # bottom
)


# ------------------------------------------------------------
# Display crop saved along with full image
# ------------------------------------------------------------

DISPLAY_BOX = (
    600,
    0,
    1240,
    640
)


# ------------------------------------------------------------
# Our arbitrary celestial clock:
#
#       Jupiter pi-o'clock = y approximately 314
#
# Jupiter moves toward smaller y values in this camera.
#
# We deliberately allow a broad region so a one-minute
# sample or slight centroid wobble cannot make us miss it.
# ------------------------------------------------------------

TARGET_Y = 314

CAPTURE_Y_HIGH = 320
CAPTURE_Y_LOW  = 285


# ------------------------------------------------------------
# Detection sensitivity
# ------------------------------------------------------------

PEAK_THRESHOLD = 35


# ------------------------------------------------------------
# Pikes Peak local civil time
# ------------------------------------------------------------

MOUNTAIN = ZoneInfo("America/Denver")


# ------------------------------------------------------------
# Email
# ------------------------------------------------------------

TO_ADDR = "JUPITER@lupton.us"
FROM_ADDR = "k1te@lupton.us"


# ------------------------------------------------------------
# Log
# ------------------------------------------------------------

TSV = ROOT / "positions.tsv"


def get_camera():

    req = urllib.request.Request(
        CAMERA_URL,
        headers={
            "User-Agent": "JUPITER-TRANSIT/2.0"
        }
    )

    with urllib.request.urlopen(req, timeout=20) as r:
        data = r.read()

    return Image.open(
        io.BytesIO(data)
    ).convert("RGB")


def find_jupiter(img):

    gray = img.convert("L")

    # Remove slowly varying sky/background brightness.
    smooth = gray.filter(
        ImageFilter.GaussianBlur(radius=8)
    )

    stars = ImageChops.subtract(
        gray,
        smooth
    )

    search = stars.crop(
        SEARCH_BOX
    )

    maximum = search.getextrema()[1]

    if maximum < PEAK_THRESHOLD:
        return None, None, maximum

    pix = search.load()

    width, height = search.size

    sx = 0.0
    sy = 0.0
    sw = 0.0

    # Use the brightest portion of the candidate object
    # and calculate a weighted centroid.
    cutoff = max(
        PEAK_THRESHOLD,
        maximum - 20
    )

    for y in range(height):
        for x in range(width):

            value = pix[x, y]

            if value >= cutoff:

                weight = value - cutoff + 1

                sx += x * weight
                sy += y * weight
                sw += weight

    if sw == 0:
        return None, None, maximum

    x = SEARCH_BOX[0] + sx / sw
    y = SEARCH_BOX[1] + sy / sw

    return (
        round(x, 1),
        round(y, 1),
        maximum
    )


def log_position(
    now_mtn,
    retrieved_utc,
    x,
    y,
    peak,
    status,
    crop_name="",
    full_name=""
):

    new_file = not TSV.exists()

    with open(
        TSV,
        "a",
        newline=""
    ) as f:

        writer = csv.writer(
            f,
            delimiter="\t"
        )

        if new_file:

            writer.writerow([
                "date",
                "time_Mountain",
                "camera",
                "x_px",
                "y_px",
                "peak",
                "status",
                "crop",
                "full",
                "retrieved_UTC"
            ])

        writer.writerow([
            now_mtn.strftime("%Y-%m-%d"),
            now_mtn.strftime("%H:%M:%S"),
            CAMERA_NAME,
            "" if x is None else x,
            "" if y is None else y,
            peak,
            status,
            crop_name,
            full_name,
            retrieved_utc.strftime(
                "%Y-%m-%dT%H:%M:%SZ"
            )
        ])


def email_jupiter(
    jpg_path,
    now_mtn,
    x,
    y
):

    # Rough MMDD-HHMM Jupiter time
    stamp = now_mtn.strftime(
        "%m%d-%H%M"
    )

    msg = EmailMessage()

    msg["From"] = FROM_ADDR
    msg["To"] = TO_ADDR

    msg["Subject"] = (
        f"JUPITER-PikesPeak-{stamp}"
    )

    msg.set_content(
        "Pikes Peak Jupiter pi-o'clock\n"
        "\n"
        f"{now_mtn.strftime('%Y-%m-%d %H:%M:%S %Z')}\n"
        f"x={x}  y={y}\n"
    )

    cid = make_msgid(
        domain="lupton.us"
    )

    cid_plain = cid[1:-1]

    msg.add_alternative(
        f"""
<html>
<body>

<p>
<b>Pikes Peak Jupiter pi-o'clock</b><br>
{now_mtn.strftime('%Y-%m-%d %H:%M:%S %Z')}<br>
x={x} &nbsp;&nbsp; y={y}
</p>

<img src="cid:{cid_plain}">

</body>
</html>
""",
        subtype="html"
    )

    with open(
        jpg_path,
        "rb"
    ) as f:

        picture = f.read()

    html_part = msg.get_payload()[1]

    html_part.add_related(
        picture,
        maintype="image",
        subtype="jpeg",
        cid=cid,
        disposition="inline",
        filename=jpg_path.name
    )

    subprocess.run(
        [
            "/usr/bin/msmtp",
            "-t"
        ],
        input=msg.as_bytes(),
        check=True
    )


def cleanup():

    now = datetime.now().timestamp()

    ninety_days = (
        90 * 86400
    )

    for p in ROOT.glob(
        "*.jpg"
    ):

        try:

            age = (
                now -
                p.stat().st_mtime
            )

            if age > ninety_days:
                p.unlink()

        except Exception:
            pass


def main():

    ROOT.mkdir(
        parents=True,
        exist_ok=True
    )

    retrieved_utc = (
        datetime.now(
            timezone.utc
        )
    )

    now_mtn = (
        retrieved_utc.astimezone(
            MOUNTAIN
        )
    )

    day = now_mtn.strftime(
        "%Y-%m-%d"
    )

    done_file = (
        ROOT /
        f"{day}.done"
    )


    # --------------------------------------------------------
    # Get camera image
    # --------------------------------------------------------

    try:

        img = get_camera()

    except Exception as e:

        print(
            f"CAMERA ERROR: {e}"
        )

        return


    # --------------------------------------------------------
    # Locate Jupiter
    # --------------------------------------------------------

    x, y, peak = (
        find_jupiter(img)
    )


    if x is None:

        log_position(
            now_mtn,
            retrieved_utc,
            None,
            None,
            peak,
            "NO-JUPITER"
        )

        print(
            f"{now_mtn} "
            f"no Jupiter candidate "
            f"peak={peak}"
        )

        cleanup()

        return


    print(
        f"{now_mtn.strftime('%Y-%m-%d %H:%M:%S %Z')} "
        f"Jupiter "
        f"x={x} "
        f"y={y} "
        f"peak={peak}"
    )


    # --------------------------------------------------------
    # Already caught today's pi-o'clock
    # --------------------------------------------------------

    if done_file.exists():

        log_position(
            now_mtn,
            retrieved_utc,
            x,
            y,
            peak,
            "DONE"
        )

        cleanup()

        return


    # --------------------------------------------------------
    # Has Jupiter reached/passed y=314 closely enough?
    #
    # Smaller y means Jupiter has moved farther upward.
    # --------------------------------------------------------

    if (
        CAPTURE_Y_LOW
        <= y
        <= CAPTURE_Y_HIGH
    ):

        stamp = now_mtn.strftime(
            "%Y-%m-%d_%H-%M-%S_%Z"
        )

        full_path = (
            ROOT /
            f"{stamp}_full.jpg"
        )

        crop_path = (
            ROOT /
            f"{stamp}_crop.jpg"
        )


        # Save entire Pikes Peak camera image.
        img.save(
            full_path,
            "JPEG",
            quality=94
        )


        # Save useful Jupiter/roofline crop.
        crop = img.crop(
            DISPLAY_BOX
        )

        crop.save(
            crop_path,
            "JPEG",
            quality=94
        )


        # ----------------------------------------------------
        # Email it BEFORE marking today done.
        #
        # Therefore an email failure does not permanently
        # suppress another attempt.
        # ----------------------------------------------------

        try:

            email_jupiter(
                full_path,
                now_mtn,
                x,
                y
            )

        except Exception as e:

            log_position(
                now_mtn,
                retrieved_utc,
                x,
                y,
                peak,
                "EMAIL-ERROR",
                crop_path.name,
                full_path.name
            )

            print(
                f"EMAIL ERROR: {e}"
            )

            return


        # ----------------------------------------------------
        # Successful daily pi-o'clock
        # ----------------------------------------------------

        log_position(
            now_mtn,
            retrieved_utc,
            x,
            y,
            peak,
            "CAPTURED-EMAILED",
            crop_path.name,
            full_path.name
        )

        done_file.write_text(
            f"{now_mtn.isoformat()}"
            f"\tx={x}"
            f"\ty={y}\n"
        )

        print(
            "CAPTURED + EMAILED: "
            f"{full_path.name} "
            f"x={x} "
            f"y={y}"
        )


    else:

        log_position(
            now_mtn,
            retrieved_utc,
            x,
            y,
            peak,
            "TRACKING"
        )


    cleanup()


if __name__ == "__main__":
    main()
PY


chmod +x "$PY"


# ------------------------------------------------------------
# 4. Install exactly ONE cron entry
# ------------------------------------------------------------

TMP="$(mktemp)"

crontab -l 2>/dev/null \
    | grep -v '# JUPITER-TRANSIT' \
    > "$TMP" || true

echo '* * * * * /usr/bin/python3 /var/www/JUPITER-TRANSIT/capture.py >> /var/www/JUPITER-TRANSIT/capture.log 2>&1 # JUPITER-TRANSIT' \
    >> "$TMP"

crontab "$TMP"

rm -f "$TMP"


# ------------------------------------------------------------
# 5. Show what was installed
# ------------------------------------------------------------

echo
echo "========================================"
echo " INSTALL COMPLETE"
echo "========================================"

echo
echo "Program:"
ls -lh "$PY"

echo
echo "Cron:"
crontab -l | grep JUPITER-TRANSIT

echo
echo "Directory:"
ls -ld "$ROOT"


# ------------------------------------------------------------
# 6. Run once immediately
# ------------------------------------------------------------

echo
echo "========================================"
echo " FIRST TEST RUN"
echo "========================================"

/usr/bin/python3 "$PY" || true


# ------------------------------------------------------------
# 7. Show newest TSV information
# ------------------------------------------------------------

echo
echo "========================================"
echo " LATEST JUPITER CLOCK READINGS"
echo "========================================"

if [ -f "$ROOT/positions.tsv" ]; then
    tail -10 "$ROOT/positions.tsv" | column -t -s $'\t'
else
    echo "No positions.tsv yet."
fi


echo
echo "========================================"
echo " Useful commands"
echo "========================================"

echo
echo "Watch:"
echo "  tail -f $ROOT/capture.log"

echo
echo "Clock table:"
echo "  tail -30 $ROOT/positions.tsv | column -t -s \$'\\t'"

echo
echo "Pictures:"
echo "  ls -lht $ROOT/*.jpg 2>/dev/null | head"

echo
echo "DONE."
echo
echo "Jupiter pi-o'clock:"
echo "  Jupiter crossing approximately y=314 pixels"
echo "  in the Pikes Peak east camera."
echo

INSTALL

chmod +x ~/bin/INSTALL-JUPITER

~/bin/INSTALL-JUPITER
