cat > ~/audio/boilerAudio.py <<'PY'
#!/usr/bin/env python3

import subprocess
import math
import time
from array import array
from datetime import datetime
from urllib.request import urlopen
from urllib.parse import quote

RATE = 16000
DEVICE = "plughw:1,0"

LOG = "/home/bbl/boilerAudio.txt"
SERVER = "https://lupton.us/boilerAudio.php"

# Acoustic burner thresholds
ON_DB  = -38.0
OFF_DB = -44.0

# Require sustained evidence before changing state
ON_SECONDS  = 3
OFF_SECONDS = 5

# Burner-rumble frequency band
LOW_HZ  = 30.0
HIGH_HZ = 300.0


def now_iso():
    return datetime.now().astimezone().isoformat(timespec="seconds")


def alpha_for(fc):
    dt = 1.0 / RATE
    rc = 1.0 / (2.0 * math.pi * fc)
    return dt / (rc + dt)


a_hi = alpha_for(HIGH_HZ)
a_lo = alpha_for(LOW_HZ)

lp_hi = 0.0
lp_lo = 0.0


def band_db(samples):
    global lp_hi, lp_lo

    ss = 0.0
    n = 0

    for s in samples:
        x = float(s)

        lp_hi += a_hi * (x - lp_hi)
        lp_lo += a_lo * (x - lp_lo)

        y = lp_hi - lp_lo

        ss += y * y
        n += 1

    if n == 0:
        return -120.0

    rms = math.sqrt(ss / n)

    if rms < 1.0:
        return -120.0

    return 20.0 * math.log10(rms / 32768.0)


def upload_line(line):
    """
    Send the exact event line to lupton.us.
    Failure is reported but NEVER stops local logging.
    """

    try:
        encoded = quote(line, safe=",:.-+")
        url = SERVER + "?" + encoded

        with urlopen(url, timeout=10) as r:
            reply = r.read(200).decode(
                "utf-8",
                errors="replace"
            ).strip()

        print(
            "*** SERVER OK",
            reply if reply else "(no reply)",
            flush=True
        )

    except Exception as e:
        print(
            "*** SERVER ERROR:",
            e,
            flush=True
        )


cmd = [
    "arecord",
    "-q",
    "-D", DEVICE,
    "-f", "S16_LE",
    "-r", str(RATE),
    "-c", "1",
    "-t", "raw"
]

print("boilerAudio starting")
print("device:", DEVICE)
print("band: %.0f-%.0f Hz" % (LOW_HZ, HIGH_HZ))
print("ON threshold :", ON_DB, "dBFS")
print("OFF threshold:", OFF_DB, "dBFS")
print("ON confirm   :", ON_SECONDS, "seconds")
print("OFF confirm  :", OFF_SECONDS, "seconds")
print("local log    :", LOG)
print("server       :", SERVER)
print()

p = subprocess.Popen(
    cmd,
    stdout=subprocess.PIPE
)

burning = False

above = 0
below = 0

burn_start_wall = None
burn_start_mono = None

previous_burn_end = None

BYTES_PER_SECOND = RATE * 2


try:

    while True:

        raw = p.stdout.read(BYTES_PER_SECOND)

        if not raw:
            print("arecord stopped")
            break

        samples = array('h')
        samples.frombytes(raw)

        db = band_db(samples)

        state = "BURN" if burning else "----"

        print(
            "%s  %6.1f dB  %s"
            % (
                now_iso(),
                db,
                state
            ),
            flush=True
        )

        now_mono = time.monotonic()

        #
        # Currently OFF:
        # look for sustained burner sound.
        #
        if not burning:

            if db >= ON_DB:
                above += 1
            else:
                above = 0

            if above >= ON_SECONDS:

                burning = True

                # Move estimated start back to first
                # second of the confirmed run.
                burn_start_mono = (
                    now_mono - (ON_SECONDS - 1)
                )

                burn_start_wall = (
                    datetime.now().astimezone()
                    - __import__("datetime").timedelta(
                        seconds=(ON_SECONDS - 1)
                    )
                )

                print(
                    "*** BURN START",
                    burn_start_wall.isoformat(
                        timespec="milliseconds"
                    ),
                    flush=True
                )

                below = 0
                above = 0


        #
        # Currently BURNING:
        # look for sustained quiet.
        #
        else:

            if db <= OFF_DB:
                below += 1
            else:
                below = 0

            if below >= OFF_SECONDS:

                # Estimate actual end as beginning
                # of the confirmed quiet interval.
                burn_end_mono = (
                    now_mono - (OFF_SECONDS - 1)
                )

                duration = (
                    burn_end_mono
                    - burn_start_mono
                )

                #
                # Time since end of previous burn
                # until beginning of this burn.
                #
                if previous_burn_end is None:
                    gap = -1

                else:

                    gap_real = (
                        burn_start_mono
                        - previous_burn_end
                    )

                    if gap_real > 600:
                        gap = -1
                    else:
                        gap = gap_real


                start_text = (
                    burn_start_wall.isoformat(
                        timespec="milliseconds"
                    )
                )

                gap_text = (
                    "-1"
                    if gap == -1
                    else "%.1f" % gap
                )

                line = (
                    "%s,%.1f,%s"
                    % (
                        start_text,
                        duration,
                        gap_text
                    )
                )


                #
                # LOCAL permanent record
                #
                with open(LOG, "a") as f:
                    f.write(line + "\n")


                print(
                    "*** BURN END "
                    "duration=%.1f gap=%s"
                    % (
                        duration,
                        gap_text
                    ),
                    flush=True
                )

                print(
                    "*** LOG:",
                    line,
                    flush=True
                )


                #
                # REMOTE permanent record
                #
                upload_line(line)


                previous_burn_end = (
                    burn_end_mono
                )

                burning = False
                below = 0
                above = 0


finally:

    try:
        p.terminate()
    except Exception:
        pass
PY

chmod +x ~/audio/boilerAudio.py

