378 lines
11 KiB
Bash
Executable file
378 lines
11 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# Subscribe to one MQTT message for FJR country and append a two-letter ISO country
|
|
# code to fjr_country.json only when it differs from the last JSON record.
|
|
#
|
|
# Cron example, every 5 minutes:
|
|
# */5 * * * * /home/hbrain/source/hasgd/bin/fjr_country_mqtt_logger.sh
|
|
#
|
|
# Optional environment variables:
|
|
# MQTT_HOST MQTT broker host, default: 192.168.0.203
|
|
# MQTT_PORT MQTT broker port, default: 1883
|
|
# MQTT_TOPIC MQTT topic, default: fjr/country_code
|
|
# MQTT_USER MQTT username, optional
|
|
# MQTT_PASSWORD MQTT password, optional
|
|
# MQTT_TIMEOUT Seconds to wait for one message, default: 30
|
|
# FJR_COUNTRY_JSON_DIR Output directory, default: repo root
|
|
# FJR_COUNTRY_JSON_FILENAME Output filename, default: fjr_country.json
|
|
# FJR_COUNTRY_JSON Full output JSON path; overrides DIR/FILENAME
|
|
|
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
|
|
|
MQTT_HOST="${MQTT_HOST:-192.168.0.203}"
|
|
MQTT_PORT="${MQTT_PORT:-1883}"
|
|
MQTT_TOPIC="${MQTT_TOPIC:-fjr/country_code}"
|
|
MQTT_TIMEOUT="${MQTT_TIMEOUT:-30}"
|
|
JSON_DIR="${FJR_COUNTRY_JSON_DIR:-${REPO_DIR}}"
|
|
JSON_FILENAME="${FJR_COUNTRY_JSON_FILENAME:-fjr_country.json}"
|
|
JSON_FILE="${FJR_COUNTRY_JSON:-${JSON_DIR%/}/${JSON_FILENAME}}"
|
|
|
|
command -v python3 >/dev/null 2>&1 || {
|
|
echo "ERROR: python3 not found." >&2
|
|
exit 1
|
|
}
|
|
|
|
mkdir -p -- "$(dirname -- "${JSON_FILE}")"
|
|
|
|
# Create the JSON file on first run.
|
|
if [[ ! -e "${JSON_FILE}" ]]; then
|
|
printf '[]\n' >"${JSON_FILE}"
|
|
fi
|
|
|
|
python3 - "${JSON_FILE}" <<'PY'
|
|
import json
|
|
import sys
|
|
|
|
path = sys.argv[1]
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
except Exception as exc:
|
|
print(f"ERROR: {path} is not valid JSON: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if not isinstance(data, list):
|
|
print(f"ERROR: {path} exists but is not a JSON array.", file=sys.stderr)
|
|
sys.exit(1)
|
|
PY
|
|
|
|
# Avoid concurrent cron runs modifying the file at the same time.
|
|
exec 9>"${JSON_FILE}.lock"
|
|
if ! flock -n 9; then
|
|
echo "Another fjr_country logger run is active; exiting."
|
|
exit 0
|
|
fi
|
|
|
|
payload="$(
|
|
MQTT_HOST="${MQTT_HOST}" \
|
|
MQTT_PORT="${MQTT_PORT}" \
|
|
MQTT_TOPIC="${MQTT_TOPIC}" \
|
|
MQTT_TIMEOUT="${MQTT_TIMEOUT}" \
|
|
MQTT_USER="${MQTT_USER:-}" \
|
|
MQTT_PASSWORD="${MQTT_PASSWORD:-}" \
|
|
python3 - <<'PY'
|
|
import os
|
|
import random
|
|
import socket
|
|
import sys
|
|
|
|
host = os.environ["MQTT_HOST"]
|
|
port = int(os.environ["MQTT_PORT"])
|
|
topic = os.environ["MQTT_TOPIC"]
|
|
timeout = float(os.environ["MQTT_TIMEOUT"])
|
|
username = os.environ.get("MQTT_USER") or None
|
|
password = os.environ.get("MQTT_PASSWORD") or None
|
|
client_id = f"fjr_country_logger_{random.randrange(65536):04x}"
|
|
|
|
|
|
def enc_len(value):
|
|
out = bytearray()
|
|
while True:
|
|
digit = value % 128
|
|
value //= 128
|
|
if value:
|
|
digit |= 128
|
|
out.append(digit)
|
|
if not value:
|
|
return bytes(out)
|
|
|
|
|
|
def field(value):
|
|
data = str(value).encode("utf-8")
|
|
return len(data).to_bytes(2, "big") + data
|
|
|
|
|
|
def read_remaining(sock):
|
|
value = 0
|
|
multiplier = 1
|
|
while True:
|
|
data = sock.recv(1)
|
|
if not data:
|
|
raise EOFError("connection closed")
|
|
digit = data[0]
|
|
value += (digit & 127) * multiplier
|
|
if not digit & 128:
|
|
return value
|
|
multiplier *= 128
|
|
|
|
|
|
def recv_packet(sock):
|
|
header = sock.recv(1)
|
|
if not header:
|
|
raise EOFError("connection closed")
|
|
remaining = read_remaining(sock)
|
|
data = bytearray()
|
|
while len(data) < remaining:
|
|
chunk = sock.recv(remaining - len(data))
|
|
if not chunk:
|
|
raise EOFError("connection closed")
|
|
data.extend(chunk)
|
|
return header[0], bytes(data)
|
|
|
|
|
|
flags = 0x02 # clean session
|
|
payload = field(client_id)
|
|
if username is not None:
|
|
flags |= 0x80
|
|
payload += field(username)
|
|
if password is not None:
|
|
flags |= 0x40
|
|
payload += field(password)
|
|
|
|
variable_header = b"\x00\x04MQTT\x04" + bytes([flags]) + b"\x00\x3c"
|
|
connect_packet = b"\x10" + enc_len(len(variable_header) + len(payload)) + variable_header + payload
|
|
subscribe_payload = field(topic) + b"\x00"
|
|
subscribe_packet = b"\x82" + enc_len(2 + len(subscribe_payload)) + b"\x00\x01" + subscribe_payload
|
|
|
|
try:
|
|
with socket.create_connection((host, port), timeout=timeout) as sock:
|
|
sock.settimeout(timeout)
|
|
sock.sendall(connect_packet)
|
|
packet_type, data = recv_packet(sock)
|
|
if packet_type >> 4 != 2 or len(data) < 2 or data[1] != 0:
|
|
code = data[1] if len(data) > 1 else "?"
|
|
print(f"ERROR: MQTT connection failed, CONNACK code {code}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
|
|
sock.sendall(subscribe_packet)
|
|
while True:
|
|
packet_type, data = recv_packet(sock)
|
|
if packet_type >> 4 != 3:
|
|
continue
|
|
topic_len = int.from_bytes(data[:2], "big")
|
|
idx = 2 + topic_len
|
|
qos = (packet_type >> 1) & 0x03
|
|
if qos:
|
|
idx += 2
|
|
sys.stdout.write(data[idx:].decode("utf-8", "replace"))
|
|
break
|
|
except socket.timeout:
|
|
print(f"ERROR: no MQTT message received from {topic} within {timeout:g}s.", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
except Exception as exc:
|
|
print(f"ERROR: MQTT subscribe failed: {exc}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
PY
|
|
)"
|
|
|
|
# Accept plain text country names/codes, a JSON string, or a JSON object containing
|
|
# country_code/country/state/value/address.country(_code). Store ISO alpha-2 code.
|
|
country_code="$(PAYLOAD="${payload}" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import unicodedata
|
|
|
|
payload = os.environ.get("PAYLOAD", "").strip()
|
|
|
|
# Common FJR/Europe mappings plus common English/local country names.
|
|
COUNTRIES = {
|
|
"albania": "AL", "shqiperia": "AL", "shqipëria": "AL",
|
|
"andorra": "AD",
|
|
"austria": "AT", "osterreich": "AT", "österreich": "AT",
|
|
"belarus": "BY",
|
|
"belgium": "BE", "belgique": "BE", "belgie": "BE", "belgië": "BE",
|
|
"bosnia and herzegovina": "BA", "bosnia": "BA", "bosna i hercegovina": "BA",
|
|
"bulgaria": "BG", "balgariya": "BG", "bǎlgariya": "BG", "българия": "BG",
|
|
"croatia": "HR", "hrvatska": "HR",
|
|
"cyprus": "CY",
|
|
"czechia": "CZ", "czech republic": "CZ", "cesko": "CZ", "česko": "CZ",
|
|
"denmark": "DK", "danmark": "DK",
|
|
"estonia": "EE", "eesti": "EE",
|
|
"finland": "FI", "suomi": "FI",
|
|
"france": "FR",
|
|
"germany": "DE", "deutschland": "DE",
|
|
"greece": "GR", "hellas": "GR", "ellada": "GR", "ελλάδα": "GR",
|
|
"hungary": "HU", "magyarorszag": "HU", "magyarország": "HU",
|
|
"iceland": "IS", "island": "IS", "ísland": "IS",
|
|
"ireland": "IE", "eire": "IE", "éire": "IE",
|
|
"italy": "IT", "italia": "IT",
|
|
"kosovo": "XK",
|
|
"latvia": "LV", "latvija": "LV",
|
|
"liechtenstein": "LI",
|
|
"lithuania": "LT", "lietuva": "LT",
|
|
"luxembourg": "LU", "letzebuerg": "LU", "lëtzebuerg": "LU",
|
|
"malta": "MT",
|
|
"moldova": "MD",
|
|
"monaco": "MC",
|
|
"montenegro": "ME", "crna gora": "ME",
|
|
"netherlands": "NL", "the netherlands": "NL", "nederland": "NL", "holland": "NL",
|
|
"north macedonia": "MK", "macedonia": "MK", "severna makedonija": "MK",
|
|
"norway": "NO", "norge": "NO", "noreg": "NO",
|
|
"poland": "PL", "polska": "PL",
|
|
"portugal": "PT",
|
|
"romania": "RO", "românia": "RO",
|
|
"san marino": "SM",
|
|
"serbia": "RS", "srbija": "RS", "србија": "RS",
|
|
"slovakia": "SK", "slovensko": "SK",
|
|
"slovenia": "SI", "slovenija": "SI",
|
|
"spain": "ES", "espana": "ES", "españa": "ES",
|
|
"sweden": "SE", "sverige": "SE",
|
|
"switzerland": "CH", "schweiz": "CH", "suisse": "CH", "svizzera": "CH",
|
|
"turkey": "TR", "turkiye": "TR", "türkiye": "TR",
|
|
"ukraine": "UA", "ukraina": "UA", "україна": "UA",
|
|
"united kingdom": "GB", "great britain": "GB", "uk": "GB",
|
|
"vatican city": "VA", "holy see": "VA",
|
|
}
|
|
|
|
|
|
def normalize(value):
|
|
value = str(value or "").strip()
|
|
value = re.sub(r"\s+", " ", value)
|
|
return value
|
|
|
|
|
|
def ascii_key(value):
|
|
value = normalize(value).lower()
|
|
value = unicodedata.normalize("NFKD", value)
|
|
value = "".join(ch for ch in value if not unicodedata.combining(ch))
|
|
return value
|
|
|
|
|
|
def as_code(value):
|
|
value = normalize(value)
|
|
if not value:
|
|
return ""
|
|
|
|
# Already ISO alpha-2, including XK for Kosovo.
|
|
if re.fullmatch(r"[A-Za-z]{2}", value):
|
|
return value.upper()
|
|
|
|
lower = value.lower()
|
|
if lower in COUNTRIES:
|
|
return COUNTRIES[lower]
|
|
|
|
key = ascii_key(value)
|
|
if key in COUNTRIES:
|
|
return COUNTRIES[key]
|
|
|
|
return ""
|
|
|
|
|
|
def get_path(obj, *path):
|
|
cur = obj
|
|
for key in path:
|
|
if not isinstance(cur, dict) or key not in cur:
|
|
return None
|
|
cur = cur[key]
|
|
return cur
|
|
|
|
try:
|
|
parsed = json.loads(payload)
|
|
except Exception:
|
|
code = as_code(payload)
|
|
if code:
|
|
print(code)
|
|
raise SystemExit(0)
|
|
print(f"ERROR: cannot map country to ISO code: {payload}", file=sys.stderr)
|
|
raise SystemExit(2)
|
|
|
|
candidates = []
|
|
if isinstance(parsed, str):
|
|
candidates.append(parsed)
|
|
elif isinstance(parsed, dict):
|
|
for path in [
|
|
("country_code",),
|
|
("address", "country_code"),
|
|
("country",),
|
|
("address", "country"),
|
|
("state",),
|
|
("value",),
|
|
]:
|
|
value = get_path(parsed, *path)
|
|
if value is not None:
|
|
candidates.append(value)
|
|
else:
|
|
candidates.append(parsed)
|
|
|
|
for candidate in candidates:
|
|
code = as_code(candidate)
|
|
if code:
|
|
print(code)
|
|
break
|
|
else:
|
|
print(f"ERROR: cannot map country to ISO code from payload: {payload}", file=sys.stderr)
|
|
raise SystemExit(2)
|
|
PY
|
|
)"
|
|
|
|
if [[ -z "${country_code}" || "${country_code}" == "null" ]]; then
|
|
echo "ERROR: MQTT payload did not contain a usable country value." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# IMPORTANT: only inspect the last record in the JSON file.
|
|
last_country_code="$(python3 - "${JSON_FILE}" <<'PY'
|
|
import json
|
|
import sys
|
|
|
|
with open(sys.argv[1], "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
|
|
if not data:
|
|
print("")
|
|
raise SystemExit(0)
|
|
|
|
last = data[-1]
|
|
if isinstance(last, dict):
|
|
print(str(last.get("country_code", last.get("code", last.get("country", last.get("value", ""))))).upper())
|
|
else:
|
|
print(str(last).upper())
|
|
PY
|
|
)"
|
|
|
|
if [[ "${country_code}" == "${last_country_code}" ]]; then
|
|
echo "No change: ${country_code}"
|
|
exit 0
|
|
fi
|
|
|
|
timestamp="$(date -Is)"
|
|
tmp_file="$(mktemp "${JSON_FILE}.tmp.XXXXXX")"
|
|
cleanup() {
|
|
[[ -n "${tmp_file:-}" && -e "${tmp_file}" ]] && rm -f -- "${tmp_file}"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
python3 - "${JSON_FILE}" "${tmp_file}" "${timestamp}" "${country_code}" <<'PY'
|
|
import json
|
|
import sys
|
|
|
|
src, dst, timestamp, country_code = sys.argv[1:5]
|
|
with open(src, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
|
|
# Append only the new current value; do not scan older history.
|
|
data.append({"timestamp": timestamp, "country_code": country_code})
|
|
|
|
with open(dst, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
f.write("\n")
|
|
PY
|
|
|
|
mv -- "${tmp_file}" "${JSON_FILE}"
|
|
tmp_file=""
|
|
|
|
echo "Appended: ${country_code}"
|