Разработчикам
Связь устройств через контракты.
У каждого устройства Subrose есть свой контракт в TON — небольшая программа в блокчейне, которая хранит владельца, обслуживающую ноду и параметры устройства. Контракты в TON обмениваются сообщениями между собой, поэтому контракт одного устройства можно научить отправлять сообщение контракту другого: второе устройство меняет параметр, а обслуживающая нода доводит изменение до самого железа за секунды.
Готовых блоков для такой связи в контракте устройства нет. Логика пишется на Tact, языке смарт-контрактов TON, и приезжает на устройство обновлением кода на месте — адрес контракта при этом сохраняется. Ниже — весь путь: сообщение, приёмник, проверка отправителя, выкатка и проверка результата.
Сегодня контракт устройства принимает от других контрактов только пополнение баланса и снимок владельцев от своей группы, а остальные сообщения молча пропускает. Свой приёмник появляется в нём вместе с обновлением кода.
01 · Механизмы
Два способа связать два устройства.
Правило живёт в TON
Контракт первого устройства шлёт сообщение контракту второго, второй проверяет отправителя и меняет своё состояние. Правило исполняется само, без сервера посередине, и каждый его запуск остаётся в истории контракта. Отправка оплачивается газом (комиссией сети) с баланса контракта-отправителя.
Правило живёт в своём коде
Скрипт читает события первого устройства через HTTP API ноды и отдаёт команду второму. Реакция мгновенная, менять логику можно хоть каждый день, но работает она ровно столько, сколько работает сервер разработчика. Рецепты — на странице «События устройства».
Способы выбираются по цене ошибки. Правило, которое должно сработать даже когда чужой сервер выключен, стоит того, чтобы жить в TON. Остальное дешевле держать в скрипте.
02 · Требования
Что нужно до первой строчки кода.
yarn install и yarn build в services/ton.Оба устройства при этом остаются обычными устройствами сети: своя нода, свой владелец, своя оплата. Совпадать у них ничего не обязано, и в одной группе они тоже быть не должны.
03 · Образец
Такой обмен уже работает внутри системы.
Группа устройств рассылает своим устройствам снимок списка владельцев, и контракт устройства этот снимок принимает. Приёмник занимает несколько строк, и в них видно всё, что нужно повторить в своём коде: проверка отправителя по адресу и защита от повторов по номеру версии.
Отправитель со своей стороны проверяет баланс до рассылки, чтобы сообщения ушли всем адресатам разом, и шлёт их без возврата при ошибке.
// Device.tact — приёмник снимка владельцев
receive(msg: SyncOwners) {
throwUnless(self.ErrorNotGroup, sender() == self.group);
if (msg.ownersVersion > self.syncedOwnersVersion) {
self.groupOwners = msg.owners;
self.syncedOwnersVersion = msg.ownersVersion;
}
}
04 · Рецепт
Сигнал от датчика к исполнителю за четыре шага.
Сценарий для примера: камера у гаража заметила движение ночью и
отправляет сигнал alarm. Второе устройство по этому
сигналу встаёт на замок — флаг lock, при котором агент
перестаёт исполнять команды. Все фрагменты ниже добавляются в контракт
устройства services/ton/src/Device.tact.
Шаг 1 — описать сообщение
Сообщение — это структура с именем и полями. Поле at —
время отправки по часам блокчейна: по нему получатель отличает свежий
сигнал от повтора и от сообщения, которое пришло с опозданием.
Собственный счётчик тут не годится: при переносе хранилища он
начинается заново, и получатель, у которого записан прежний номер,
замолчит навсегда. Время такой ловушки не создаёт.
// Сигнал соседнему устройству.
message DeviceSignal {
name: String; // что произошло: "alarm", "overheat", "gone"
at: Int as uint32; // время отправки по часам блокчейна
}
// Подписанные команды: отправить сигнал, править список, сменить адресата.
message SendSignal {
name: String;
}
message SetPeer {
peer: Address;
allowed: Bool;
}
message SetSignalTarget {
target: Address;
}
Шаг 2 — завести поля в хранилище
Всё состояние связи живёт в одном новом поле — структуре под ссылкой. Так сделано не для красоты: у контракта устройства почти исчерпан запас газа на распаковку хранилища в тот момент, когда подпись ещё не проверена. Несколько отдельных полей добавляют работы ровно в этом месте, и подписанные команды перестают приниматься — включая саму команду обновления кода, то есть контракт становится неуправляемым навсегда. Одна ссылка распаковывается лениво и в этот бюджет не лезет.
Поле дописывается в самый конец хранилища, после
storageVersion, и заполняется в init. Как
переносить хранилище на уже задеплоенном устройстве — в разделе
«Обновление кода».
// Всё состояние связи — в одной структуре.
struct SignalState {
peers: map<Address, Bool>; // кто вправе слать сигналы
peerCount: Int as uint8;
lastSeen: map<Address, Int as uint32>; // время последнего принятого сигнала
target: Address?; // кому это устройство шлёт свой сигнал
}
// В хранилище контракта — ОДНО новое поле, последним:
signal: SignalState?;
Шаг 3 — принять сигнал
Единственное доказательство отправителя в TON — адрес, с которого
пришло сообщение: подделать его нельзя. Приёмник ставится
выше общего receive(_: Slice) {} в конце
контракта, иначе сообщение попадёт в него и будет проглочено.
// Константы объявляются ВНУТРИ contract Device, рядом с остальными:
const ErrorPeerNotAllowed: Int = 3101;
const ErrorNoSignalTarget: Int = 3102;
const ErrorTooManyPeers: Int = 3103;
const MAX_PEERS: Int = 8;
const SIGNAL_VALUE: Int = ton("0.02");
receive(msg: DeviceSignal) {
let st = self.signal;
// 1. Отправитель должен быть в списке разрешённых.
throwUnless(self.ErrorPeerNotAllowed, st != null && st!!.peers.exists(sender()));
let state = st!!;
// 2. Повтор и отставший сигнал отбрасываем — но с ошибкой, а не молча:
// отказ виден в истории контракта, а тихий пропуск не виден никому.
let seen = state.lastSeen.get(sender());
throwUnless(self.ErrorPeerNotAllowed, seen == null || msg.at > seen!!);
state.lastSeen.set(sender(), msg.at);
self.signal = state;
// 3. Реакция: встать на замок и записать событие.
if (msg.name == "alarm") {
self.lock = true;
emit(DeviceEvent {
contractAddress: myAddress(),
name: "locked_by_peer",
}.toCell());
}
}
emit кладёт событие в историю контракта. Имя пишется
своё, а структура берётся готовая — DeviceEvent: её нода
уже умеет разбирать, поэтому событие появится в консоли и в ответе
GET /events/Device/… без единой правки на стороне ноды.
Шаг 4 — отправить сигнал и настроить список
Отправку и список разрешённых отправителей заводят как обычные
команды устройства — те же подписанные сообщения, что
SetLock или SetEvents. Разбор команд лежит в
dispatchSignedMessage, туда добавляются три ветки. Но
отправка сигнала тратит деньги с баланса контракта,
поэтому её нельзя пускать по общей проверке: команды устройства
принимает и владелец с ограниченными правами, а он не должен уметь
отправлять средства на выбранный им же адрес. Право на отправку
закрывается тем же гейтом, что и замена кода — только ключ устройства
или владелец с полными правами.
// В dispatchSignedMessage — рядом с остальными командами:
if (op == SendSignal.opcode()) {
self.doSendSignal(SendSignal.fromCell(message).name);
return;
}
if (op == SetPeer.opcode()) {
let m = SetPeer.fromCell(message);
self.doSetPeer(m.peer, m.allowed);
return;
}
if (op == SetSignalTarget.opcode()) {
self.doSetTarget(SetSignalTarget.fromCell(message).target);
return;
}
// Денежные команды — по строгим правам. Проверка стоит в beforeSignedMessage:
// она выполняется уже ПОСЛЕ приёма сообщения, где газа достаточно.
override fun beforeSignedMessage(msg: SignedMessage) {
let op = msg.message.beginParse().loadUint(32);
if (op == Upgrade.opcode() ||
op == SendSignal.opcode() ||
op == SetSignalTarget.opcode()) {
self.requireFullOrDeviceKey(msg.publicKey);
}
}
fun doSendSignal(name: String) {
let st = self.signal;
throwUnless(self.ErrorNoSignalTarget, st != null && st!!.target != null);
send(SendParameters {
to: st!!.target!!,
value: self.SIGNAL_VALUE,
bounce: true, // не дошло — деньги и остаток газа вернутся отправителю
mode: SendPayFwdFeesSeparately,
body: DeviceSignal { name, at: now() }.toCell(),
});
}
fun emptyState(): SignalState {
return SignalState { peers: emptyMap(), peerCount: 0, lastSeen: emptyMap(), target: null };
}
fun doSetPeer(peer: Address, allowed: Bool) {
let state = self.signal == null ? self.emptyState() : self.signal!!;
if (allowed) {
if (!state.peers.exists(peer)) {
throwUnless(self.ErrorTooManyPeers, state.peerCount < self.MAX_PEERS);
state.peers.set(peer, true);
state.peerCount = state.peerCount + 1;
}
} else if (state.peers.exists(peer)) {
state.peers.del(peer);
state.lastSeen.del(peer); // иначе вернувшийся сосед будет заглушён старой отметкой
state.peerCount = state.peerCount - 1;
}
self.signal = state;
}
fun doSetTarget(target: Address) {
let state = self.signal == null ? self.emptyState() : self.signal!!;
state.target = target;
self.signal = state;
}
Список разрешённых отправителей ограничен восемью адресами. Ограничение здесь не формальность: перебор карты стоит газа, а газ платит контракт устройства из своего баланса.
05 · Выкатка
Обновление кода на месте.
Новый код приезжает на уже работающее устройство командой
Upgrade: контракт заменяет свою программу, сохраняя
адрес, баланс и хранилище. Команда подписывается ключом устройства
или владельцем с полными правами — правами уровня «ограниченный
владелец» заменить код нельзя.
cd services/ton
yarn build # собрать контракты
yarn test # прогнать сценарий в песочнице
Перед выкаткой сценарий прогоняют в песочнице — этот шаг
обязателен: тесты в
services/ton/__tests__ поднимают оба контракта в памяти
и проверяют, что чужой адрес получает отказ, а повторный сигнал
ничего не меняет. Дальше Upgrade отправляется скриптом
выкатки — примеры лежат рядом, в deploy/testnet.
Ловушка, на которой спотыкаются: TON не заполняет новые поля
хранилища нулями. Контракт, задеплоенный без полей из шага 2,
получив код с ними, упадёт на первом же сообщении и на любом
запросе к нему. Поэтому хранилище переносят в два шага — сначала
промежуточный код, который читает старую раскладку и собирает новую,
потом уже настоящий новый код. Готовые примеры такого переноса лежат
в services/ton/src/migrations. На новых устройствах,
задеплоенных сразу с новым кодом, шаг лишний.
06 · Дальше по цепочке
Как изменение в контракте доходит до самого устройства.
Смена флага в контракте — это ещё не поведение железа. Дальше работает нода: она следит за новыми блоками TON и видит, что контракт её устройства изменился. После этого нода стучится к устройству с одним коротким сообщением — «состояние изменилось, проверь». Проходит это за пару блоков, то есть за секунды.
Само состояние в этом сообщении не передаётся. Агент идёт за ним в
цепочку и проверяет полученное криптографически, поэтому подменить
флаг по дороге нода не может. Прочитав свежий lock,
агент перестаёт исполнять команды — сигнал соседа доехал до
железа.
контракт камеры
│ DeviceSignal{name:"alarm"}
▼
контракт второго устройства
│ lock = true, событие "locked_by_peer"
▼
нода видит изменение в новом блоке
│ «состояние изменилось, проверь»
▼
агент забирает состояние из цепочки,
проверяет его и встаёт на замок
07 · Проверка
Что смотреть после запуска.
События контракта читаются через HTTP API ноды. Тот же запрос показывает и штатные факты жизни устройства — регистрацию, смену ноды, — и события, добавленные своим кодом.
GET /events/Device/0:<адрес-контракта-устройства>
# Пример ответа:
[
{ "EventName": "DeviceEvent",
"Data": { "contractAddress": "0:abc…def", "name": "locked_by_peer" },
"Timestamp": 1751000123,
"TxHash": "97f1…" }
]
Первое, что проверяют после обновления кода, — что устройство вообще
принимает команды: любая подписанная команда, хоть
SetDeviceName, должна пройти. Если она перестала
приниматься, новый код съел запас газа на распаковку хранилища, и
дальше пробовать нечего — сначала возвращают прежнюю раскладку.
Состояние устройства после сигнала показывает карточка в веб-консоли Subrose: замок, флаги и обслуживающая нода читаются прямо из контракта. Отказы видно там же — сигнал с адреса, которого нет в списке разрешённых, заканчивается ошибкой контракта, и запись о ней остаётся в истории.
08 · Правила
Пять вещей, на которых ломаются такие цепочки.
sender() исполнит сигнал любого, кто заплатит за сообщение.09 · Вопросы
Перед тем как связывать устройства.
Начать
Первое звено цепочки.
Отправка события с устройства, чтение его через API и команда второму устройству — это тот же путь, только без правки контрактов. Рецепты для Shell, Python и C — на странице «События устройства».
Открыть события устройства