Hasenradar — веб-приложение для мониторинга и диагностики печей 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, которая может не выполниться. - Понятные ошибки. Ошибка разбора не даёт запустить скрипт, ошибка выполнения останавливает его на нужной строке. В обоих случаях указаны строка и столбец.

Как его используют
Простой пример — отчёт по нескольким печам. Ошибка чтения одной печи попадает в вывод, но не останавливает остальные:
#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позволила менять поведение, не трогая то, что уже работает.