Hasenradar — веб-приложение для мониторинга и диагностики печей HASE. Сервисные техники смотрят в нём телеметрию, графики и ошибки, а для всего, что приходится повторять, в приложении есть терминал со своим языком скриптов — .hase. Рассказываю, зачем он понадобился, что пошло не так в первой версии и как устроена вторая.

Редактор .hase с подсветкой синтаксиса и проверкой кода
Редактор .hase. Скриншоты сняты на встроенных симуляторах, без данных клиентов.

Зачем отдельный язык

Многие задачи техника состоят из одних и тех же шагов: подключиться к печи, прочитать параметр, дождаться нужной температуры, собрать отчёт по нескольким устройствам, поправить запись в базе. Руками это долго, и на десятом повторе легко ошибиться.

Дать для этого JavaScript нельзя. Код, который выполняется прямо в приложении, получил бы доступ ко всему: к сессии пользователя, к базе, к данным других устройств. Нужен был язык, в котором есть только разрешённые действия, а всё, что меняет данные, видно до запуска.

Первая версия: команды терминала

Первая версия выросла из команд терминала. Значения хранились как текст, переменные писались через $, арифметика шла отдельной командой calc. Так выглядел пример из документации: прочитать температуру и, если она выше порога, записать новое значение параметра.

get T as temp
if $temp > 400 {
  set PL 80
}

Для коротких действий этого хватало. Но скрипты становились длиннее, и вместе с ними росло число ошибок, которых не было видно.

Что пошло не так

В августе 2026 года я разбирал первую версию и нашёл ошибки, у которых была общая черта: язык ошибался молча.

  • Терминал, редактор и проверка кода в редакторе разбирали скрипт каждый по-своему. Они по-разному понимали, где заканчивается строка и что считать числом, и каждое расхождение давало неправильный результат без сообщения об ошибке.
  • Одиночный знак = в условии, например if $a = 5 { }, загонял разбор в бесконечный цикл, и вкладка браузера зависала.
  • В JavaScript Number('') равно нулю. Поэтому опечатка в имени переменной в условии if $typo < 100 { set ... } давала «истину», и запись уходила на печь.
  • Кавычки снимались раньше, чем значение распознавалось, поэтому if $state == "connected" сравнивал не со строкой, а с флагом подключения.
  • Окно подтверждения опасных команд проверяло только верхний уровень скрипта, и удаление внутри if проходило без подтверждения.
  • Длинные ID контроллеров проходили через Number() и возвращались в базу в виде 1.0000283e+21.
  • После одного нажатия Stop все следующие циклы до перезагрузки страницы останавливались сразу. Скрипт всегда заканчивался сообщением «Script completed», даже если упал на третьей строке. А set сообщал об успехе, как только запись вставала в очередь, а не когда база её действительно сохраняла.

Исправление началось с главного: у языка остался один разборщик, общий для терминала, редактора и проверки, и тесты к нему. Ошибки синтаксиса теперь называют проблему и показывают строку и столбец. Сравнение с неизвестной переменной стало ошибкой, запись ждёт настоящего ответа базы, а Stop убирает из очереди команды, которые ещё не ушли на печь.

Вторая версия

В конце сентября появилась вторая версия языка. Скрипт выбирает её строкой #version 2, а старые скрипты без этой строки работают как раньше.

#version 2
fn classify(t, limit) {
  if t == null { return "unknown" }
  if t >= limit { return "warm" }
  return "cool"
}
let readings = [80, 120, null]
for value in readings {
  print(value, classify(value, 100))
}

Синтаксис знаком по JavaScript и Python: let, fn, if, for, while, try и catch. Но поведение отличается там, где JavaScript легко ошибается тихо:

  • Сравнение не приводит типы: 42 == "42" ложно. Отсутствующее значение — это null, а не ноль, а обращение к необъявленной переменной — ошибка.
  • ID контроллеров, серийные номера и коды с ведущими нулями пишутся строками. Целые числа, которые нельзя сохранить точно, язык отклоняет.
  • let copy = original копирует данные, и изменение копии не меняет оригинал.
  • У скрипта нет доступа к объектам браузера, сети, файлам, учётным данным и eval. Вызывать можно только функции из встроенного списка: работа с текстом, списками, JSON и статистикой, а также функции самого приложения вроде device.snapshot() и device.wait().

Как он выполняется

Скрипт никогда не превращается в JavaScript. Текст разбивается на токены, из них строится дерево, а интерпретатор обходит это дерево узел за узлом. Разбор и выполнение второй версии занимают около тысячи строк TypeScript.

Такой подход даёт контроль, которого не было бы у eval:

  • Лимиты. Не больше 100 000 шагов выполнения, глубина вызовов до 64, до 10 000 элементов в коллекции. Бесконечный цикл заканчивается ошибкой, а не зависшей вкладкой.
  • Остановка. Интерпретатор регулярно отдаёт управление браузеру, поэтому Stop срабатывает сразу. Команды, которые ещё не отправлены на печь, убираются из очереди. Запись, которая уже ушла, отменить нельзя, и документация говорит об этом прямо.
  • Проверка перед запуском. До выполнения интерпретатор собирает все вызовы из дерева. Записи на устройство и в базу показываются на подтверждение, даже если они стоят в ветке if, которая может не выполниться.
  • Понятные ошибки. Ошибка разбора не даёт запустить скрипт, ошибка выполнения останавливает его на нужной строке. В обоих случаях указаны строка и столбец.
Терминал с выводом скрипта .hase
Команды терминала и вывод скрипта

Как его используют

Простой пример — отчёт по нескольким печам. Ошибка чтения одной печи попадает в вывод, но не останавливает остальные:

#version 2
let ids = ["controller-A", "controller-B"]
let rows = [["ID", "T"]]
for id in ids {
  try {
    let data = device.snapshot(id)
    if data != null && type(data.T) == "number" {
      rows = push(rows, [id, data.T])
    }
  } catch error {
    print(id, error)
  }
}
print(csv.stringify(rows))

Самые большие скрипты чинят историю сжигания сразу на всех печах. В них несколько сотен строк, и скрипт очистки истории начинается с режима просмотра и проверки, что выбрана нужная база:

#version 2
const DRY_RUN = true    # сначала только посмотреть, ничего не менять
const BATCH_SIZE = 25
const EXPECTED_DATABASE = "…"
assert(firebase.url() == EXPECTED_DATABASE, "STOPP: Falsche Datenbank ausgewählt!")

Печей много, а лимит шагов действует на один запуск, поэтому такие проверки идут пакетами. Скрипт обрабатывает 25 устройств, скачивает результат в JSON и через script.nextBatch() планирует следующий пакет. Каждый пакет начинается заново со свежими переменными, а между пакетами переносится только явно переданное состояние.

Окна из скриптов

Скрипт может открыть настоящее окно Hasenradar. Этот виджет показывает текущие коды ошибок подключённой печи и обновляется сам, без цикла и таймера:

#version 2
#name Fehler
ui.widget({
  title: "Fehler",
  width: 200,
  height: 96,
  items: [{type: "errors"}]
})

Кнопки в окнах работают без колбэков: ui.wait() ждёт нажатия и возвращает его как обычное значение, а что делать дальше, скрипт решает в обычном цикле. Скрипт можно сохранить под своей командой терминала и запускать при открытии приложения. Для тех, кто не хочет писать код, есть визуальный конструктор, который генерирует код сам.

Визуальный конструктор окна с предпросмотром
Визуальный конструктор: панель горения с живыми данными демо-печи

Редактор и помощник

Скрипты пишутся в редакторе Monaco, том же, что в VS Code: подсветка, автодополнение, подсказки при наведении и ошибки прямо в тексте. Скрипт можно сохранить у себя в браузере или в общей библиотеке команды.

Вместе со второй версией в редакторе появился помощник на Gemini. Он пишет, правит и объясняет скрипты по описанию на обычном языке. Перед отправкой он показывает запрос целиком и скрывает известные чувствительные данные, а предложенный код проверяет на синтаксис и известные функции. В октябре помощника выключили, чтобы остановить расходы на генерацию.

Что я из этого вынес

  • У языка должен быть один разборщик. Как только терминал и редактор понимают код по-разному, ошибки становятся незаметными.
  • Ошибка должна быть громкой: с текстом, строкой и столбцом. Скрипт, который молча идёт по ветке else, опаснее скрипта, который падает.
  • Всё, что меняет данные или оборудование, должно быть видно до запуска.
  • Новая версия языка не должна ломать старые скрипты. Строка #version 2 позволила менять поведение, не трогая то, что уже работает.