#!/bin/bash
# Настройка ТС ПИоТ на рабочем месте: пакеты, тип кассы, Прим/Штрих, регистрация ЕСМ.
#
# Один запуск на самом РМ, только от суперпользователя:
#   sudo bash etp.sh
# Скрипт сам проверяет кассу и пакеты, спрашивает про установку,
# настраивает, регистрирует и в конце проверяет, что сервисы отвечают.
#
# Откуда пакеты, от свежего индекса репозитория, не из зашитых версий:
#   1) testing
#   2) если пакета там нет — current
#   3) если нет и там — system11
#      esm и esm-lm-controller на 2026-10-12 есть только в system11
#      (тот же файл продублирован в system10). В testing и current их нет.
#   develop не используется.
#   Если в сюите несколько версий, берётся большая по dpkg --compare-versions.
#   Уже стоящую более новую версию скрипт не понижает.
#
# Штрих и Ритейл D3: TYP 2 и 12 меняются на 22. COM и SPEED кассы не трогаются.
#   Штрих C3 не ниже C3,63074,260602 (июнь 2026).
#   Ритейл D3 не ниже апреля 2026: живой эталон VER=D3,3469,260417.
#   Прошивка T3 для ТС ПИоТ не подходит, скрипт на ней останавливается.
#   Дальше D3 идёт тем же путём, что Штрих. Порт 4042 держит bukkassa.
#   Если смена закрыта, скрипт спрашивает и шлёт @!SHBEG;TXT=Оператор.
#   ГИС МТ регистрирует кассу только после этого и только если в самой ККТ
#   есть лицензия ТС ПИоТ. Без неё касса отвечает кодом 55.
#   Лицензию скрипт запрашивает у ИТС Посцентра так же, как кнопки FRS
#   на azs2343 14.10.2026: сведения о ККТ, параметр модели 79, заводской
#   номер из таблицы 18, состояние ККТ, чтение лицензий, затем
#   ITSLicenseIssue с allow_trial=false. Если в кассе блок версии 0 и ИТС
#   отвечает «новой лицензии нет», скрипт записывает триал и спрашивает,
#   ждать ли оплаченную. Если да — раз в минуту запрос и снова вопрос,
#   не дольше 8 минут. Если нет — остаётся триал, регистрация идёт дальше.
#   Чужой блок не подставляется.
#   Если console_test_fr_drv_ng нет, скрипт ставит пакет bukts-dkkt из apt.
#   Так для Штрих и Ритейл D3. Линейка T3 не используется.
#
# Прим: TYP 1 и 11 не меняются.
#   Образец 172.24.132.19: /etc/opt/bukts-dkkt/iskra/config.xml
#   физический порт из bukts-kassa.cfg и скорость 19200,
#   затем в bukts-kassa.cfg COM=127.0.0.1 и SPEED=65501 (это listenPort драйвера).
#   В STAT Прима версии нет. Драйвер Искра пишет её в /var/log/dkkt/dkkt.log.
#   Минимум с 172.24.132.19: 7.2LPC08 01-01-26. Ниже — установка останавливается.
#
# Если есть /etc/opt/bukts/conf/bukts-kassa.cfg, кассу правит веб.
# Скрипт этот файл и основной bukts-kassa.cfg тогда не переписывает.
#
# bukts-loader перезапускает только если касса должна перечитать тип или порт.
# Открытые заправки при этом не проверяет.
# Не делает: закрытие смены, dpkg --purge esm
# (postrm esm выполняет rm -rf /opt/esp и сносит esm-lm-controller).

set -euo pipefail

REPO="${REPO:-http://repo-tn.bukts.ru}"
API="${API:-http://127.0.0.1:51077}"
CACHE="${CACHE:-/var/cache/esm-tspiot}"
BACKUP_DIR="${BACKUP_DIR:-/var/backups/esm-tspiot-setup}"
GIS_WAIT="${GIS_WAIT:-180}"
ONLY_ZN=""
CHECK=0
ASSUME_YES=0
SKIP_REGISTER=0
ORIG_ARGS=("$@")

KASSA_MAIN="/etc/opt/bukts/bukts-kassa.cfg"
KASSA_CONF="/etc/opt/bukts/conf/bukts-kassa.cfg"
KASSA_ENABLED="/etc/opt/bukts/conf/.enabled"
CHZNAK_INI="/etc/opt/bukts-chznak/config.ini"
ISKRA_XML="/etc/opt/bukts-dkkt/iskra/config.xml"
ISKRA_SAMPLE="/etc/opt/bukts-dkkt/samples/iskra/config.xml"
NEED_PKGS=(esm esm-lm-controller bukts-kassa bukts-chznak bukts-dkkt)
MIN_FW="C3,63074,260602"
MIN_FW_D="D3,0,260401"
MIN_FW_PRIM="7.2LPC08 01-01-26"
SHTRIH_FW_DONE=0
FW_VER=""
PRIM_FW_DONE=0
LOADER_PENDING="/var/lib/esm-tspiot-setup/loader-restart-pending"
INSTALL_ORDER=(esm esm-lm-controller bukts-kassa bukts-chznak bukts-dkkt)

if [[ -t 1 ]]; then
    B=$'\033[1m'; G=$'\033[32m'; Y=$'\033[33m'; R=$'\033[31m'; Z=$'\033[0m'
else
    B=""; G=""; Y=""; R=""; Z=""
fi

stamp() { date '+%H:%M:%S'; }
say()  { printf '%s\n' "$(stamp) $*"; }
log()  { say "${B}==>${Z} $*"; }
ok()   { say "${G} ok${Z} $*"; }
warn() { say "${Y} !!${Z} $*" >&2; }
die()  { say "${R}ошибка:${Z} $*" >&2; exit 1; }
stop() { say "${Y}стоп:${Z} $*" >&2; exit 1; }

usage() {
    cat <<'EOF'
Запуск возможен только от суперпользователя:
  sudo bash esm-tspiot-setup.sh

Один запуск: проверка кассы, проверка пакетов, вопрос про установку
или обновление, настройка, регистрация и проверка ответов сервисов.

  --yes              ставить недостающие пакеты без вопроса
  --zn ЗН            зарегистрировать только эту кассу
  --wait СЕКУНД      ожидание регистрации в ГИС МТ (по умолчанию 180)
  -h, --help         эта справка

Для Штриха касса занимается на пару секунд, порт сразу закрывается.
EOF
}

while [[ $# -gt 0 ]]; do
    case "$1" in
        --check) CHECK=1; shift ;;
        --yes) ASSUME_YES=1; shift ;;
        --skip-register) SKIP_REGISTER=1; shift ;;
        --zn)
            [[ $# -ge 2 ]] || die "--zn требует заводской номер"
            ONLY_ZN="$2"
            shift 2
            ;;
        --wait)
            [[ $# -ge 2 ]] || die "--wait требует число секунд"
            GIS_WAIT="$2"
            shift 2
            ;;
        --repo)
            [[ $# -ge 2 ]] || die "--repo требует URL"
            REPO="${2%/}"
            shift 2
            ;;
        -h|--help) usage; exit 0 ;;
        *) die "неизвестный аргумент: $1 (см. --help)" ;;
    esac
done

[[ "$GIS_WAIT" =~ ^[0-9]+$ ]] || die "--wait должен быть целым числом секунд"

if [[ "$(id -u)" -ne 0 ]]; then
    printf '%s\n' "Запуск возможен только от суперпользователя." >&2
    exit 1
fi

need() { command -v "$1" >/dev/null 2>&1 || die "нет команды $1"; }

pkg_ver() { dpkg-query -W -f='${Version}' "$1" 2>/dev/null || true; }

pkg_installed() {
    local status
    status="$(dpkg-query -W -f='${Status}' "$1" 2>/dev/null || true)"
    [[ "$status" == "install ok installed" ]]
}

ask_yes() {
    local answer
    if [[ "$ASSUME_YES" == 1 ]]; then
        log "передан --yes, пакеты ставятся без вопроса"
        return 0
    fi
    [[ -r /dev/tty ]] || die "нет терминала для вопроса. Запустите в консоли или добавьте --yes"
    while true; do
        printf '%s' "Установить или обновить эти пакеты? [да/нет]: " >/dev/tty
        IFS= read -r answer </dev/tty || die "ответ не получен"
        case "$answer" in
            да|Да|ДА|д|Д|y|Y|yes|Yes|YES) return 0 ;;
            нет|Нет|НЕТ|н|Н|n|N|no|No|NO) return 1 ;;
            *) printf '%s\n' "Нужно ответить да или нет." >/dev/tty ;;
        esac
    done
}

repo_testing_configured() {
    (
        shopt -s nullglob
        local file
        for file in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
            [[ -f "$file" ]] || continue
            if grep -Eq '^[[:space:]]*deb[[:space:]].*[[:space:]]testing[[:space:]]+bukts([[:space:]]|$)' "$file"; then
                exit 0
            fi
        done
        exit 1
    )
}

enable_commented_testing() {
    local file changed=0
    shopt -s nullglob
    for file in /etc/apt/sources.list /etc/apt/sources.list.d/*.list; do
        [[ -f "$file" ]] || continue
        if grep -Eq '^[[:space:]]*#[[:space:]]*deb[[:space:]].* testing bukts' "$file"; then
            sed -i -E '/^[[:space:]]*#[[:space:]]*deb[[:space:]].* testing bukts/ s/^[[:space:]]*#[[:space:]]*//' "$file"
            changed=1
            log "включил закомментированный testing в ${file}"
        fi
    done
    shopt -u nullglob
    [[ "$changed" == 1 ]]
}

run_repo_script() {
    local tmp
    tmp="$(mktemp /tmp/update_repo_test.XXXXXX.sh)"
    log "в apt нет сюиты testing, скачиваю update_repo_test.sh"
    curl -fsS -m 40 -o "$tmp" "http://bukts.ru/files/service/update/update_repo_test.sh" \
        || stop "Не скачался http://bukts.ru/files/service/update/update_repo_test.sh"
    chmod 755 "$tmp"
    log "подключаю testing, current и system. Скрипт перепишет /etc/apt/sources.list, прежний файл сохранится рядом как .bak-*"
    bash "$tmp" || stop "update_repo_test.sh завершился с ошибкой"
}

apt_refresh() {
    if ! repo_testing_configured; then
        if [[ "$CHECK" == 1 ]]; then
            log "[check] активного testing нет, включил бы его или запустил update_repo_test.sh"
        elif enable_commented_testing; then
            ok "testing включён в уже существующем списке, debian sources.list не трогаю"
        else
            run_repo_script
        fi
    else
        ok "репозиторий testing в apt уже подключён"
    fi
    if [[ "$CHECK" == 1 ]] && ! repo_testing_configured; then
        warn "без репозитория testing версии пакетов по apt не смотрю"
        return 1
    fi
    log "apt-get update"
    if apt-get update -o Acquire::http::Timeout=40; then
        return 0
    fi
    # Часы кассы иногда отстают на несколько секунд, и Release «ещё не действителен».
    warn "apt-get update не прошёл, повторяю через 20 с"
    sleep 20
    if apt-get update -o Acquire::http::Timeout=40; then
        return 0
    fi
    if [[ "$CHECK" == 1 ]]; then
        warn "apt-get update не прошёл"
        return 1
    fi
    if repo_testing_configured; then
        stop "apt-get update не прошёл. testing в bukts.list уже есть, sources.list не переписываю."
    fi
    warn "в apt нет сюиты testing, подключаю репозитории"
    run_repo_script
    apt-get update -o Acquire::http::Timeout=40 || stop "apt-get update не прошёл и после update_repo_test.sh"
}

apt_policy_field() {
    local pkg="$1" label="$2" value
    value="$(apt-cache policy "$pkg" 2>/dev/null | awk -v label="$label" '$1 == label { print $2; exit }')"
    printf '%s\n' "$value"
}

# Версия из testing, если пакет там есть. Иначе current, иначе system.
# develop не берём, даже если apt считает его кандидатом.
apt_desired_version() {
    apt-cache madison "$1" 2>/dev/null | python3 -c '
import subprocess, sys
rows = []
for line in sys.stdin:
    parts = [p.strip() for p in line.split("|")]
    if len(parts) < 3:
        continue
    ver, source = parts[1], parts[2].lower()
    if "develop" in source:
        continue
    if "testing" in source:
        rank = 0
    elif "current" in source:
        rank = 1
    elif "system" in source:
        rank = 2
    else:
        rank = 3
    rows.append((rank, ver))
if not rows:
    sys.exit(0)
best_rank = min(r[0] for r in rows)
cands = [ver for rank, ver in rows if rank == best_rank]

def gt(a, b):
    return subprocess.call(["dpkg", "--compare-versions", a, "gt", b]) == 0

best = cands[0]
for ver in cands[1:]:
    if gt(ver, best):
        best = ver
print(best)
'
}

pkg_binary_ok() {
    case "$1" in
        esm) [[ -x /opt/esp/esm/bin/controlmodule ]] ;;
        esm-lm-controller) [[ -x /opt/esp/lmcontroller/bin/lmcontroller ]] ;;
        bukts-dkkt) find_fr_drv >/dev/null ;;
        *) return 0 ;;
    esac
}

install_planned() {
    local pkg have cand reason need_any=0 missing_repo=0
    local -a specs=()
    local reinstall=0
    need_any=0
    say "Сверяю установленные версии с кандидатом apt после update."
    for pkg in "${INSTALL_ORDER[@]}"; do
        have="$(pkg_ver "$pkg")"
        cand="$(apt_desired_version "$pkg")"
        if [[ -z "$cand" || "$cand" == "(none)" ]]; then
            warn "пакета ${pkg} нет в подключённых репозиториях"
            missing_repo=1
            continue
        fi
        if [[ -z "$have" || "$have" == "(none)" ]] || ! pkg_installed "$pkg"; then
            say "  ${pkg}: не установлен -> ${cand}"
            specs+=("${pkg}=${cand}")
            need_any=1
            continue
        fi
        if dpkg --compare-versions "$have" gt "$cand"; then
            warn "${pkg} ${have} новее кандидата apt ${cand}, версию не понижаю"
            continue
        fi
        if dpkg --compare-versions "$have" lt "$cand"; then
            say "  ${pkg}: ${have} -> ${cand}"
            specs+=("${pkg}=${cand}")
            need_any=1
            continue
        fi
        if ! pkg_binary_ok "$pkg"; then
            say "  ${pkg}: версия ${cand} есть, но нет файлов программы"
            specs+=("${pkg}=${cand}")
            reinstall=1
            need_any=1
            continue
        fi
        ok "${pkg} ${have}, не старее репозитория"
    done
    if [[ "$missing_repo" == 1 ]]; then
        if [[ "$CHECK" == 1 ]]; then
            warn "apt не видит часть пакетов. В режиме --check установку не предлагаю"
            return 0
        fi
        stop "Без пакетов из репозитория продолжать нечем. Проверьте apt-get update и сюиты testing, current и system."
    fi
    if [[ "$need_any" == 0 ]]; then
        ok "нужные пакеты уже стоят и не старее репозитория"
        return 0
    fi
    say "Это установка недостающих и обновление тех, что старее репозитория."
    say "Пакет bukts-kassa при установке сам bukts-loader не перезапускает."
    say "Скрипт пакеты не удаляет: purge esm стирает /opt/esp вместе с esm-lm-controller."
    if [[ "$CHECK" == 1 ]]; then
        log "[check] пакеты не ставлю и не обновляю"
        return 0
    fi
    if ! ask_yes; then
        say "Остановился. Пакеты не ставил и не обновлял, регистрацию не начинал."
        say "Когда можно будет установить или обновить их, запустите скрипт ещё раз."
        exit 0
    fi
    local flag=()
    if [[ "$reinstall" == 1 ]]; then
        flag=(--reinstall)
    fi
    log "apt-get install ${specs[*]}"
    local preview
    preview="$(LANG=C LC_ALL=C apt-get install -s -y --no-install-recommends "${flag[@]}" "${specs[@]}" 2>/dev/null || true)"
    if printf '%s\n' "$preview" | grep -Eq '^Inst bukts-loader([[:space:]]|$)'; then
        stop "Это обновление затронет bukts-loader. Во время работы АЗС его не обновляю и не перезапускаю."
    fi
    if ! DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
        -o Dpkg::Options::=--force-confdef \
        -o Dpkg::Options::=--force-confold \
        "${flag[@]}" "${specs[@]}"; then
        warn "повтор установки: libabsl беру из bullseye, зеркало security отдало 404"
        DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
            -o Dpkg::Options::=--force-confdef \
            -o Dpkg::Options::=--force-confold \
            libabsl20200923/bullseye "${flag[@]}" "${specs[@]}" \
            || stop "apt-get install не поставил пакеты"
    fi
    if [[ ! -x /opt/esp/lmcontroller/bin/lmcontroller ]] && pkg_installed esm-lm-controller; then
        stop "esm-lm-controller установлен, а /opt/esp/lmcontroller/bin/lmcontroller нет. Так бывает после purge esm: его postrm делает rm -rf /opt/esp."
    fi
    for pkg in "${INSTALL_ORDER[@]}"; do
        if pkg_installed "$pkg"; then
            ok "${pkg} $(pkg_ver "$pkg")"
        fi
    done
}

active_kassa_file() {
    if [[ -f "$KASSA_ENABLED" && -f "$KASSA_CONF" ]]; then
        printf '%s\n' "$KASSA_CONF"
    elif [[ -f "$KASSA_MAIN" ]]; then
        printf '%s\n' "$KASSA_MAIN"
    else
        return 1
    fi
}

kassa_get() {
    local file="$1" key="$2"
    python3 - "$file" "$key" <<'PY'
import re, sys
path, key = sys.argv[1], sys.argv[2].upper()
rx = re.compile(r'^\s*(?:\d+:)?(?P<k>TYP|COM|SPEED)\s*=\s*(?P<v>\S+)', re.I)
found = []
for line in open(path, encoding="utf-8", errors="replace"):
    line = line.rstrip("\r\n")
    s = line.strip()
    if not s or s.startswith("#") or s.startswith(";"):
        continue
    m = rx.match(line)
    if m and m.group("k").upper() == key:
        found.append(m.group("v").strip().strip('"').strip("'"))
if len(set(found)) > 1:
    sys.stderr.write("несколько значений " + key + ": " + ", ".join(found) + ". Беру последнее, как bukts-dkkt/start.sh\n")
print(found[-1] if found else "")
PY
}

backup_file() {
    local file="$1"
    mkdir -p "$BACKUP_DIR"
    cp -a "$file" "${BACKUP_DIR}/$(basename "$file").$(date +%Y%m%d%H%M%S)"
}

read_firmware() {
    local rc=0
    printf '%s\n' "$(stamp) ==> на пару секунд подключаюсь к кассе 127.0.0.1:65522 и сразу закрываю порт" >&2
    python3 - <<'PY' || rc=$?
import socket, sys
try:
    s = socket.create_connection(("127.0.0.1", 65522), 3)
except OSError as exc:
    sys.stderr.write(f"connect: {exc}\n")
    sys.exit(2)
s.settimeout(2.5)
data = b""
try:
    s.sendall(b"@!STAT;TYP=1\n")
    while len(data) < 8000:
        try:
            chunk = s.recv(4096)
        except socket.timeout:
            break
        if not chunk:
            break
        data += chunk
        if b"STAT;" in data and b"$" in data.split(b"STAT;", 1)[-1]:
            break
finally:
    s.close()
sys.stdout.buffer.write(data)
PY
    printf '%s\n' "$(stamp) ok порт кассы 65522 закрыт" >&2
    return "$rc"
}

# Кнопка «Открыть смену по кассе» в «Оборудование → Кассовые аппараты»
# шлёт на 65522 SHBEG;TXT=<кассир>. Без имени касса смену не открывает.
kassa_cmd() {
    local cmd="$1"
    LANG=C.UTF-8 python3 - "$cmd" <<'PY'
import socket, sys, time
cmd = sys.argv[1]
deadline = time.time() + 45
try:
    s = socket.create_connection(("127.0.0.1", 65522), 5)
except OSError as exc:
    sys.stderr.write(f"connect: {exc}\n")
    sys.exit(2)
s.settimeout(5)
last = ""
buf = b""
try:
    s.sendall(("@!" + cmd + "\n\0").encode("utf-8"))
    while time.time() < deadline:
        try:
            chunk = s.recv(4096)
        except socket.timeout:
            if last:
                break
            continue
        if not chunk:
            break
        buf += chunk
        while b"\n" in buf or b"$" in buf:
            if b"\n" in buf:
                raw, buf = buf.split(b"\n", 1)
            else:
                raw, _, buf = buf.partition(b"$")
                raw = raw + b"$"
            text = raw.decode("utf-8", "replace").strip("\r")
            if not text:
                continue
            last = text
            if "TCOD=WAIT" in text or "TCOD=DOING" in text:
                continue
            sys.stdout.write(text + "\n")
            sys.exit(0)
finally:
    s.close()
if last:
    sys.stdout.write(last + "\n")
    sys.exit(0)
sys.stderr.write("касса не ответила\n")
sys.exit(1)
PY
}

ask_open_shift() {
    local answer
    if [[ "$ASSUME_YES" == 1 ]]; then
        log "передан --yes, открываю смену по кассе"
        return 0
    fi
    [[ -r /dev/tty ]] || return 1
    while true; do
        printf '%s' "Смена по кассе закрыта. Открыть смену? [да/нет]: " >/dev/tty
        IFS= read -r answer </dev/tty || return 1
        case "$answer" in
            да|Да|ДА|д|Д|y|Y|yes|Yes|YES) return 0 ;;
            нет|Нет|НЕТ|н|Н|n|N|no|No|NO) return 1 ;;
            *) printf '%s\n' "Нужно ответить да или нет." >/dev/tty ;;
        esac
    done
}

find_fr_drv() {
    local dir bin=""
    shopt -s nullglob
    for dir in /opt/bukts-dkkt/poscenter/linux_*/x86_64; do
        if [[ -x "${dir}/console_test_fr_drv_ng" ]]; then
            bin="${dir}/console_test_fr_drv_ng"
        fi
    done
    shopt -u nullglob
    [[ -n "$bin" ]] || return 1
    printf '%s\n' "$bin"
}

# Драйвер Посцентра лежит в пакете bukts-dkkt. На части РМ пакета нет.
install_poscenter_driver() {
    local cand preview
    cand="$(apt_desired_version bukts-dkkt)"
    if [[ -z "$cand" || "$cand" == "(none)" ]]; then
        stop "В apt нет пакета bukts-dkkt. Драйвер Посцентра скачать неоткуда, чужой блок лицензии не подставляю."
    fi
    if [[ "$CHECK" == 1 ]]; then
        log "[check] поставил бы bukts-dkkt=${cand}: в нём console_test_fr_drv_ng"
        return 0
    fi
    local -a flag=()
    if pkg_installed bukts-dkkt; then
        flag=(--reinstall)
    fi
    log "драйвера Посцентра нет, ставлю bukts-dkkt=${cand}"
    preview="$(LANG=C LC_ALL=C apt-get install -s -y --no-install-recommends "${flag[@]}" "bukts-dkkt=${cand}" 2>/dev/null || true)"
    if printf '%s\n' "$preview" | grep -Eq '^Inst bukts-loader([[:space:]]|$)'; then
        stop "Установка драйвера Посцентра затронет bukts-loader. Во время работы АЗС его не обновляю."
    fi
    if ! DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
        -o Dpkg::Options::=--force-confdef \
        -o Dpkg::Options::=--force-confold \
        "${flag[@]}" "bukts-dkkt=${cand}"; then
        warn "повтор установки драйвера: libabsl беру из bullseye"
        DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
            -o Dpkg::Options::=--force-confdef \
            -o Dpkg::Options::=--force-confold \
            libabsl20200923/bullseye "${flag[@]}" "bukts-dkkt=${cand}" \
            || stop "Не поставил bukts-dkkt=${cand}. Драйвер Посцентра не скачался."
    fi
    find_fr_drv >/dev/null || stop "Пакет bukts-dkkt ${cand} встал, а console_test_fr_drv_ng в нём нет."
    ok "драйвер Посцентра установлен: $(find_fr_drv)"
}

ask_free_kassa_port() {
    local answer
    if [[ "$ASSUME_YES" == 1 ]]; then
        log "передан --yes, освобождаю порт кассы для лицензии ИТС"
        return 0
    fi
    [[ -r /dev/tty ]] || return 1
    while true; do
        printf '%s' "Порт кассы занят. Остановить bukts-loader, запросить лицензию и запустить снова? [да/нет]: " >/dev/tty
        IFS= read -r answer </dev/tty || return 1
        case "$answer" in
            да|Да|ДА|д|Д|y|Y|yes|Yes|YES) return 0 ;;
            нет|Нет|НЕТ|н|Н|n|N|no|No|NO) return 1 ;;
            *) printf '%s\n' "Нужно ответить да или нет." >/dev/tty ;;
        esac
    done
}

# Лицензию берёт драйвер этой кассы у its.pos-center.ru.
# В вызов не передаётся готовый блок: он чужой для другого юрлица.
fr_drv_run() {
    local bin="$1" url="$2"
    shift 2
    # Команда идёт первой: `console_test_fr_drv_ng <command> [-a url] [-p password]`.
    "$bin" "$@" -a "$url" -p 30
}

piot_license_missing() {
    local text="$1" line
    line="$(printf '%s\n' "$text" | grep -i 'piot' | head -n 1 || true)"
    [[ -n "$line" ]] || return 2
    [[ "$line" == *none* || "$line" == *None* || "$line" == *NONE* ]]
}

stop_html_test() {
    if [[ -f /tmp/etp-html-test.pid ]]; then
        kill "$(cat /tmp/etp-html-test.pid)" 2>/dev/null || true
        rm -f /tmp/etp-html-test.pid
    fi
    pkill -x console_test_fr_drv_ng 2>/dev/null || true
    sleep 1
}

restore_loader_after_its() {
    stop_html_test
    [[ "${ITS_LOADER_HELD:-0}" == 1 ]] || return 0
    ITS_LOADER_HELD=0
    log "запускаю bukts-loader обратно"
    if [[ -x /etc/init.d/bukts-loader ]]; then
        /etc/init.d/bukts-loader start || true
    else
        systemctl start bukts-loader || true
    fi
}

ensure_its_license() {
    local com="$1" speed="$2" bin url out rc=0 held=0
    [[ "$com" == /dev/* ]] || stop "Лицензию ТС ПИоТ запрашиваю только на последовательном порту кассы, сейчас COM=${com}."
    [[ "$speed" =~ ^[0-9]+$ ]] || stop "В bukts-kassa.cfg нет скорости порта, лицензию у ИТС запросить не могу."
    if ! bin="$(find_fr_drv)"; then
        install_poscenter_driver
        if [[ "$CHECK" == 1 ]]; then
            return 0
        fi
        bin="$(find_fr_drv)" || stop "Драйвер Посцентра так и не появился. Лицензию у ИТС запросить нечем."
    fi
    url="serial://${com}?timeout=60000&baudrate=${speed}&protocol=v1"
    if [[ "$CHECK" == 1 ]]; then
        log "[check] прошёл бы кнопки FRS и при пустом ПИоТ вызвал обычный ITSLicenseIssue, без триала"
        return 0
    fi
    say "    порт кассы нужен драйверу Посцентра, чтобы прочитать лицензию ТС ПИоТ"
    ask_free_kassa_port || stop "Порт кассы занят. Лицензию ТС ПИоТ не прочитал и чужой блок не подставлял."
    log "останавливаю bukts-loader, чтобы занять ${com}"
    ITS_LOADER_HELD=1
    trap restore_loader_after_its EXIT
    if [[ -x /etc/init.d/bukts-loader ]]; then
        /etc/init.d/bukts-loader stop || stop "bukts-loader не остановился"
    else
        systemctl stop bukts-loader || stop "bukts-loader не остановился"
    fi
    local i
    for i in 1 2 3 4 5 6 7 8 9 10; do
        if ! pgrep -x bukts-loader >/dev/null 2>&1 && ! pgrep -x bukkassa >/dev/null 2>&1; then
            break
        fi
        sleep 1
    done
    if pgrep -x bukts-loader >/dev/null 2>&1 || pgrep -x bukkassa >/dev/null 2>&1; then
        stop "bukts-loader не отпустил порт кассы. Лицензию не запрашивал."
    fi
    log "запускаю тест драйвера Посцентра"
    (
        cd "$(dirname "$bin")" && exec "$bin" html-test
    ) >/tmp/etp-html-test.log 2>&1 &
    echo $! >/tmp/etp-html-test.pid
    # Не в $(...): иначе у python нет /dev/tty и вопрос про лицензию молча пропускается.
    set +e
    python3 - "$url" >/tmp/etp-license.out <<'PY'
import json, socket, sys, time, urllib.request
url = sys.argv[1]

def fail(msg):
    print("FAIL " + msg)
    raise SystemExit(1)

def note(msg):
    sys.stderr.write(msg + "\n")
    sys.stderr.flush()

def rpc(method, params, timeout=300):
    body = json.dumps({"jsonrpc": "2.0", "method": method, "params": params}).encode()
    req = urllib.request.Request(
        "http://127.0.0.1:8080/api/execute",
        data=body,
        headers={"Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        data = json.load(resp)
    if data.get("error"):
        err = data["error"]
        raise RuntimeError(str(err.get("message") or err))
    return data.get("result") or {}

def bad(res):
    code = res.get("ResultCode")
    return code not in (None, 0, "0")

ready = False
for _ in range(30):
    try:
        sock = socket.create_connection(("127.0.0.1", 8080), 1)
        sock.close()
        ready = True
        break
    except OSError:
        time.sleep(0.3)
if not ready:
    fail("тест драйвер не открыл порт 8080")

def call(method, params=None, timeout=60):
    try:
        res = rpc(method, params if params is not None else {"Password": 30}, timeout=timeout)
    except Exception as exc:
        fail(method + ": " + str(exc))
    if bad(res):
        fail(method + ": " + str(res.get("ResultCodeDescription") or res.get("ResultCode")))
    return res

def frs_read():
    # Тот же порядок, что кнопки FRS на azs2343 14.10.2026 17:26.
    # ИТС v1.license.issue принимает ИНН и полный заводской номер из
    # таблицы 18. Короткий Get_SerialNumber и номер ФН в этот запрос не входят:
    # ФН нужен ЕСМ, не серверу лицензий ККТ.
    call("GetDeviceMetrics")
    call("ReadModelParamValue", {"Password": 30, "ModelParamNumber": 79})
    table = call("ReadTable2", {"Password": 30, "TableNumber": 18, "FieldNumber": 1, "RowNumber": 1})
    serial = str(table.get("ValueOfFieldString") or "").strip()
    status = call("GetECRStatus")
    inn = str(status.get("INN") or "").strip()
    if serial or inn:
        note("в ИТС уйдёт ИНН %s, ЗН %s" % (inn or "?", serial or "?"))
    return call("ReadFeatureLicenses")

def no_new_license(err):
    text = str(err)
    return ("Нет лицензии" in text or "No new license" in text
            or "-32007" in text or "-75" in text)

call("Connect", {"Password": 30, "ConnectionURI": url})
lic = frs_read()
status = str(lic.get("LicensePiotStatus") or "")

def issue_once(trial):
    try:
        got = rpc("ITSLicenseIssue", {"Password": 30, "ITSLicenseTrial": bool(trial)}, timeout=300)
    except Exception as exc:
        return None, str(exc)
    if bad(got):
        return None, str(got.get("ResultCodeDescription") or got.get("ResultCode"))
    if not (got.get("License") or "") or not (got.get("DigitalSign") or ""):
        return None, "ИТС не вернул блок лицензии этой кассы"
    return got, ""

def write_block(block):
    res = rpc("WriteFeatureLicenses", {
        "Password": 30,
        "License": block["License"],
        "DigitalSign": block["DigitalSign"],
    })
    if bad(res):
        fail("запись: " + str(res.get("ResultCodeDescription") or res.get("ResultCode")))
    try:
        rpc("RebootKKT", {"Password": 30}, timeout=30)
    except Exception:
        pass
    time.sleep(5)
    back = False
    for _ in range(30):
        try:
            again = rpc("Connect", {"Password": 30, "ConnectionURI": url}, timeout=8)
        except SystemExit:
            raise
        except Exception:
            time.sleep(2)
            continue
        if not bad(again):
            back = True
            break
        time.sleep(2)
    if not back:
        fail("касса не ответила после перезагрузки с новой лицензией")

def paid_attempts():
    errors = []
    for n in range(1, 4):
        note("попытка %d/3, обычная лицензия" % n)
        if n > 1:
            frs_read()
        got, err = issue_once(False)
        if got:
            return got, errors
        errors.append(err)
        note("попытка %d не удалась: %s" % (n, err))
        if no_new_license(err):
            break
        if n < 3:
            time.sleep(15)
    return None, errors

issued, errors = paid_attempts()
if issued:
    write_block(issued)
    print("ISSUED")
    raise SystemExit(0)

if status and status.lower() != "none":
    note("новой обычной лицензии у ИТС нет, в кассе остаётся " + status)
    print("PRESENT " + status)
    raise SystemExit(0)

note("обычной лицензии нет, ТС ПИоТ в кассе пустой. Записываю триал и сразу повторяю обычный запрос")
trial, err = issue_once(True)
if not trial:
    fail("триал для пустого блока не выдан: " + err + ". Обычная лицензия: " + "; ".join(errors))
write_block(trial)
lic = frs_read()
status = str(lic.get("LicensePiotStatus") or "")
note("после триала в кассе " + (status or "пусто"))

def ask(prompt):
    try:
        tty = open("/dev/tty", "r+", encoding="utf-8", errors="replace")
    except OSError:
        note("нет терминала, оплаченную лицензию не жду")
        return False
    while True:
        tty.write(prompt)
        tty.flush()
        answer = tty.readline()
        if not answer:
            tty.close()
            return False
        answer = answer.strip().lower()
        if answer in ("да", "д", "y", "yes"):
            tty.close()
            return True
        if answer in ("нет", "н", "n", "no"):
            tty.close()
            return False
        tty.write("Нужно ответить да или нет.\n")

if not ask("Оплаченная лицензия на эту кассу уже есть? Ждать её у ИТС? [да/нет]: "):
    note("остаёмся на триале " + (status or ""))
    print("ISSUED-TRIAL " + status)
    raise SystemExit(0)

issued = None
for n in range(1, 9):
    note("пауза 60 с, затем обычный запрос %d/8" % n)
    time.sleep(60)
    got, err = issue_once(False)
    if got:
        issued = got
        break
    note("запрос %d: %s" % (n, err))
    if n < 8 and not ask("Пока нет. Ждать ещё минуту? [да/нет]: "):
        break
if issued:
    write_block(issued)
    print("ISSUED")
    raise SystemExit(0)
note("оплаченную не дождались, в кассе остаётся триал " + (status or ""))
print("ISSUED-TRIAL " + status)
PY
    rc=$?
    set -e
    out="$(cat /tmp/etp-license.out 2>/dev/null || true)"
    say "    лицензия: ${out:0:300}"
    if [[ "$rc" -ne 0 || "$out" == FAIL* ]]; then
        stop "Лицензию ТС ПИоТ для этой кассы получить не удалось. Чужой блок не подставлял."
    fi
    case "$out" in
        PRESENT*) ok "лицензия ТС ПИоТ в этой кассе уже есть" ;;
        ISSUED-TRIAL*) ok "в кассе триальная лицензия ТС ПИоТ, на ней и продолжаю" ;;
        ISSUED*) ok "лицензия ТС ПИоТ записана в эту кассу с сервера ИТС" ;;
        *) stop "Не понял ответ драйвера по лицензии. Чужой блок не подставлял." ;;
    esac
    restore_loader_after_its
}

open_kassa_shift() {
    local reply
    log "открываю смену по кассе: @!SHBEG;TXT=Оператор на 127.0.0.1:65522"
    reply="$(kassa_cmd 'SHBEG;TXT=Оператор')" || stop "Касса не ответила на открытие смены. Порт 65522 уже закрыт."
    say "    ответ кассы: ${reply}"
    if [[ "$reply" == *"RES=0"* || "$reply" == *"Смена уже открыта"* ]]; then
        ok "смена по кассе открыта"
        return 0
    fi
    stop "Касса не открыла смену: ${reply}"
}

firmware_ok_for() {
    local family="$1" text="$2" minimum="${3:-$MIN_FW}"
    python3 - "$family" "$text" "$minimum" <<'PY'
import re, sys
family, text = sys.argv[1], sys.argv[2]
# Пороги: C3,63074,260602 и D3 с 1 апреля 2026. Линейка T3 для ТС ПИоТ не подходит.
floors = {
    "C": (3, 63074, 260602),
    "D": (3, 0, 260401),
}
m = re.search(r"VER=([^;$\s]+)", text)
if not m:
    print("NOVER")
    sys.exit(0)
ver = m.group(1).strip().strip("\r")
parts = ver.split(",")
if parts and parts[0][:1] == "T":
    print("T3 " + ver)
    sys.exit(0)
if len(parts) < 3 or not parts[0][:1] in floors:
    print("BAD " + ver)
    sys.exit(0)
try:
    letter = parts[0][:1]
    major = int(parts[0][1:])
    build = int(parts[1])
    day = int(parts[2])
except ValueError:
    print("BAD " + ver)
    sys.exit(0)
min_major, min_build, min_day = floors[letter]
below = major < min_major or day < min_day or (
    major == min_major and day == min_day and build < min_build
)
if below:
    print("LOW " + ver)
    sys.exit(0)
above = major > min_major or day > min_day or build > min_build
print(("HIGH " if above else "OK ") + ver)
PY
}

apply_shtrih_typ() {
    local file="$1"
    python3 - "$file" <<'PY'
import re, sys
path = sys.argv[1]
rx = re.compile(r'^(?P<pre>\s*(?:\d+:)?)(?P<k>TYP)(?P<eq>\s*=\s*)(?P<v>\S+)(?P<rest>.*)$', re.I)
lines = open(path, encoding="utf-8", errors="replace").read().splitlines(keepends=True)
idx = None
for i, line in enumerate(lines):
    s = line.strip()
    if not s or s.startswith("#") or s.startswith(";"):
        continue
    m = rx.match(line.rstrip("\r\n"))
    if m:
        idx = i
if idx is None:
    sys.exit(3)
m = rx.match(lines[idx].rstrip("\r\n"))
old = m.group("v").strip().strip('"').strip("'")
if old in ("22",):
    print("keep")
    sys.exit(0)
if old not in ("2", "12"):
    print(old)
    sys.exit(4)
nl = "\n" if lines[idx].endswith("\n") else ""
lines[idx] = f"{m.group('pre')}{m.group('k')}{m.group('eq')}22{m.group('rest')}{nl}"
open(path, "w", encoding="utf-8").write("".join(lines))
print("changed")
PY
}

apply_prim() {
    local file="$1" device_com="$2"
    python3 - "$file" "$ISKRA_XML" "$ISKRA_SAMPLE" "$device_com" <<'PY'
import os, re, shutil, sys
kassa, xml, sample, device = sys.argv[1:]
rx = re.compile(r'^(?P<pre>\s*(?:\d+:)?)(?P<k>COM|SPEED)(?P<eq>\s*=\s*)(?P<v>\S+)(?P<rest>.*)$', re.I)

def load(path):
    return open(path, encoding="utf-8", errors="replace").read().splitlines(keepends=True)

def last_idx(lines, key):
    idx = None
    for i, line in enumerate(lines):
        s = line.strip()
        if not s or s.startswith("#") or s.startswith(";"):
            continue
        m = rx.match(line.rstrip("\r\n"))
        if m and m.group("k").upper() == key:
            idx = i
    return idx

def set_key(lines, key, new):
    i = last_idx(lines, key)
    if i is None:
        lines.append(f"{key}={new}\n")
        return True
    raw = lines[i].rstrip("\r\n")
    m = rx.match(raw)
    if m.group("v").strip().strip('"').strip("'") == new:
        return False
    lines[i] = f"{m.group('pre')}{m.group('k')}{m.group('eq')}{new}{m.group('rest')}\n"
    return True

cfg_changed = False
lines = load(kassa)
if set_key(lines, "COM", "127.0.0.1"):
    cfg_changed = True
if set_key(lines, "SPEED", "65501"):
    cfg_changed = True
if cfg_changed:
    open(kassa, "w", encoding="utf-8").write("".join(lines))

xml_created = False
if not os.path.isfile(xml):
    os.makedirs(os.path.dirname(xml), exist_ok=True)
    if not os.path.isfile(sample):
        sys.stderr.write(f"нет {sample}\n")
        sys.exit(2)
    shutil.copy(sample, xml)
    xml_created = True
text = open(xml, encoding="utf-8", errors="replace").read()
# Первый Device в файле — рабочий. Дальше в комментарии есть другой <Port>.
head, sep, tail = text.partition("<!--")
def sub_first(chunk, pattern, repl):
    new, n = re.subn(pattern, repl, chunk, count=1)
    return new, n > 0
head2, n1 = sub_first(head, r"(<Port>)[^<]*(</Port>)", rf"\g<1>{device}\g<2>")
head3, n2 = sub_first(head2, r"(<BaudRate>)[^<]*(</BaudRate>)", r"\g<1>19200\g<2>")
head4, n3 = sub_first(head3, r'(listenPort=")[^"]+(")', r'\g<1>65501\g<2>')
if not (n1 and n2 and n3):
    sys.stderr.write("в config.xml не нашёл Port, BaudRate или listenPort\n")
    sys.exit(3)
new_text = head4 + sep + tail
xml_changed = xml_created or new_text != text
if xml_changed:
    open(xml, "w", encoding="utf-8").write(new_text)
if cfg_changed and xml_changed:
    print("both")
elif cfg_changed:
    print("cfg")
elif xml_changed:
    print("xml")
else:
    print("keep")
PY
}

mark_loader_restart() {
    mkdir -p "$(dirname "$LOADER_PENDING")"
    date '+%Y-%m-%d %H:%M:%S' > "$LOADER_PENDING"
}

ask_loader_restart() {
    local answer
    if [[ "$ASSUME_YES" == 1 ]]; then
        log "передан --yes, перезапускаю bukts-loader"
        return 0
    fi
    [[ -r /dev/tty ]] || return 1
    while true; do
        printf '%s' "Требуется перезапуск bukts-loader, перезапускаем? [да/нет]: " >/dev/tty
        IFS= read -r answer </dev/tty || return 1
        case "$answer" in
            да|Да|ДА|д|Д|y|Y|yes|Yes|YES) return 0 ;;
            нет|Нет|НЕТ|н|Н|n|N|no|No|NO) return 1 ;;
            *) printf '%s\n' "Нужно ответить да или нет." >/dev/tty ;;
        esac
    done
}

maybe_restart_loader() {
    [[ -f "$LOADER_PENDING" ]] || return 0
    if ! ask_loader_restart; then
        say "bukts-loader не перезапускал. Установка продолжается."
        rm -f "$LOADER_PENDING"
        return 0
    fi
    log "перезапускаю bukts-loader"
    if [[ -x /etc/init.d/bukts-loader ]]; then
        /etc/init.d/bukts-loader restart || stop "bukts-loader не перезапустился"
    else
        systemctl restart bukts-loader || stop "bukts-loader не перезапустился"
    fi
    rm -f "$LOADER_PENDING"
    ok "bukts-loader перезапущен"
}

restart_dkkt() {
    log "перезапускаю службу bukts-dkkt"
    if [[ -x /etc/init.d/bukts-dkkt ]]; then
        /etc/init.d/bukts-dkkt stop || true
    fi
    # Старый Poscenter переживает сломанный pidfile и занимает COM и 4042.
    pkill -TERM -f '/opt/bukts-dkkt/poscenter/' || true
    pkill -TERM -f '/opt/bukts-dkkt/start.sh' || true
    sleep 1
    pkill -KILL -f '/opt/bukts-dkkt/poscenter/' || true
    rm -f /var/opt/bukts-dkkt/run/bukts-dkkt.pid
    if [[ -x /etc/init.d/bukts-dkkt ]]; then
        /etc/init.d/bukts-dkkt start || true
    else
        systemctl restart bukts-dkkt || true
    fi
}

wait_port() {
    local port="$1" i
    for i in $(seq 1 20); do
        if ss -lnt | grep -Eq ":${port}[[:space:]]"; then
            ok "порт ${port} слушает"
            return 0
        fi
        sleep 1
    done
    return 1
}

wait_orchestrator() {
    local i
    for i in $(seq 1 30); do
        if curl -fsS -m 3 "${API}/api/v1/info" >/dev/null 2>&1; then
            ok "оркестратор отвечает на ${API}"
            return 0
        fi
        sleep 1
    done
    return 1
}

list_kkts() {
    local i body
    for i in 1 2 3 4 5; do
        body="$(curl -sS -m 25 "${API}/api/v1/dkktList" 2>/dev/null || true)"
        if printf '%s' "$body" | python3 -c '
import json, sys
raw = sys.stdin.read().strip()
if not raw:
    sys.exit(1)
data = json.loads(raw)
sys.exit(0 if data.get("kkt") else 1)
' 2>/dev/null; then
            printf '%s' "$body" | python3 -c '
import json, sys
raw = sys.stdin.read().strip()
data = json.loads(raw)
if "error" in data:
    sys.stderr.write(json.dumps(data, ensure_ascii=False) + "\n")
    sys.exit(2)
for item in data.get("kkt") or []:
    inn = "".join((item.get("kktInn") or "").split())
    shift = (item.get("shiftState") or "").replace("|", "/")
    print("|".join([
        item.get("kktSerial") or "",
        item.get("fnSerial") or "",
        inn,
        shift,
    ]))
'
            return 0
        fi
        printf '%s\n' "$(stamp)     список ККТ ещё не готов, повтор ${i}" >&2
        sleep 3
    done
    return 1
}

instance_ids() {
    local body code
    body="$(curl -sS -m 15 -w $'\n%{http_code}' "${API}/api/v1/instances/info")" || return 2
    code="${body##*$'\n'}"
    body="${body%$'\n'*}"
    if [[ "$code" == "204" || -z "$body" ]]; then
        return 0
    fi
    if [[ "$code" != "200" ]]; then
        printf '%s\n' "$body" >&2
        return 2
    fi
    printf '%s' "$body" | python3 -c '
import json, sys
data = json.loads(sys.stdin.read() or "{}")
if "error" in data:
    sys.stderr.write(json.dumps(data, ensure_ascii=False) + "\n")
    sys.exit(2)
for item in data.get("instances") or []:
    if item.get("id"):
        print(item["id"])
'
}

has_instance() {
    local zn="$1" id ids
    ids="$(instance_ids)" || return 2
    while IFS= read -r id; do
        [[ "$id" == "$zn" ]] && return 0
    done < <(printf '%s\n' "$ids")
    return 1
}

register_kkt() {
    local zn="$1" fn="$2" inn="$3" payload response code rc
    payload="$(python3 -c 'import json,sys; print(json.dumps({"id":sys.argv[1],"kktSerial":sys.argv[1],"fnSerial":sys.argv[2],"kktInn":sys.argv[3]}, ensure_ascii=False))' "$zn" "$fn" "$inn")"
    log "создаю экземпляр ЕСМ: ЗН ${zn}, ФН ${fn}, ИНН ${inn}"
    response="$(curl -sS -m 40 -X POST \
        -H 'Content-Type: application/json' \
        -H 'User-Agent: ESM-GUI/1.0' \
        -d "$payload" \
        -w $'\n%{http_code}' \
        "${API}/api/v1/tspiot")" || die "нет ответа оркестратора при создании ${zn}"
    code="${response##*$'\n'}"
    response="${response%$'\n'*}"
    printf '%s\n' "$response"
    [[ "$code" == "200" || "$code" == "201" ]] || die "создание ${zn} вернуло HTTP ${code}"
    rc=0
    python3 -c '
import json, sys
data = json.loads(sys.argv[1])
err = data.get("error")
if err:
    text = err.get("text") or ""
    desc = err.get("description") or ""
    sys.stderr.write(text + (" — " + desc if desc else "") + "\n")
    sys.exit(1)
state = data.get("serviceState") or ""
if state and state != "Работает":
    sys.exit(2)
' "$response" || rc=$?
    if [[ "$rc" -eq 1 ]]; then
        die "оркестратор отказал в создании экземпляра ${zn}"
    fi
    if [[ "$rc" -eq 0 ]]; then
        return 0
    fi
    log "запускаю службу экземпляра ${zn}"
    response="$(curl -sS -m 30 -X POST \
        -H 'Content-Type: application/json' \
        -H 'User-Agent: ESM-GUI/1.0' \
        -d "$payload" \
        -w $'\n%{http_code}' \
        "${API}/api/v1/service/start/${zn}")" || die "нет ответа при запуске ${zn}"
    code="${response##*$'\n'}"
    [[ "$code" == "200" || "$code" == "201" ]] || die "запуск ${zn} вернул HTTP ${code}"
}

cm_status() {
    local zn="$1" body code
    body="$(curl -sS -m 15 -w $'\n%{http_code}' "${API}/api/v1/status/${zn}" || true)"
    code="${body##*$'\n'}"
    body="${body%$'\n'*}"
    [[ "$code" == "200" ]] || return 1
    printf '%s' "$body" | python3 -c '
import json, sys
data = json.load(sys.stdin)
print(((data.get("controlModule") or {}).get("status") or ""))
'
}

log_bytes() {
    local file="$1"
    if [[ -f "$file" ]]; then
        wc -c < "$file" | tr -d '[:space:]'
    else
        printf '0'
    fi
}

log_tail_has() {
    local file="$1" start="$2" needle="$3"
    [[ -f "$file" ]] || return 1
    tail -c +"$((start + 1))" "$file" | grep -a -q -F "$needle"
}

stop_kkt_license() {
    stop "Касса в кабинете ЕСМ уже может быть видна: экземпляр создан, лицензия ЕСМ получена. В ГИС МТ регистрации нет, идентификатор пустой. Касса не шифрует канал, код 55: «Отсутствует лицензия ТС ПИоТ в ККТ». Модуль после нескольких попыток блокирует установку канала на 2 часа. Пакеты эту лицензию в кассу не записывают."
}

stop_esm_license() {
    stop "Триал ТС ПИоТ в кассу записан, но лицензию самого ЕСМ сервер api.ao-esp.ru вернул пустой: в ответе license=null, в логе «jwt license пустой» и «Лицензия не найдена». Без неё модуль не начинает регистрацию в ГИС МТ и не создаёт licenses.yaml. Это не таймаут скрипта и не отказ кассы: в кабинете ЕСП на этот РНМ, ИНН, ЗН и ФН лицензии ЕСМ нет."
}

wait_gis() {
    local zn="$1" status i logf mark
    logf="/var/log/esp/esm/um/esm-cm_${zn}.log"
    mark="$(log_bytes "$logf")"
    log "жду регистрацию ${zn} в ГИС МТ, до ${GIS_WAIT} с. Смену не закрываю"
    for ((i = 0; i < GIS_WAIT; i += 5)); do
        status="$(cm_status "$zn" || true)"
        if [[ -n "$status" && "$status" == *"зарегистрирован"* && "$status" != *"ожидает"* ]]; then
            ok "${zn}: ${status}"
            return 0
        fi
        say "    ${zn}: ${status:-статус ещё пуст}"
        if log_tail_has "$logf" "$mark" "Отсутствует лицензия ТС ПИоТ в ККТ" \
            || log_tail_has "$logf" "$mark" "Установка КК заблокирована на 2ч"; then
            stop_kkt_license
        fi
        if log_tail_has "$logf" "$mark" "jwt license пустой" \
            || log_tail_has "$logf" "$mark" "Авторегистрация отложена: лицензия не получена"; then
            stop_esm_license
        fi
        sleep 5
    done
    warn "${zn}: за ${GIS_WAIT} с ГИС МТ не подтвердил регистрацию"
    warn "лог: ${logf}"
    if log_tail_has "$logf" "$mark" "Отсутствует лицензия ТС ПИоТ в ККТ" \
        || log_tail_has "$logf" "$mark" "Установка КК заблокирована на 2ч"; then
        stop_kkt_license
    fi
    if log_tail_has "$logf" "$mark" "jwt license пустой" \
        || log_tail_has "$logf" "$mark" "Авторегистрация отложена: лицензия не получена"; then
        stop_esm_license
    fi
    return 1
}

check_instance_files() {
    local zn="$1" missing=0 f
    local -a files=(
        "/etc/esp/esm/um/config_${zn}.yml"
        "/etc/esp/esm/um/db_${zn}.db"
        "/etc/esp/esm/um/licenses.yaml"
        "/etc/esp/esm/um/server.crt"
        "/etc/esp/esm/um/server.pem"
    )
    for f in "${files[@]}"; do
        if [[ -e "$f" ]]; then
            ok "есть ${f}"
        else
            warn "нет ${f}"
            missing=1
        fi
    done
    return "$missing"
}

instance_port() {
    local zn="$1"
    curl -fsS -m 15 "${API}/api/v1/instances/info" | python3 -c '
import json, sys
zn = sys.argv[1]
data = json.load(sys.stdin)
for item in data.get("instances") or []:
    if item.get("id") == zn:
        print(item.get("port") or "")
        break
' "$zn"
}

register_all() {
    local kkt_raw serial fn inn shift seen=0 matched=0 failed=0 present_rc port
    if ! wait_orchestrator; then
        if [[ "$CHECK" == 1 ]]; then
            warn "оркестратор не запущен, в режиме --check регистрацию не смотрю"
            return 0
        fi
        die "оркестратор не ответил на ${API}. Служба esm-orchestrator должна быть запущена"
    fi
    kkt_raw="$(list_kkts)" || {
        if [[ "$CHECK" == 1 ]]; then
            warn "оркестратор не отдал список ККТ"
            return 0
        fi
        die "оркестратор не отдал список ККТ"
    }
    while IFS='|' read -r serial fn inn shift; do
        [[ "$serial" =~ ^[0-9]+$ ]] || continue
        seen=1
        if [[ -n "$ONLY_ZN" && "$serial" != "$ONLY_ZN" ]]; then
            continue
        fi
        matched=1
        log "касса ЗН ${serial}, ФН ${fn}, ИНН ${inn}, смена: ${shift:-неизвестно}"
        if [[ "$shift" == "Открыта" ]]; then
            say "    смена открыта: обновление ККТ оркестратор пропустит, регистрацию экземпляра это не останавливает"
        else
            say "    смена по кассе закрыта: ГИС МТ ставит контрольный канал только после открытия смены"
            if [[ "$CHECK" == 1 ]]; then
                log "[check] отправил бы на 65522 команду @!SHBEG;TXT=Оператор"
            elif ask_open_shift; then
                open_kassa_shift
            else
                stop "Смена по кассе закрыта. ГИС МТ регистрирует кассу только после открытия смены."
            fi
        fi
        if [[ -z "$fn" || -z "$inn" ]]; then
            warn "у ${serial} нет ФН или ИНН"
            failed=1
            continue
        fi
        if [[ "$CHECK" == 1 ]]; then
            log "[check] зарегистрировал бы ${serial}"
            continue
        fi
        present_rc=0
        has_instance "$serial" || present_rc=$?
        if [[ "$present_rc" -eq 2 ]]; then
            die "не прочитал список экземпляров ЕСМ"
        fi
        if [[ "$present_rc" -eq 0 ]]; then
            ok "экземпляр ${serial} уже есть"
        else
            register_kkt "$serial" "$fn" "$inn"
        fi
        wait_gis "$serial" || failed=1
        check_instance_files "$serial" || failed=1
        port="$(instance_port "$serial" || true)"
        [[ -n "$port" ]] || port="50401"
        if ss -lnt | grep -Eq ":${port}[[:space:]]"; then
            ok "модуль ${serial} слушает ${port}"
        else
            warn "порт модуля ${port} не слушает"
            failed=1
        fi
        if ss -lnt | grep -Eq ':51401[[:space:]]'; then
            ok "API ТС ПИоТ слушает 51401"
        else
            warn "порт 51401 не слушает"
            failed=1
        fi
    done < <(printf '%s\n' "$kkt_raw")
    if [[ "$seen" == 0 ]]; then
        if [[ "$CHECK" == 1 ]]; then
            warn "список ККТ пуст"
            return 0
        fi
        if ss -lnt | grep -Eq ':4042[[:space:]]'; then
            stop "Список ККТ пуст, хотя порт 4042 слушает. Смотрите /var/log/esp/esm/um/esm-orchestrator.log"
        fi
        stop "Список ККТ пуст и порт 4042 не слушает."
    fi
    if [[ -n "$ONLY_ZN" && "$matched" == 0 ]]; then
        stop "В списке ККТ нет заводского номера ${ONLY_ZN}"
    fi
    return "$failed"
}

prim_fw_from_log() {
    [[ -f /var/log/dkkt/dkkt.log ]] || return 1
    grep -a 'Версия ПО' /var/log/dkkt/dkkt.log | tail -n 1 | sed -n 's/.*Версия ПО:[[:space:]]*//p'
}

prim_fw_class() {
    python3 - "$1" "$MIN_FW_PRIM" <<'PY'
import re, sys
got, minimum = sys.argv[1].strip(), sys.argv[2].strip()

def parse(text):
    m = re.search(r"(\d+(?:\.\d+)*)\S*\s+(\d{2})-(\d{2})-(\d{2})", text)
    if not m:
        return None
    nums = tuple(int(x) for x in m.group(1).split("."))
    day, month, yy = int(m.group(2)), int(m.group(3)), int(m.group(4))
    year = 2000 + yy if yy < 80 else 1900 + yy
    return nums, (year, month, day)

cur, base = parse(got), parse(minimum)
if cur is None or base is None:
    print("BAD " + got)
    sys.exit(0)
if cur < base:
    print("LOW " + got)
elif cur > base:
    print("HIGH " + got)
else:
    print("OK " + got)
PY
}

judge_prim_firmware() {
    local raw class ver
    raw="$(prim_fw_from_log)" || return 1
    [[ -n "$raw" ]] || return 1
    say "    Прим, версия ПО: ${raw}"
    class="$(prim_fw_class "$raw")" || true
    ver="${class#* }"
    case "${class%% *}" in
        HIGH) ok "прошивка ${ver} выше ${MIN_FW_PRIM}, для ТС ПИоТ подходит" ;;
        OK) ok "прошивка ${ver}, минимальная ${MIN_FW_PRIM}, подходит" ;;
        *) stop "Обновите кассу до прошивки ${MIN_FW_PRIM}" ;;
    esac
    PRIM_FW_DONE=1
    return 0
}

require_shtrih_firmware() {
    local active typ stat fw_line fw_class fw_ver
    [[ -f "$KASSA_MAIN" || -f "$KASSA_CONF" ]] || return 0
    active="$(active_kassa_file)" || return 0
    typ="$(kassa_get "$active" TYP)"
    case "$typ" in
        2|12|22)
            log "Штрих TYP=${typ}: проверяю прошивку до установки пакетов"
            if ! ss -lnt | grep -Eq ':65522[[:space:]]'; then
                stop "Порт 65522 не слушает, прошивку Штриха спросить не у кого. Установку останавливаю."
            fi
            stat="$(read_firmware)" || stop "Касса не ответила на @!STAT;TYP=1. Порт 65522 уже закрыт. Установку останавливаю."
            [[ -n "$stat" ]] || stop "Касса не сообщила STAT. Порт 65522 уже закрыт. Установку останавливаю."
            say "    ответ кассы: ${stat:0:300}"
            fw_line="$(firmware_ok_for shtrih "$stat" "$MIN_FW")" || true
            fw_class="${fw_line%% *}"
            fw_ver="${fw_line#* }"
            FW_VER="$fw_ver"
            case "$fw_class" in
                HIGH|OK)
                    if [[ "$fw_ver" == D* ]]; then
                        ok "прошивка ${fw_ver} подходит для ТС ПИоТ, настраивается как Штрих"
                    else
                        ok "прошивка ${fw_ver} подходит для ТС ПИоТ"
                    fi
                    ;;
                T3) stop "Прошивка ${fw_ver} для ТС ПИоТ не подходит. Нужна Штрих не ниже ${MIN_FW} или Ритейл D3 не ниже 01.04.2026." ;;
                *) stop "Обновите кассу. Для Штриха нужна не ниже ${MIN_FW}, для Ритейла не ниже D3 от 01.04.2026. Прошивка T3 не подходит." ;;
            esac
            SHTRIH_FW_DONE=1
            ;;
        1|11)
            log "Прим TYP=${typ}: версию из STAT не читаю, её там нет"
            if judge_prim_firmware; then
                return 0
            fi
            say "    драйвер Искра ещё не записал версию ПО, проверю после его запуска"
            ;;
        *)
            warn "TYP=${typ:-пусто}: прошивку пока не проверяю"
            ;;
    esac
}

configure_chznak() {
    local result
    log "настраиваю ${CHZNAK_INI}: локальный ЕСМ https://127.0.0.1:51401"
    if [[ "$CHECK" == 1 ]]; then
        log "[check] закомментировал бы BUKSERVER, DEBUG, DEBUG_ECHO и дописал LOCAL_SERVER_*"
        return 0
    fi
    mkdir -p "$(dirname "$CHZNAK_INI")"
    [[ -f "$CHZNAK_INI" ]] && backup_file "$CHZNAK_INI"
    result="$(python3 - "$CHZNAK_INI" <<'PY'
import sys
path = sys.argv[1]
wanted = {
    "LOCAL_SERVER_ADDRESS": "https://127.0.0.1",
    "LOCAL_SERVER_PORT": "51401",
    "LOCAL_SERVER_TIMEOUT": "6000",
}
comment_keys = ("BUKSERVER", "DEBUG", "DEBUG_ECHO")
try:
    lines = open(path, encoding="utf-8", errors="replace").read().splitlines()
except FileNotFoundError:
    lines = []
out = []
seen = set()
changed = False
for line in lines:
    raw = line.strip()
    body = raw[1:].strip() if raw.startswith("#") or raw.startswith(";") else raw
    if "=" in body:
        key = body.split("=", 1)[0].strip()
    else:
        key = ""
    if key in comment_keys:
        value = body.split("=", 1)[1].strip() if "=" in body else ""
        new = f"#{key}={value}" if value else f"#{key}"
        if line != new:
            changed = True
        out.append(new)
        continue
    if key in wanted:
        new = f"{key}={wanted[key]}"
        if line != new:
            changed = True
        out.append(new)
        seen.add(key)
        continue
    out.append(line)
for key, value in wanted.items():
    if key not in seen:
        out.append(f"{key}={value}")
        changed = True
text = "\n".join(out).rstrip() + "\n"
if changed or not lines:
    open(path, "w", encoding="utf-8").write(text)
print("changed" if changed or not lines else "keep")
PY
)"
    if [[ "$result" == "changed" ]]; then
        ok "в config.ini закомментированы BUKSERVER, DEBUG, DEBUG_ECHO и задан локальный ЕСМ"
        if [[ -x /etc/init.d/bukts-chznak ]]; then
            log "перезапускаю bukts-chznak, чтобы он перечитал config.ini"
            /etc/init.d/bukts-chznak restart || warn "bukts-chznak не перезапустился"
        fi
    else
        ok "config.ini Честного Знака уже указывает на локальный ЕСМ"
    fi
}

configure_kassa() {
    local active web=0 typ com speed stat fw_rc fw_line family changed=0
    [[ -f "$KASSA_MAIN" || -f "$KASSA_CONF" ]] || die "нет ${KASSA_MAIN}"
    if [[ -f "$KASSA_CONF" ]]; then
        web=1
        warn "есть ${KASSA_CONF}: тип, COM и SPEED кассы задаются веб-интерфейсом"
        if [[ -f "$KASSA_ENABLED" ]]; then
            say "    файл ${KASSA_ENABLED} есть, служба читает настройки из conf"
        else
            say "    файла ${KASSA_ENABLED} нет, служба сейчас читает ${KASSA_MAIN}"
            say "    скрипт всё равно не правит cfg, пока лежит копия в conf"
        fi
    fi
    active="$(active_kassa_file)" || die "не нашёл действующий bukts-kassa.cfg"
    typ="$(kassa_get "$active" TYP)"
    com="$(kassa_get "$active" COM)"
    speed="$(kassa_get "$active" SPEED)"
    log "действующий файл ${active}"
    log "TYP=${typ:-пусто} COM=${com:-пусто} SPEED=${speed:-пусто}"
    configure_chznak
    [[ -n "$typ" && -n "$com" && -n "$speed" ]] || die "в ${active} нет TYP, COM или SPEED"

    case "$typ" in
        2|12|22) family="shtrih" ;;
        11) family="prim" ;;
        1)
            stop "TYP=1, это старый Прим. Скрипт тип не меняет. Драйвер Искра из bukts-dkkt запускается только при TYP=11, поэтому регистрацию ТС ПИоТ не начинаю."
            ;;
        *) stop "TYP=${typ} не Штрих (2, 12, 22) и не Прим (11). Ничего не меняю." ;;
    esac

    if [[ "$family" == "shtrih" && "$SHTRIH_FW_DONE" == 1 ]]; then
        ok "прошивка уже проверена, повторно кассу не занимаю"
    elif [[ "$family" == "prim" ]]; then
        say "    Прим: в STAT версии прошивки нет, её присылает сама касса драйверу Искра"
    else
    if ! ss -lnt | grep -Eq ':65522[[:space:]]'; then
        stop "Порт 65522 не слушает, прошивку спросить не у кого. Кассовая программа сейчас не запущена."
    fi
    stat="$(read_firmware)" || stop "Касса не ответила на @!STAT;TYP=1. Порт 65522 уже закрыт, повторите запуск скрипта."
    [[ -n "$stat" ]] || stop "Касса приняла соединение, но STAT пустой. Порт 65522 уже закрыт."
    say "    ответ кассы: ${stat:0:300}"
    fw_line="$(firmware_ok_for "$family" "$stat" "$MIN_FW")" || true
    local fw_class="${fw_line%% *}" fw_ver="${fw_line#* }"
    case "$fw_class" in
        NOVER)
            if [[ "$family" == "prim" ]]; then
                say "    у Прима в STAT нет версии прошивки, смотрю её в журнале драйвера Искра"
            else
                stop "Обновите кассу. Для Штриха нужна не ниже ${MIN_FW}, для Ритейла не ниже D3 от 01.04.2026. Прошивка T3 не подходит."
            fi
            ;;
        T3|LOW|BAD)
            stop "Прошивка ${fw_ver} для ТС ПИоТ не подходит. Нужна Штрих не ниже ${MIN_FW} или Ритейл D3 не ниже 01.04.2026."
            ;;
        HIGH|OK)
            FW_VER="$fw_ver"
            if [[ "$fw_ver" == D* ]]; then
                ok "прошивка ${fw_ver} подходит для ТС ПИоТ, настраивается как Штрих"
            else
                ok "прошивка ${fw_ver} подходит для ТС ПИоТ"
            fi
            ;;
        *)
            stop "Не разобрал версию прошивки: ${fw_line}"
            ;;
    esac
    fi

    if [[ "$family" == "shtrih" ]]; then
        if [[ "$FW_VER" == D* ]]; then
            log "протокол Ритейл D3, тип и порт как у Штриха"
        else
            log "протокол Штрих"
        fi
        if [[ "$typ" == "22" ]]; then
            ok "TYP уже 22"
        elif [[ "$web" == 1 ]]; then
            stop "Тип кассы сейчас ${typ}. В веб-интерфейсе поставьте 22 и запустите скрипт снова. Файлы скрипт не менял."
        else
            if [[ "$CHECK" == 1 ]]; then
                log "[check] поменял бы TYP ${typ} на 22 в ${active}"
            else
                local shtrih_result shtrih_rc=0
                backup_file "$active"
                shtrih_result="$(apply_shtrih_typ "$active")" || shtrih_rc=$?
                if [[ "$shtrih_rc" -ne 0 ]]; then
                    stop "Не смог заменить TYP ${typ} на 22 в ${active} (код ${shtrih_rc}). Копия файла: ${BACKUP_DIR}"
                fi
                ok "TYP ${typ} заменён на 22 в ${active} (${shtrih_result})"
                changed=1
                mark_loader_restart
            fi
        fi
        say "    COM и SPEED Штриха не меняю: кассу по порту держит bukkassa"
        ensure_its_license "$com" "$speed"
    else
        log "протокол Прим, TYP ${typ} оставляю"
        local device="$com"
        if [[ "$device" == "127.0.0.1" ]]; then
            device=""
            if [[ -f "$ISKRA_XML" ]]; then
                device="$(python3 -c '
import re,sys
text=open(sys.argv[1],encoding="utf-8",errors="replace").read().split("<!--",1)[0]
m=re.search(r"<Port>([^<]+)</Port>", text)
print(m.group(1).strip() if m else "")
' "$ISKRA_XML")"
            fi
            if [[ -z "$device" || "$device" == "127.0.0.1" ]]; then
                stop "В bukts-kassa.cfg уже COM=127.0.0.1, а в iskra/config.xml нет порта кассы. Укажите его в <Port> вручную и запустите скрипт снова."
            fi
            local xml_baud=""
            xml_baud="$(python3 -c '
import re,sys
text=open(sys.argv[1],encoding="utf-8",errors="replace").read().split("<!--",1)[0]
m=re.search(r"<BaudRate>([^<]+)</BaudRate>", text)
print((m.group(1).strip() if m else ""))
' "$ISKRA_XML")"
            if [[ "$device" == "/dev/ttyUSB0" && "$xml_baud" == "9600" ]]; then
                stop "В iskra/config.xml остался образец /dev/ttyUSB0 и 9600. Туда нужен настоящий порт кассы и скорость 19200. cfg уже указывает на 127.0.0.1, угадывать порт не буду."
            fi
            say "    физический порт уже вынесен в драйвер Искра: ${device}"
        fi
        if [[ "$web" == 1 && ( "$com" != "127.0.0.1" || "$speed" != "65501" ) ]]; then
            stop "В веб-интерфейсе поставьте COM=127.0.0.1 и SPEED=65501. Сейчас COM=${com} SPEED=${speed}. cfg скрипт не менял."
        fi
        if [[ "$CHECK" == 1 ]]; then
            log "[check] в ${ISKRA_XML} поставил бы порт ${device} и скорость 19200, listenPort 65501"
            log "[check] в ${active} поставил бы COM=127.0.0.1 и SPEED=65501"
        else
            [[ -f "$ISKRA_XML" ]] && backup_file "$ISKRA_XML"
            backup_file "$active"
            local prim_result
            prim_result="$(apply_prim "$active" "$device")"
            if [[ -f "$ISKRA_XML" ]]; then
                chown bukts:bukts "$ISKRA_XML" 2>/dev/null || true
            fi
            case "$prim_result" in
                cfg|both)
                    ok "Прим: драйвер ${device} @ 19200, касса COM=127.0.0.1 SPEED=65501"
                    changed=1
                    mark_loader_restart
                    ;;
                xml)
                    ok "Прим: в драйвере Искра порт ${device} и скорость 19200"
                    changed=1
                    ;;
                *)
                    ok "Прим уже настроен: ${device} @ 19200, касса 127.0.0.1:65501"
                    ;;
            esac
        fi
    fi

    if [[ "$CHECK" == 1 ]]; then
        say "    режим --check: службу bukts-dkkt не перезапускаю"
        return 0
    fi
    if [[ "$family" == "shtrih" ]] && pgrep -f '/opt/bukts-dkkt/poscenter/' >/dev/null 2>&1; then
        log "старый драйвер Poscenter держит кассу и порт 4042, останавливаю его"
        restart_dkkt || true
        if ! ss -lnt | grep -Eq ':65522[[:space:]]'; then
            mark_loader_restart
        fi
    fi
    if [[ "$family" == "shtrih" ]] && ! ss -lnt | grep -Eq ':4042[[:space:]]'; then
        log "для Штриха порт 4042 должен открыть bukkassa, для этого нужен перезапуск bukts-loader"
        mark_loader_restart
    fi
    if [[ -f "$LOADER_PENDING" ]]; then
        maybe_restart_loader
    fi
    if [[ "$changed" == 1 ]]; then
        if ! restart_dkkt; then
            warn "служба bukts-dkkt не перезапустилась"
            if [[ "$family" == "shtrih" ]]; then
                say "    для Штрих это не всегда поломка: при TYP=22 start.sh сразу завершается, кассу держит bukkassa"
            fi
        fi
    else
        say "    настройки кассы не менялись, bukts-dkkt не перезапускаю"
    fi

    if [[ "$family" == "shtrih" ]]; then
        say "    для TYP=22 bukts-dkkt сам сразу выходит: список ККТ идёт от bukkassa :4042"
        if ! wait_port 4042; then
            stop "Порт 4042 не слушает. Для Штриха его открывает bukkassa, не служба bukts-dkkt. Если тип только что стал 22, кассовый процесс ещё может работать со старым типом. bukts-loader скрипт не перезапускает."
        fi
    else
        if ! wait_port 65501 || ! wait_port 4042; then
            stop "Драйвер Искра не открыл 65501 или 4042. Если COM в cfg только что сменился на 127.0.0.1, bukkassa может ещё держать старый последовательный порт. bukts-loader скрипт не перезапускает. Файл настроек уже записан, после освобождения порта запустите скрипт снова."
        fi
        if [[ "$PRIM_FW_DONE" != 1 ]]; then
            local i prim_ok=0
            for i in $(seq 1 15); do
                if judge_prim_firmware; then
                    prim_ok=1
                    break
                fi
                sleep 1
            done
            if [[ "$prim_ok" != 1 ]]; then
                stop "Обновите кассу до прошивки ${MIN_FW_PRIM}"
            fi
        fi
    fi
}

start_services() {
    if [[ "$CHECK" == 1 ]]; then
        return 0
    fi
    systemctl enable esm-orchestrator >/dev/null 2>&1 || true
    systemctl start esm-orchestrator || die "не запустился esm-orchestrator"
    if pkg_installed esm-lm-controller; then
        systemctl enable esm-lm-controller >/dev/null 2>&1 || true
        systemctl start esm-lm-controller || warn "esm-lm-controller не запустился"
    fi
}

http_up() {
    local url="$1" code
    code="$(curl -k -sS -m 8 -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true)"
    [[ -n "$code" && "$code" != "000" ]]
}

verify_ready() {
    local failed=0 body ids id status port code
    log "Проверяю, что всё отвечает"
    if http_up "${API}/api/v1/info"; then
        ok "оркестратор ${API} отвечает"
    else
        warn "оркестратор ${API} не ответил"
        failed=1
    fi
    body="$(curl -fsS -m 8 "${API}/api/v1/dkktList" 2>/dev/null || true)"
    if printf '%s' "$body" | python3 -c 'import json,sys; d=json.load(sys.stdin); raise SystemExit(0 if d.get("kkt") else 1)' 2>/dev/null; then
        ok "список ККТ получен"
    else
        warn "список ККТ пустой или оркестратор его не отдал"
        failed=1
    fi
    ids="$(instance_ids)" || ids=""
    if [[ -z "$ids" ]]; then
        warn "нет зарегистрированных экземпляров ЕСМ"
        failed=1
    fi
    while IFS= read -r id; do
        [[ -n "$id" ]] || continue
        if [[ -n "$ONLY_ZN" && "$id" != "$ONLY_ZN" ]]; then
            continue
        fi
        status="$(cm_status "$id" || true)"
        port="$(instance_port "$id" 2>/dev/null || true)"
        [[ -n "$port" ]] || port="50401"
        if [[ "$status" == *"зарегистрирован"* && "$status" != *"ожидает"* ]]; then
            ok "ЕСМ ${id}: ${status}"
        else
            warn "ЕСМ ${id}: ${status:-нет статуса}"
            failed=1
        fi
        if ss -lnt | grep -Eq ":${port}[[:space:]]"; then
            ok "модуль ${id} слушает ${port}"
        else
            warn "модуль ${id}: порт ${port} не слушает"
            failed=1
        fi
        if systemctl is-active --quiet "esm-cm-${id}"; then
            ok "служба esm-cm-${id} запущена"
        else
            warn "служба esm-cm-${id} не запущена"
            failed=1
        fi
    done < <(printf '%s\n' "$ids")
    code="$(curl -k -sS -m 8 -o /dev/null -w '%{http_code}' "https://127.0.0.1:51401/" 2>/dev/null || true)"
    if [[ -n "$code" && "$code" != "000" ]]; then
        ok "API ТС ПИоТ https://127.0.0.1:51401 отвечает"
    else
        warn "API ТС ПИоТ на порту 51401 не ответил"
        failed=1
    fi
    if ss -lnt | grep -Eq ':4042[[:space:]]'; then
        ok "ДККТ на порту 4042 слушает"
    else
        warn "порт 4042 не слушает"
        failed=1
    fi
    note_lm
    if [[ "$failed" != 0 ]]; then
        die "Не всё отвечает. Установка не закончена, см. строки выше."
    fi
    echo
    ok "Всё работает."
}

note_lm() {
    if ss -lnt | grep -Eq ':5995[[:space:]]'; then
        ok "ЛМ ЧЗ слушает 5995"
    else
        warn "ЛМ ЧЗ на порту 5995 нет. Контроллер esm-lm-controller сам модуль Честного знака не заменяет"
        warn "онлайн-проверка через ГИС МТ от этого не зависит"
    fi
}

main() {
    local arch failed
    need curl
    need python3
    need dpkg
    need sha256sum
    need ss
    need systemctl
    need flock
    need apt-get
    need apt-cache
    mkdir -p /var/lock
    exec 9>/var/lock/esm-tspiot-setup.lock
    flock -n 9 || stop "Скрипт уже запущен в другом сеансе."
    arch="$(dpkg --print-architecture)"
    [[ "$arch" == "amd64" ]] || die "esm в репозитории собран под amd64, здесь ${arch}"

    log "Проверяю кассу"
    require_shtrih_firmware
    log "Проверяю пакеты"
    apt_refresh || true
    install_planned
    if [[ "$CHECK" == 1 ]]; then
        configure_kassa
        say "Режим --check закончен. Пакеты, настройки и регистрацию не выполнял."
        return 0
    fi
    log "Настраиваю кассу и регистрирую ЕСМ"
    configure_kassa
    start_services
    failed=0
    register_all || failed=1
    if [[ "$failed" != 0 ]]; then
        die "Регистрация в ГИС МТ не завершилась, см. строки выше."
    fi
    verify_ready
}

main "$@"
