diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf60d26..5dbf425 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# Changelog
+## v0.3.0 — Radio controls, command presets and CubeSat model
+
+- добавлена раскрывающаяся схема подключения CC1101 к ESP32-WROOM-32;
+- добавлена раскрывающаяся схема подключения E32-433T30D к ESP32-WROOM-32;
+- добавлена настройка частоты, мощности и параметров радиоканала прямо из OpenMCC;
+- RF-настройки сохраняются локально и формируют будущую команду `$CMD,RADIO,...` для ESP32-шлюза;
+- добавлен запрос `RADIO_STATUS` для будущей прошивки радиошлюза;
+- добавлен список готовых безопасных команд с пояснениями;
+- 3D-модель переработана в более реалистичный 2U CubeSat с фиксированными солнечными панелями;
+- удалена условная одиночная штыревая антенна, добавлена раскрытая рулеточная дипольная антенна с двумя противоположными плечами;
+- добавлена документация `docs/RADIO_CONTROL_UI.md`;
+- версия Windows-приложения повышена до 0.3.0.
+
## v0.2.0 — Windows desktop packaging
- добавлен desktop-host на Electron;
diff --git a/README.md b/README.md
index 13085fc..28416eb 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# OpenMCC
-**OpenMCC (Open Mission Control Center)** — локальный интерфейс учебного центра управления полётами для CubeSat/МКА. Проект работает офлайн, использует Web Serial API для связи с микроконтроллерами и постепенно расширяется до полноценной наземной станции с радиоканалом и автоматическим сопровождением спутника.
+**OpenMCC (Open Mission Control Center)** — локальный интерфейс учебного центра управления полётами для CubeSat/МКА. Проект работает офлайн, использует последовательное соединение с микроконтроллерами и постепенно расширяется до полноценной наземной станции с радиоканалом и автоматическим сопровождением спутника.
OpenMCC можно запускать двумя способами:
@@ -9,23 +9,28 @@ OpenMCC можно запускать двумя способами:
## Что уже работает
-- подключение Arduino/STM32/ESP32 через Web Serial;
+- подключение Arduino/STM32/ESP32 через Web Serial или desktop-host;
- приём и разбор телеметрии;
- отправка команд;
- журнал событий;
- графики в реальном времени на Chart.js;
-- 3D-модель CubeSat на Three.js;
+- 3D-модель 2U CubeSat на Three.js с рулеточной дипольной антенной;
- отдельный интерфейс двухосевой поворотки AZ/EL;
- симулятор поворотного устройства без реальной механики;
- демонстрационная прошивка Arduino Uno/Nano для телеметрии;
- Windows desktop-host на Electron с нативным выбором последовательного порта;
-- автоматическая сборка установщика через GitHub Actions.
-
-## Что пока не реализовано
-
-- реальный радиошлюз на ESP32-WROOM-32;
-- драйвер CC1101;
-- драйвер E32-433T30D;
+- автоматическая сборка установщика через GitHub Actions;
+- интерфейс выбора CC1101 или E32-433T30D;
+- раскрывающиеся таблицы подключения радиомодулей к ESP32-WROOM-32;
+- настройка RF-параметров в интерфейсе с локальным сохранением и формированием команды для будущего ESP32-шлюза;
+- готовые примеры безопасных команд в центре команд.
+
+## Что пока не реализовано полностью
+
+- реальная прошивка радиошлюза ESP32-WROOM-32;
+- драйвер CC1101 в ESP32;
+- драйвер E32-433T30D в ESP32;
+- фактическое применение RF-настроек на радиомодуле;
- единый пакетный протокол радиоканала с CRC/подтверждениями;
- TLE/SGP4;
- карта орбиты и радиогоризонт;
@@ -38,7 +43,7 @@ OpenMCC можно запускать двумя способами:
После публикации релиза пользователь скачивает файл вида:
```text
-OpenMCC-Setup-0.2.0.exe
+OpenMCC-Setup-0.3.0.exe
```
и запускает обычный мастер установки. Python, Node.js и отдельный браузер пользователю не требуются.
@@ -83,6 +88,19 @@ examples/arduino_avr_demo/OpenMCC_Arduino_AVR/OpenMCC_Arduino_AVR.ino
Загрузите его в Arduino Uno/Nano, подключите плату к ПК, выберите в OpenMCC `Arduino Uno` и `115200 бод`, затем нажмите **«Подключить устройство»**.
+## Радиоканал
+
+Наземный шлюз планируется на `ESP32-WROOM-32`.
+
+В панели **«Радиоканал ЦУПа»** можно выбрать:
+
+- `CC1101` — SPI;
+- `E32-433T30D` — UART.
+
+После выбора OpenMCC показывает рекомендуемое подключение, питание и параметры радиотракта. Подробно: `docs/RADIO_CONTROL_UI.md`.
+
+До появления прошивки ESP32 кнопка **«Сохранить / передать в шлюз»** всегда сохраняет конфигурацию локально. Если ESP32 подключена, интерфейс дополнительно отправляет команду `$CMD,RADIO,...`; фактическое изменение настроек радиомодуля зависит от будущей прошивки шлюза.
+
## Архитектура
```text
@@ -110,27 +128,29 @@ examples/arduino_avr_demo/OpenMCC_Arduino_AVR/OpenMCC_Arduino_AVR.ino
- `docs/PROJECT_STATUS.md` — что сделано, что проверено и что осталось;
- `docs/ARCHITECTURE.md` — архитектура программной и аппаратной части;
- `docs/RADIO_LINK.md` — план двустороннего радиоканала CC1101 / E32;
+- `docs/RADIO_CONTROL_UI.md` — подключение и управление радиомодулями из OpenMCC;
- `docs/DESKTOP_APP.md` — сборка, установка и публикация Windows-приложения;
- `CHANGELOG.md` — история заметных изменений.
## Основные модули
```text
-index.html интерфейс OpenMCC
-css/style.css базовые стили
-css/layout.css адаптивная компоновка v0.2
-css/panels.css оформление панелей v0.2
-css/widgets.css элементы управления и подсказки v0.2
-js/parser.js разбор входящих пакетов
-js/logger.js журналирование
-js/serial.js основное Web Serial-соединение
-js/ui.js команды аппарату
-js/charts.js графики Chart.js
-js/rotator.js отдельное соединение с повороткой
-js/satellite.js 3D-модель Three.js
-js/help.js подсказки, навигация и встроенная справка
-desktop/main.cjs Electron-host и Web Serial для desktop-версии
-package.json конфигурация desktop-сборки
+index.html интерфейс OpenMCC
+css/style.css базовые стили
+css/layout.css адаптивная компоновка
+css/panels.css оформление панелей
+css/widgets.css общие элементы управления
+css/radio-controls.css RF-настройки и готовые команды v0.3
+js/parser.js разбор входящих пакетов
+js/logger.js журналирование
+js/serial.js основное последовательное соединение
+js/ui.js команды аппарату
+js/charts.js графики Chart.js
+js/rotator.js отдельное соединение с повороткой
+js/satellite.js 3D-модель 2U CubeSat
+js/help.js подсказки, RF UI и готовые команды
+desktop/main.cjs Electron-host и Serial для desktop-версии
+package.json конфигурация desktop-сборки
```
## Сборка Windows-установщика для разработчика
@@ -142,8 +162,8 @@ npm run dist:win
Результат создаётся в `release/`.
-GitHub Actions также собирает установщик автоматически. При публикации тега вида `v0.2.0` workflow создаёт GitHub Release и загружает туда Windows installer.
+GitHub Actions собирает установщик автоматически. После изменения версии `package.json` и слияния в `main` workflow может создать новый GitHub Release с установщиком соответствующей версии.
## Важный принцип проекта
-OpenMCC должен оставаться независимым от конкретного радиомодуля. CC1101 и E32-433T30D будут скрыты за ESP32-WROOM-32, а интерфейс продолжит работать с единым протоколом телеметрии и команд.
+OpenMCC должен оставаться независимым от конкретного радиомодуля. CC1101 и E32-433T30D скрываются за ESP32-WROOM-32, а интерфейс телеметрии и команд остаётся единым.
diff --git a/css/radio-controls.css b/css/radio-controls.css
new file mode 100644
index 0000000..10902cd
--- /dev/null
+++ b/css/radio-controls.css
@@ -0,0 +1,247 @@
+/* OpenMCC v0.3 — radio configuration and command presets */
+
+#radioAdvancedControls {
+ display: grid;
+ gap: 12px;
+ margin-top: 12px;
+}
+
+.radioEmptyState {
+ display: grid;
+ gap: 5px;
+ padding: 14px;
+ border: 1px dashed rgba(71, 103, 137, .75);
+ border-radius: 10px;
+ background: rgba(5, 12, 23, .38);
+}
+
+.radioEmptyState strong { color: #d8e9f7; font-size: 12px; }
+.radioEmptyState span { color: #7893ac; font-size: 11px; line-height: 1.5; }
+
+.radioDetails {
+ overflow: hidden;
+ border: 1px solid rgba(43, 73, 108, .72);
+ border-radius: 11px;
+ background: rgba(5, 12, 23, .45);
+}
+
+.radioDetails summary {
+ padding: 11px 13px;
+ cursor: pointer;
+ color: #cfe8f5;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: .02em;
+ background: rgba(15, 29, 49, .7);
+}
+
+.radioDetails[open] summary { border-bottom: 1px solid rgba(43, 73, 108, .62); }
+.radioDetailsBody { padding: 12px; }
+
+.radioPinTable {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 10px;
+}
+
+.radioPinTable th,
+.radioPinTable td {
+ padding: 7px 8px;
+ border-bottom: 1px solid rgba(39, 66, 98, .52);
+ text-align: left;
+ vertical-align: top;
+}
+
+.radioPinTable th {
+ color: #7fa1bd;
+ font-size: 9px;
+ letter-spacing: .07em;
+ text-transform: uppercase;
+}
+
+.radioPinTable td:first-child,
+.radioPinTable td:nth-child(2) {
+ color: #d9eef8;
+ font-family: Consolas, monospace;
+ white-space: nowrap;
+}
+
+.radioPinTable td:last-child { color: #829bb2; }
+
+.radioSafetyNote,
+.radioRegulatoryNote {
+ margin: 10px 0 0;
+ color: #8da5bb;
+ font-size: 10px;
+ line-height: 1.55;
+}
+
+.radioRegulatoryNote {
+ padding: 9px 10px;
+ border-left: 2px solid rgba(255, 193, 7, .68);
+ background: rgba(255, 193, 7, .035);
+}
+
+.radioConfigCard {
+ padding: 13px;
+ border: 1px solid rgba(43, 73, 108, .75);
+ border-radius: 11px;
+ background: linear-gradient(180deg, rgba(9, 20, 35, .78), rgba(5, 12, 23, .72));
+}
+
+.radioConfigHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ margin-bottom: 12px;
+}
+
+.radioConfigHeader > div { display: grid; gap: 3px; }
+.radioConfigHeader span { color: #6f8ca8; font-size: 9px; letter-spacing: .09em; text-transform: uppercase; }
+.radioConfigHeader strong { color: #dcecf8; font-size: 13px; }
+
+.radioLocalBadge {
+ padding: 4px 7px;
+ border: 1px solid rgba(73, 112, 148, .6);
+ border-radius: 999px;
+ color: #7898b4 !important;
+ font-size: 8px !important;
+ white-space: nowrap;
+}
+
+.radioControlGrid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.radioControlGrid > label:not(.radioCheck) {
+ display: grid;
+ gap: 6px;
+}
+
+.radioControlGrid label > span {
+ color: #8099b0;
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: .07em;
+ text-transform: uppercase;
+}
+
+.radioControlGrid select,
+.radioControlGrid input[type="number"] {
+ width: 100%;
+ min-height: 38px;
+ padding: 0 10px;
+ color: #e4f1fb;
+ border: 1px solid rgba(45, 75, 109, .85);
+ border-radius: 8px;
+ background: #14223a;
+}
+
+.radioCheck {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 38px;
+ padding: 8px 10px;
+ border: 1px solid rgba(45, 75, 109, .72);
+ border-radius: 8px;
+ background: rgba(20, 34, 58, .62);
+}
+
+.radioCheck input { accent-color: #00d9ff; }
+.radioCheck span { margin: 0; }
+
+.radioCommandPreview {
+ display: grid;
+ gap: 6px;
+ margin-top: 12px;
+ padding: 10px;
+ border: 1px solid rgba(42, 70, 102, .65);
+ border-radius: 9px;
+ background: rgba(3, 9, 17, .72);
+}
+
+.radioCommandPreview span {
+ color: #6d89a3;
+ font-size: 9px;
+ letter-spacing: .08em;
+ text-transform: uppercase;
+}
+
+.radioCommandPreview code {
+ overflow-wrap: anywhere;
+ color: #8feaff;
+ font: 600 10px/1.5 Consolas, monospace;
+}
+
+.radioActionRow {
+ display: grid;
+ grid-template-columns: 1.35fr 1fr;
+ gap: 8px;
+ margin-top: 10px;
+}
+
+.radioApplyButton,
+.radioSecondaryButton {
+ min-height: 40px;
+ margin: 0;
+ border-radius: 8px;
+ font-size: 11px;
+}
+
+.radioApplyButton {
+ color: #041019;
+ border-color: #21dfff;
+ background: linear-gradient(135deg, #14d9ff, #62e8ff);
+ font-weight: 750;
+}
+
+.radioSecondaryButton {
+ color: #b4c9da;
+ border-color: rgba(56, 86, 119, .85);
+ background: rgba(17, 31, 52, .72);
+}
+
+.radioApplyStatus {
+ margin-top: 9px;
+ color: #7f98ae;
+ font-size: 10px;
+ line-height: 1.45;
+}
+
+.radioApplyStatus[data-state="saved"] { color: #8fd6b3; }
+.radioApplyStatus[data-state="sent"] { color: #77e5ff; }
+.radioApplyStatus[data-state="error"] { color: #ff8d8d; }
+
+.commandPresetGroup { margin-top: 2px; }
+
+.commandPresetRow {
+ display: grid;
+ grid-template-columns: minmax(0, 1.6fr) minmax(150px, .8fr);
+ gap: 8px;
+}
+
+.commandPresetRow select {
+ width: 100%;
+ min-height: 42px;
+ padding: 0 10px;
+ color: #e3f0fb;
+ border: 1px solid rgba(45, 75, 109, .85);
+ border-radius: 8px;
+ background: #14223a;
+}
+
+.commandPresetRow button { margin: 0; }
+.commandPresetNote { margin-top: 8px; color: #7892a8; font-size: 10px; line-height: 1.45; }
+
+@media (max-width: 720px) {
+ .radioControlGrid,
+ .radioActionRow,
+ .commandPresetRow { grid-template-columns: 1fr; }
+ .radioPinTable { font-size: 9px; }
+ .radioPinTable th,
+ .radioPinTable td { padding: 6px; }
+}
diff --git a/docs/RADIO_CONTROL_UI.md b/docs/RADIO_CONTROL_UI.md
new file mode 100644
index 0000000..134949f
--- /dev/null
+++ b/docs/RADIO_CONTROL_UI.md
@@ -0,0 +1,93 @@
+# Радиоканал OpenMCC — интерфейс настройки v0.3
+
+## Назначение
+
+Панель **«Радиоканал ЦУПа»** предназначена для будущего наземного радиошлюза на `ESP32-WROOM-32`.
+
+В OpenMCC можно выбрать один из двух вариантов радиомодуля:
+
+- `CC1101` — подключение к ESP32 по SPI;
+- `E32-433T30D` — подключение к ESP32 по UART.
+
+После выбора модуля интерфейс показывает рекомендуемую разводку, доступные параметры и текст команды, которая будет передана прошивке ESP32.
+
+> В версии v0.3 графический интерфейс уже готов, однако драйверы CC1101/E32 в прошивке ESP32 ещё не реализованы. Поэтому настройки гарантированно сохраняются локально, а реальное применение на радиомодуле появится после разработки прошивки радиошлюза.
+
+## CC1101
+
+Рекомендуемая разводка OpenMCC:
+
+| ESP32-WROOM-32 | CC1101 | Назначение |
+|---|---|---|
+| 3V3 | VCC | питание 3,3 В |
+| GND | GND | общая земля |
+| GPIO18 | SCK | SPI clock |
+| GPIO23 | SI / MOSI | данные ESP32 → CC1101 |
+| GPIO19 | SO / MISO | данные CC1101 → ESP32 |
+| GPIO21 | CSn | выбор SPI-устройства |
+| GPIO4 | GDO0 | прерывание / событие пакета |
+| GPIO27 | GDO2 | дополнительный цифровой сигнал |
+
+Интерфейс позволяет подготовить:
+
+- частоту;
+- мощность передатчика;
+- скорость в эфире;
+- модуляцию;
+- полосу приёмного фильтра;
+- аппаратный CRC;
+- CCA перед передачей.
+
+TI указывает для CC1101 диапазоны 300–348, 387–464 и 779–928 МГц, программируемую скорость 0,6–600 кбит/с и мощность до +12 dBm. Конкретный модуль и согласующая цепь должны соответствовать используемому диапазону.
+
+Пример будущей команды ESP32:
+
+```text
+$CMD,RADIO,TYPE=CC1101,FREQ=435.000,POWER=10,RATE=38.4,MOD=GFSK,BW=AUTO,CRC=1,CCA=1
+```
+
+## E32-433T30D
+
+Рекомендуемая разводка OpenMCC:
+
+| ESP32-WROOM-32 / питание | E32-433T30D | Назначение |
+|---|---|---|
+| внешний стабилизированный 5 В, не менее 1 А | VCC | питание радиомодуля |
+| GND ESP32 + GND источника | GND | общая земля |
+| GPIO17 / TX2 | RXD | ESP32 → E32 |
+| GPIO16 / RX2 | TXD | E32 → ESP32 |
+| GPIO25 | M0 | выбор режима |
+| GPIO26 | M1 | выбор режима |
+| GPIO27 | AUX | состояние / готовность |
+
+Ebyte указывает питание 3,3–5,2 В, рекомендуемый логический уровень 3,3 В и ток передачи до примерно 670 мА при 30 dBm. Поэтому 1-ваттный модуль не следует питать от слабого выхода 3V3 отладочной платы.
+
+Интерфейс позволяет подготовить:
+
+- частоту 410–441 МГц;
+- мощность 21/24/27/30 dBm;
+- воздушную скорость 2,4/4,8/9,6/19,2 кбит/с;
+- скорость UART;
+- прозрачный или фиксированный режим передачи;
+- FEC.
+
+Пример будущей команды ESP32:
+
+```text
+$CMD,RADIO,TYPE=E32,FREQ=433,POWER=30,AIR=2.4,UART=9600,MODE=TRANSPARENT,FEC=1
+```
+
+## Как пользоваться
+
+1. Подключить `ESP32-WROOM-32` к основному USB-порту OpenMCC.
+2. В панели соединения выбрать профиль `ESP32` и нужную скорость USB-порта.
+3. В панели радиоканала выбрать `CC1101` или `E32-433T30D`.
+4. Раскрыть блок подключения и сверить проводку.
+5. Задать RF-параметры.
+6. Нажать **«Сохранить / передать в шлюз»**.
+
+Без подключённой ESP32 параметры сохраняются в локальном хранилище OpenMCC. При подключённом шлюзе формируется команда `$CMD,RADIO,...`.
+
+## Радиорегулирование
+
+OpenMCC технически позволяет вводить частоту и мощность, поддерживаемые радиомодулем. Пользователь обязан самостоятельно выбирать только разрешённые частоты, уровни мощности и режимы работы в соответствии с местным законодательством, лицензией и назначением станции.
diff --git a/js/help.js b/js/help.js
index 463f99c..8281f76 100644
--- a/js/help.js
+++ b/js/help.js
@@ -1,49 +1,54 @@
"use strict";
/* ============================================================
- OpenMCC v0.2
- Interface help, tooltips, quick navigation and UI-only
- radio gateway planning controls.
+ OpenMCC v0.3
+ Interface help, tooltips, quick navigation, radio setup UI
+ and command presets.
============================================================ */
(() => {
- const HELP_VERSION = "0.2.0";
- const FIRST_RUN_KEY = "openmcc-help-v02-seen";
+ const HELP_VERSION = "0.3.0";
+ const FIRST_RUN_KEY = "openmcc-help-v03-seen";
const RADIO_KEY = "openmcc-radio-module";
+ const RADIO_SETTINGS_PREFIX = "openmcc-radio-settings-";
const tooltipDefinitions = [
["#connectButton", "Открывает системный выбор последовательного USB-порта. Для тестовой Arduino Uno используйте 115200 бод."],
- ["#deviceProfile", "Профиль помогает OpenMCC корректно подписать устройство. Если не уверены — оставьте автоматическое определение."],
- ["#serialBaudRate", "Скорость обмена по USB. Демонстратор Arduino и текущие прошивки OpenMCC используют 115200 бод."],
+ ["#deviceProfile", "Профиль помогает OpenMCC корректно подписать устройство. Для радиошлюза выбирайте ESP32."],
+ ["#serialBaudRate", "Скорость обмена по USB. Демонстратор Arduino и будущий ESP32-шлюз рассчитаны на 115200 бод."],
["#packetCounter", "Число принятых телеметрических пакетов за текущий сеанс."],
- ["#crcCounter", "Счётчик ошибок/контрольных событий. В текущем текстовом протоколе полноценный CRC радиопакета ещё не внедрён."],
+ ["#crcCounter", "Счётчик ошибок/контрольных событий. Радиопакетный CRC будет реализован в прошивке ESP32-шлюза."],
["#telemetryPanel", "Здесь отображаются последние принятые значения TEMP, VOLT, CURR, RSSI и SNR."],
- ["#commandPanel", "Команды уходят в подключённое основное устройство. Сейчас это USB-плата; позже тем же интерфейсом будет управляться ESP32-радиошлюз."],
+ ["#commandPanel", "Команды уходят в подключённое основное устройство. Позже это будет ESP32-радиошлюз, который передаст команду аппарату по радио."],
["[data-command='PING']", "Проверка канала команд. Демонстратор Arduino отвечает $ACK,PONG."],
["[data-command='INFO']", "Запрашивает сведения об устройстве и версии протокола."],
["[data-command='START']", "Разрешает периодическую передачу телеметрии на демонстраторе."],
["[data-command='STOP']", "Останавливает периодическую передачу телеметрии на демонстраторе."],
["#telemetryRate", "Выберите период между телеметрическими пакетами."],
["#applyTelemetryRate", "Отправляет устройству команду изменения периода телеметрии."],
- ["#customCommand", "Произвольная команда протокола OpenMCC. Используйте только команды, которые поддерживает подключённая прошивка."],
+ ["#customCommand", "Произвольная команда протокола OpenMCC. Используйте только команды, поддерживаемые подключённой прошивкой."],
+ ["#commandPresetSelect", "Готовые безопасные примеры команд. Часть команд относится к будущей прошивке МКА и может вернуть UNKNOWN_COMMAND на демонстраторе."],
+ ["#commandPresetSend", "Отправляет выбранную команду через тот же канал, что и обычный центр команд."],
["#rotatorConnectButton", "Открывает второй независимый последовательный порт для Arduino Uno поворотного устройства."],
["#rotatorSimulationMode", "Позволяет проверять весь интерфейс AZ/EL без собранного SatNOGS Rotator v3."],
["#rotatorTargetAz", "Целевой азимут антенны. В текущей логике допустим диапазон 0…360°."],
["#rotatorTargetEl", "Целевой угол места антенны. В текущей логике допустим диапазон 0…90°."],
["#rotatorMoveButton", "Передаёт команду $ROT,SET,AZ=…,EL=… в контроллер поворотки или симулятор."],
- ["#rotatorStopButton", "Останавливает движение. Для реальной механики всё равно нужна отдельная аппаратная аварийная остановка."],
+ ["#rotatorStopButton", "Останавливает движение. Для реальной механики нужна также отдельная аппаратная аварийная остановка."],
["#rotatorHomeButton", "Запускает поиск нулевого положения по концевым выключателям. Реальную механику пока не проверяли."],
["#rotatorParkButton", "Команда перевода антенны в заранее заданное парковочное положение."],
["#rotatorAutoMode", "Автоматическое сопровождение будет включено после реализации TLE/SGP4 и расчёта AZ/EL."],
- ["#radioPanel", "Планируемый наземный радиошлюз. Выбранная базовая плата — ESP32-WROOM-32; радиомодуль зависит от закупки."],
- ["#radioModuleType", "Выберите предполагаемый вариант радиомодуля. Это пока только настройка интерфейса и документации, не реальный драйвер."],
+ ["#radioPanel", "Настройка наземного радиошлюза ESP32-WROOM-32. Здесь можно выбрать CC1101 или E32-433T30D, посмотреть подключение и подготовить RF-конфигурацию."],
+ ["#radioModuleType", "Выберите установленный радиомодуль. Параметры и схема подключения ниже перестроятся автоматически."],
+ ["#radioApplyButton", "Сохраняет RF-настройки локально. Если ESP32 подключена, OpenMCC также отправит конфигурацию в шлюз; применение зависит от прошивки ESP32."],
+ ["#radioReadButton", "Запрашивает текущую RF-конфигурацию у ESP32-шлюза. Команда заработает после реализации соответствующей прошивки."],
["#pauseChartsButton", "Замораживает добавление новых точек на графики без остановки самой телеметрии."],
["#clearChartsButton", "Очищает накопленные точки графиков текущего сеанса."],
- ["#satelliteViewport", "Процедурная 3D-модель CubeSat. При появлении ROLL/PITCH/YAW модель сможет отражать ориентацию аппарата."],
+ ["#satelliteViewport", "Условная 2U-модель CubeSat с фиксированными солнечными панелями и раскрытой рулеточной дипольной антенной."],
["#resetSatelliteView", "Возвращает камеру 3D-вида в исходное положение."],
["#toggleSatelliteRotation", "Включает или выключает демонстрационное автовращение модели."],
["#mapPanel", "Карта пока не реализована. Следующий крупный этап — TLE/SGP4, трасса орбиты, положение ЦУПа и автоматическое сопровождение."],
- ["#eventLog", "Журнал сохраняет ключевые события соединения, команд и ошибок. Он нужен для диагностики работы ЦУПа."],
+ ["#eventLog", "Журнал сохраняет ключевые события соединения, команд и ошибок."],
["#helpButton", "Открывает краткую встроенную инструкцию. Горячая клавиша: F1."],
];
@@ -52,34 +57,59 @@
label: "CC1101 / E32",
interfaceLabel: "SPI / UART",
interfaceValue: "SPI или UART",
- powerValue: "Уточнить после закупки",
- hint: "Модуль ещё не выбран. Архитектура OpenMCC специально оставлена независимой от конкретного радиомодуля.",
+ powerValue: "Выберите модуль ниже",
+ hint: "Модуль ещё не выбран. После выбора появятся схема подключения, диапазоны частоты, мощности и другие параметры.",
},
cc1101: {
label: "CC1101",
interfaceLabel: "SPI",
interfaceValue: "SPI + GDO0/GDO2",
powerValue: "3,3 В; логика 3,3 В",
- hint: "CC1101 подключается к ESP32-WROOM-32 по SPI. Драйвер радиоканала и пакетный CRC ещё предстоит реализовать.",
+ hint: "CC1101 — гибко настраиваемый Sub-1 GHz трансивер. Для 433/435 МГц используйте модуль и антенну, рассчитанные на этот диапазон.",
},
e32: {
label: "E32-433T30D",
interfaceLabel: "UART",
interfaceValue: "UART + M0/M1/AUX",
- powerValue: "Отдельное питание модуля; общая GND",
- hint: "E32-433T30D подключается к ESP32-WROOM-32 по UART. Мощную версию не следует питать от слабого вывода 3,3 В отладочной платы.",
+ powerValue: "3,3–5,2 В; при 30 dBm до ~670 мА",
+ hint: "E32-433T30D — UART LoRa-модуль до 30 dBm. Для 1 Вт предусмотрите отдельное питание с общей землёй и 3,3-вольтовыми логическими уровнями.",
},
};
+ const commandPresets = [
+ { label: "PING — проверка связи", command: "PING", params: null, note: "Безопасная проверка командного канала. Работает с демонстратором Arduino." },
+ { label: "INFO — сведения об устройстве", command: "INFO", params: null, note: "Запрашивает тип устройства и версию протокола. Работает с демонстратором." },
+ { label: "START — запустить телеметрию", command: "START", params: null, note: "Включает периодическую телеметрию на демонстраторе." },
+ { label: "STOP — остановить телеметрию", command: "STOP", params: null, note: "Останавливает периодическую телеметрию на демонстраторе." },
+ { label: "LED ON — включить индикатор", command: "LED", params: { VALUE: 1 }, note: "Включает встроенный LED демонстрационной Arduino." },
+ { label: "LED OFF — выключить индикатор", command: "LED", params: { VALUE: 0 }, note: "Выключает встроенный LED демонстрационной Arduino." },
+ { label: "RATE 1000 — телеметрия 1 раз/с", command: "RATE", params: { VALUE: 1000 }, note: "Устанавливает период телеметрии 1000 мс." },
+ { label: "STATUS — запрос состояния МКА", command: "STATUS", params: null, note: "Зарезервированный пример для будущей бортовой прошивки. Демонстратор может ответить UNKNOWN_COMMAND." },
+ { label: "POWER — запрос состояния питания", command: "POWER", params: null, note: "Зарезервированный пример для будущей бортовой прошивки." },
+ { label: "RADIO_STATUS — состояние радиошлюза", command: "RADIO_STATUS", params: null, note: "Будет использоваться ESP32-шлюзом после реализации его прошивки." },
+ ];
+
let tooltipElement = null;
let activeTooltipTarget = null;
+ function loadEnhancedStyles() {
+ if (document.querySelector("link[data-openmcc-v03]")) return;
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = "css/radio-controls.css";
+ link.dataset.openmccV03 = "1";
+ document.head.appendChild(link);
+ }
+
+ function updateBuildBadge() {
+ const badge = document.querySelector(".buildBadge");
+ if (badge) badge.textContent = "v0.3 · LAB";
+ }
+
function addTooltips() {
tooltipDefinitions.forEach(([selector, text]) => {
document.querySelectorAll(selector).forEach((element) => {
- if (!element.dataset.tip) {
- element.dataset.tip = text;
- }
+ if (!element.dataset.tip) element.dataset.tip = text;
if (!element.getAttribute("aria-label") && ["BUTTON", "INPUT", "SELECT"].includes(element.tagName)) {
element.setAttribute("aria-label", text);
}
@@ -91,31 +121,20 @@
if (!tooltipElement || !target) return;
const text = target.dataset.tip;
if (!text) return;
-
tooltipElement.textContent = text;
tooltipElement.setAttribute("aria-hidden", "false");
tooltipElement.classList.add("visible");
-
const padding = 12;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const tooltipRect = tooltipElement.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
-
let left = pointerEvent?.clientX ?? (targetRect.left + targetRect.width / 2);
let top = pointerEvent?.clientY ?? targetRect.bottom;
-
left += 14;
top += 14;
-
- if (left + tooltipRect.width + padding > viewportWidth) {
- left = Math.max(padding, viewportWidth - tooltipRect.width - padding);
- }
-
- if (top + tooltipRect.height + padding > viewportHeight) {
- top = Math.max(padding, (pointerEvent?.clientY ?? targetRect.top) - tooltipRect.height - 14);
- }
-
+ if (left + tooltipRect.width + padding > viewportWidth) left = Math.max(padding, viewportWidth - tooltipRect.width - padding);
+ if (top + tooltipRect.height + padding > viewportHeight) top = Math.max(padding, (pointerEvent?.clientY ?? targetRect.top) - tooltipRect.height - 14);
tooltipElement.style.left = `${left}px`;
tooltipElement.style.top = `${top}px`;
}
@@ -130,32 +149,27 @@
function initializeTooltipSystem() {
tooltipElement = document.getElementById("openmccTooltip");
if (!tooltipElement) return;
-
document.addEventListener("pointerover", (event) => {
const target = event.target.closest?.("[data-tip]");
if (!target) return;
activeTooltipTarget = target;
placeTooltip(target, event);
});
-
document.addEventListener("pointermove", (event) => {
if (activeTooltipTarget) placeTooltip(activeTooltipTarget, event);
});
-
document.addEventListener("pointerout", (event) => {
if (!activeTooltipTarget) return;
const nextTarget = event.relatedTarget;
if (nextTarget && activeTooltipTarget.contains(nextTarget)) return;
hideTooltip();
});
-
document.addEventListener("focusin", (event) => {
const target = event.target.closest?.("[data-tip]");
if (!target) return;
activeTooltipTarget = target;
placeTooltip(target);
});
-
document.addEventListener("focusout", hideTooltip);
window.addEventListener("scroll", hideTooltip, { passive: true });
window.addEventListener("resize", hideTooltip);
@@ -164,8 +178,7 @@
function initializeNavigation() {
document.querySelectorAll("[data-scroll-target]").forEach((button) => {
button.addEventListener("click", () => {
- const target = document.getElementById(button.dataset.scrollTarget);
- target?.scrollIntoView({ behavior: "smooth", block: "start" });
+ document.getElementById(button.dataset.scrollTarget)?.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
}
@@ -175,30 +188,21 @@
if (!dialog) return;
if (typeof dialog.showModal === "function") {
if (!dialog.open) dialog.showModal();
- } else {
- dialog.setAttribute("open", "");
- }
+ } else dialog.setAttribute("open", "");
}
function closeHelp() {
const dialog = document.getElementById("helpDialog");
if (!dialog) return;
- if (typeof dialog.close === "function" && dialog.open) {
- dialog.close();
- } else {
- dialog.removeAttribute("open");
- }
+ if (typeof dialog.close === "function" && dialog.open) dialog.close();
+ else dialog.removeAttribute("open");
}
function initializeHelpDialog() {
document.querySelectorAll("[data-help-open]").forEach((button) => button.addEventListener("click", openHelp));
document.querySelectorAll("[data-help-close]").forEach((button) => button.addEventListener("click", closeHelp));
-
const dialog = document.getElementById("helpDialog");
- dialog?.addEventListener("click", (event) => {
- if (event.target === dialog) closeHelp();
- });
-
+ dialog?.addEventListener("click", (event) => { if (event.target === dialog) closeHelp(); });
document.addEventListener("keydown", (event) => {
if (event.key === "F1") {
event.preventDefault();
@@ -214,66 +218,342 @@
const interfaceValue = document.getElementById("radioInterfaceValue");
const powerValue = document.getElementById("radioPowerValue");
const hint = document.getElementById("radioConfigHint");
-
if (moduleLabel) moduleLabel.textContent = profile.label;
if (interfaceLabel) interfaceLabel.textContent = profile.interfaceLabel;
if (interfaceValue) interfaceValue.textContent = profile.interfaceValue;
if (powerValue) powerValue.textContent = profile.powerValue;
if (hint) hint.textContent = profile.hint;
+ renderRadioControls(profileId);
+ }
+
+ function getRadioSettingsKey(profileId) {
+ return `${RADIO_SETTINGS_PREFIX}${profileId}`;
+ }
+
+ function loadRadioSettings(profileId, defaults) {
+ try {
+ const parsed = JSON.parse(localStorage.getItem(getRadioSettingsKey(profileId)) || "null");
+ return { ...defaults, ...(parsed && typeof parsed === "object" ? parsed : {}) };
+ } catch {
+ return { ...defaults };
+ }
+ }
+
+ function saveRadioSettings(profileId, settings) {
+ localStorage.setItem(getRadioSettingsKey(profileId), JSON.stringify(settings));
+ }
+
+ function radioPinTable(profileId) {
+ const rows = profileId === "cc1101" ? [
+ ["3V3", "VCC", "Питание 3,3 В"],
+ ["GND", "GND", "Общая земля"],
+ ["GPIO18", "SCK", "SPI clock"],
+ ["GPIO23", "SI / MOSI", "ESP32 → CC1101"],
+ ["GPIO19", "SO / MISO", "CC1101 → ESP32"],
+ ["GPIO21", "CSn", "Chip Select"],
+ ["GPIO4", "GDO0", "Прерывание / пакет"],
+ ["GPIO27", "GDO2", "Доп. сигнал состояния"],
+ ] : [
+ ["Внешние 5 В ≥ 1 А", "VCC", "Питание; не от слабого 3V3 ESP32"],
+ ["GND ESP32 + БП", "GND", "Общая земля обязательна"],
+ ["GPIO17 / TX2", "RXD", "ESP32 → E32"],
+ ["GPIO16 / RX2", "TXD", "E32 → ESP32"],
+ ["GPIO25", "M0", "Выбор режима"],
+ ["GPIO26", "M1", "Выбор режима"],
+ ["GPIO27", "AUX", "Готовность / занятость"],
+ ];
+ return rows.map(([esp, radio, purpose]) => `
${esp} ${radio} ${purpose} `).join("");
+ }
+
+ function cc1101Controls(settings) {
+ return `
+
+ Частота, МГц
+ Мощность TX
+ ${[-30,-20,-15,-10,0,5,7,10,12].map(v => `${v > 0 ? "+" : ""}${v} dBm `).join("")}
+
+ Скорость в эфире
+ ${[1.2,2.4,4.8,9.6,38.4,100,250].map(v => `${v} kbps `).join("")}
+
+ Модуляция
+ ${["2-FSK","GFSK","4-FSK","MSK","OOK"].map(v => `${v} `).join("")}
+
+ Полоса RX
+ ${["AUTO","58","102","203","406"].map(v => `${v === "AUTO" ? "Авто" : `${v} кГц`} `).join("")}
+
+ Аппаратный CRC
+ CCA перед передачей
+
`;
+ }
+
+ function e32Controls(settings) {
+ return `
+
+ Частота, МГц
+ Мощность TX
+ ${[21,24,27,30].map(v => `${v} dBm${v === 30 ? " ≈ 1 Вт" : ""} `).join("")}
+
+ Скорость в эфире
+ ${[2.4,4.8,9.6,19.2].map(v => `${v} kbps `).join("")}
+
+ UART ESP32 ↔ E32
+ ${[1200,2400,4800,9600,19200,38400,57600,115200].map(v => `${v} бод `).join("")}
+
+ Режим передачи
+ Прозрачный
+ Фиксированная адресация
+
+ FEC
+
`;
+ }
+
+ function renderRadioControls(profileId) {
+ const panel = document.getElementById("radioPanel");
+ if (!panel) return;
+ let container = document.getElementById("radioAdvancedControls");
+ if (!container) {
+ container = document.createElement("div");
+ container.id = "radioAdvancedControls";
+ const hint = document.getElementById("radioConfigHint");
+ panel.insertBefore(container, hint || null);
+ }
+ if (profileId === "unknown") {
+ container.innerHTML = `Выберите CC1101 или E32-433T30D После выбора здесь появятся подключение и параметры радиотракта.
`;
+ return;
+ }
+
+ const defaults = profileId === "cc1101"
+ ? { frequency: 435.0, power: 10, rate: 38.4, modulation: "GFSK", bandwidth: "AUTO", crc: true, cca: true }
+ : { frequency: 433, power: 30, airRate: 2.4, uartRate: 9600, mode: "TRANSPARENT", fec: true };
+ const settings = loadRadioSettings(profileId, defaults);
+
+ container.innerHTML = `
+
+ Подключение ${radioProfiles[profileId].label} к ESP32-WROOM-32
+
+
ESP32 / питание ${radioProfiles[profileId].label} Назначение ${radioPinTable(profileId)}
+
${profileId === "cc1101"
+ ? "CC1101 работает с логикой 3,3 В. Не подавайте на сигнальные выводы 5 В. Выбранная разводка GPIO является конфигурацией OpenMCC и может быть изменена в прошивке ESP32."
+ : "E32-433T30D при 30 dBm потребляет большой импульсный ток. Используйте отдельный стабилизированный источник питания с общей GND; сигнальные уровни рекомендуется держать 3,3 В."}
+
+
+
+
+ ${profileId === "cc1101" ? cc1101Controls(settings) : e32Controls(settings)}
+
Команда для ESP32 —
+
+ Сохранить / передать в шлюз
+ Запросить из шлюза
+
+
Настройки ещё не передавались.
+
Частоту и мощность выбирайте только в пределах разрешённых для вашей станции, диапазона и лицензии. Интерфейс OpenMCC не отменяет требования радиорегулирования.
+
`;
+
+ bindRadioControlEvents(profileId);
+ addTooltips();
+ }
+
+ function collectRadioSettings(profileId) {
+ const frequency = Number(document.getElementById("radioFrequency")?.value);
+ const power = Number(document.getElementById("radioTxPower")?.value);
+ if (profileId === "cc1101") {
+ return {
+ frequency,
+ power,
+ rate: Number(document.getElementById("radioDataRate")?.value),
+ modulation: document.getElementById("radioModulation")?.value || "GFSK",
+ bandwidth: document.getElementById("radioBandwidth")?.value || "AUTO",
+ crc: Boolean(document.getElementById("radioCrc")?.checked),
+ cca: Boolean(document.getElementById("radioCca")?.checked),
+ };
+ }
+ return {
+ frequency,
+ power,
+ airRate: Number(document.getElementById("radioAirRate")?.value),
+ uartRate: Number(document.getElementById("radioUartRate")?.value),
+ mode: document.getElementById("radioE32Mode")?.value || "TRANSPARENT",
+ fec: Boolean(document.getElementById("radioFec")?.checked),
+ };
+ }
+
+ function validateRadioSettings(profileId, settings) {
+ const [minimum, maximum] = profileId === "cc1101" ? [387, 464] : [410, 441];
+ if (!Number.isFinite(settings.frequency) || settings.frequency < minimum || settings.frequency > maximum) {
+ throw new Error(`Частота должна быть в диапазоне ${minimum}…${maximum} МГц`);
+ }
+ }
+
+ function buildRadioParameters(profileId, settings) {
+ if (profileId === "cc1101") {
+ return {
+ TYPE: "CC1101",
+ FREQ: settings.frequency.toFixed(3),
+ POWER: settings.power,
+ RATE: settings.rate,
+ MOD: settings.modulation.replace("-", ""),
+ BW: settings.bandwidth,
+ CRC: settings.crc ? 1 : 0,
+ CCA: settings.cca ? 1 : 0,
+ };
+ }
+ return {
+ TYPE: "E32",
+ FREQ: settings.frequency.toFixed(0),
+ POWER: settings.power,
+ AIR: settings.airRate,
+ UART: settings.uartRate,
+ MODE: settings.mode,
+ FEC: settings.fec ? 1 : 0,
+ };
+ }
+
+ function previewRadioCommand(profileId) {
+ const preview = document.getElementById("radioCommandPreview");
+ if (!preview) return;
+ try {
+ const settings = collectRadioSettings(profileId);
+ validateRadioSettings(profileId, settings);
+ const params = buildRadioParameters(profileId, settings);
+ preview.textContent = `$CMD,RADIO,${Object.entries(params).map(([key, value]) => `${key}=${value}`).join(",")}`;
+ } catch (error) {
+ preview.textContent = error.message;
+ }
+ }
+
+ function setRadioStatus(text, type = "neutral") {
+ const element = document.getElementById("radioApplyStatus");
+ if (!element) return;
+ element.textContent = text;
+ element.dataset.state = type;
+ }
+
+ async function applyRadioSettings(profileId) {
+ try {
+ const settings = collectRadioSettings(profileId);
+ validateRadioSettings(profileId, settings);
+ saveRadioSettings(profileId, settings);
+ previewRadioCommand(profileId);
+ const serialState = window.OpenMCCSerial?.getState?.();
+ if (!serialState?.connected) {
+ setRadioStatus("Сохранено на этом компьютере. Подключите ESP32-шлюз, чтобы передать настройки в устройство.", "saved");
+ window.OpenMCCLogger?.write?.("RF-настройки сохранены локально", "success", "RADIO", { profileId, settings });
+ return;
+ }
+ const parameters = buildRadioParameters(profileId, settings);
+ await window.OpenMCCUI.transmitCommand("RADIO", parameters);
+ setRadioStatus("Конфигурация отправлена в ESP32. Фактическое применение требует прошивки радиошлюза v0.3+.", "sent");
+ } catch (error) {
+ setRadioStatus(`Ошибка: ${error.message}`, "error");
+ window.OpenMCCLogger?.write?.(`RF-конфигурация не применена: ${error.message}`, "error", "RADIO");
+ }
+ }
+
+ async function requestRadioSettings() {
+ const serialState = window.OpenMCCSerial?.getState?.();
+ if (!serialState?.connected) {
+ setRadioStatus("Сначала подключите ESP32-шлюз через основной порт.", "error");
+ return;
+ }
+ try {
+ await window.OpenMCCUI.transmitCommand("RADIO_STATUS");
+ setRadioStatus("Запрос RADIO_STATUS отправлен. Ответ появится после реализации прошивки ESP32-шлюза.", "sent");
+ } catch (error) {
+ setRadioStatus(`Запрос не отправлен: ${error.message}`, "error");
+ }
+ }
+
+ function bindRadioControlEvents(profileId) {
+ document.querySelectorAll("#radioAdvancedControls input, #radioAdvancedControls select").forEach((control) => {
+ control.addEventListener("input", () => previewRadioCommand(profileId));
+ control.addEventListener("change", () => previewRadioCommand(profileId));
+ });
+ document.getElementById("radioApplyButton")?.addEventListener("click", () => applyRadioSettings(profileId));
+ document.getElementById("radioReadButton")?.addEventListener("click", requestRadioSettings);
+ previewRadioCommand(profileId);
}
function initializeRadioPlanner() {
const select = document.getElementById("radioModuleType");
if (!select) return;
-
const storedValue = localStorage.getItem(RADIO_KEY);
- if (storedValue && radioProfiles[storedValue]) {
- select.value = storedValue;
- }
-
+ if (storedValue && radioProfiles[storedValue]) select.value = storedValue;
updateRadioProfile(select.value);
-
select.addEventListener("change", () => {
localStorage.setItem(RADIO_KEY, select.value);
updateRadioProfile(select.value);
});
}
+ function initializeCommandPresets() {
+ const panel = document.getElementById("commandPanel");
+ const lastCommand = document.querySelector("#commandPanel .lastCommand");
+ if (!panel || document.getElementById("commandPresetSelect")) return;
+ const group = document.createElement("div");
+ group.className = "commandGroup commandPresetGroup";
+ group.innerHTML = `
+ Готовые команды
+
+
+ ${commandPresets.map((preset, index) => `${preset.label} `).join("")}
+
+ Отправить выбранную
+
+
`;
+ panel.insertBefore(group, lastCommand || null);
+ const select = document.getElementById("commandPresetSelect");
+ const sendButton = document.getElementById("commandPresetSend");
+ const note = document.getElementById("commandPresetNote");
+
+ const updateNote = () => {
+ const preset = commandPresets[Number(select?.value) || 0];
+ if (note) note.textContent = preset.note;
+ };
+ const updateAvailability = () => {
+ const connected = Boolean(window.OpenMCCSerial?.getState?.()?.connected);
+ if (sendButton) sendButton.disabled = !connected;
+ };
+
+ select?.addEventListener("change", updateNote);
+ sendButton?.addEventListener("click", async () => {
+ const preset = commandPresets[Number(select?.value) || 0];
+ try {
+ await window.OpenMCCUI.transmitCommand(preset.command, preset.params);
+ } catch {
+ // transmitCommand already writes the error to the event log.
+ }
+ });
+ window.addEventListener("openmcc:serial-connected", updateAvailability);
+ window.addEventListener("openmcc:serial-disconnected", updateAvailability);
+ updateNote();
+ updateAvailability();
+ }
+
function showFirstRunToast() {
if (localStorage.getItem(FIRST_RUN_KEY) === "1") return;
-
const toast = document.createElement("div");
toast.className = "openmccToast";
- toast.innerHTML = `
- OpenMCC v${HELP_VERSION}
- Интерфейс обновлён. Наведите курсор на элементы для подсказок или нажмите F1, чтобы открыть краткую инструкцию.
- Понятно
- `;
-
+ toast.innerHTML = `OpenMCC v${HELP_VERSION} Добавлены схемы подключения и RF-настройки CC1101/E32, готовые команды и обновлённая 3D-модель CubeSat.Понятно `;
toast.querySelector("button")?.addEventListener("click", () => {
localStorage.setItem(FIRST_RUN_KEY, "1");
toast.remove();
});
-
document.body.appendChild(toast);
}
function initialize() {
+ loadEnhancedStyles();
+ updateBuildBadge();
+ initializeRadioPlanner();
+ initializeCommandPresets();
addTooltips();
initializeTooltipSystem();
initializeNavigation();
initializeHelpDialog();
- initializeRadioPlanner();
showFirstRunToast();
-
- window.dispatchEvent(new CustomEvent("openmcc:help-ready", {
- detail: { version: HELP_VERSION },
- }));
+ window.dispatchEvent(new CustomEvent("openmcc:help-ready", { detail: { version: HELP_VERSION } }));
}
- if (document.readyState === "loading") {
- document.addEventListener("DOMContentLoaded", initialize, { once: true });
- } else {
- initialize();
- }
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", initialize, { once: true });
+ else initialize();
})();
diff --git a/js/satellite.js b/js/satellite.js
index f493441..69aacf4 100644
--- a/js/satellite.js
+++ b/js/satellite.js
@@ -1,1463 +1,414 @@
"use strict";
import * as THREE from "three";
+import { OrbitControls } from "three/addons/controls/OrbitControls.js";
-import {
- OrbitControls
-} from "three/addons/controls/OrbitControls.js";
-
-
-/* ============================================================
- OpenMCC
- Open Mission Control Center
-
- Module: satellite.js
- Version: 0.1.0
-
- Назначение:
- - отображение программной модели CubeSat;
- - управление камерой;
- - отображение ориентации;
- - обработка ROLL, PITCH и YAW;
- - автоматическое демонстрационное вращение.
- ============================================================ */
-
-
-const SATELLITE_CONFIG = Object.freeze({
-
- version:
- "0.1.0",
-
- background:
- 0x07101e,
-
- cameraDistance:
- 7.2,
-
- autoRotationSpeed:
- 0.25,
-
- orientationSmoothing:
- 0.10
-
-});
-
-
-const state = {
-
- initialized:
- false,
-
- autoRotation:
- true,
-
- telemetryReceived:
- false,
-
- scene:
- null,
-
- camera:
- null,
-
- renderer:
- null,
-
- controls:
- null,
-
- satelliteRoot:
- null,
-
- clock:
- null,
-
- animationFrame:
- null,
-
- currentOrientation:
- {
- roll: 0,
- pitch: 0,
- yaw: 0
- },
-
- targetOrientation:
- {
- roll: 0,
- pitch: 0,
- yaw: 0
- }
-
-};
-
-
-const elements = {
-
- viewport:
- null,
-
- canvas:
- null,
-
- status:
- null,
-
- resetViewButton:
- null,
-
- autoRotationButton:
- null,
-
- roll:
- null,
-
- pitch:
- null,
-
- yaw:
- null
-
-};
-
-
-function writeLog(
- message,
- type = "info",
- metadata = null
-) {
-
- if (
- window.OpenMCCLogger &&
- typeof window.OpenMCCLogger.write === "function"
- ) {
-
- window.OpenMCCLogger.write(
- message,
- type,
- "SATELLITE",
- metadata
- );
-
- return;
-
- }
-
- console.log(
- `[OpenMCC Satellite] ${message}`
- );
-
-}
-
-
-function cacheElements() {
-
- elements.viewport =
- document.getElementById(
- "satelliteViewport"
- );
-
- elements.canvas =
- document.getElementById(
- "satelliteCanvas"
- );
-
- elements.status =
- document.getElementById(
- "satelliteStatus"
- );
-
- elements.resetViewButton =
- document.getElementById(
- "resetSatelliteView"
- );
-
- elements.autoRotationButton =
- document.getElementById(
- "toggleSatelliteRotation"
- );
-
- elements.roll =
- document.getElementById(
- "orientationRoll"
- );
-
- elements.pitch =
- document.getElementById(
- "orientationPitch"
- );
-
- elements.yaw =
- document.getElementById(
- "orientationYaw"
- );
-
-}
-
-
-function setStatus(
- text,
- type = "ready"
-) {
-
- if (!elements.status) {
-
- return;
-
- }
-
- elements.status.textContent =
- text;
-
- elements.status.classList.remove(
- "ready",
- "error"
- );
-
- elements.status.classList.add(
- type
- );
-
-}
-
-
-function createScene() {
-
- state.scene =
- new THREE.Scene();
-
- state.scene.background =
- null;
-
- state.camera =
- new THREE.PerspectiveCamera(
- 38,
- 1,
- 0.1,
- 100
- );
-
- state.camera.position.set(
- 5.4,
- 3.8,
- SATELLITE_CONFIG.cameraDistance
- );
-
-
- state.renderer =
- new THREE.WebGLRenderer({
-
- canvas:
- elements.canvas,
-
- antialias:
- true,
-
- alpha:
- true,
-
- powerPreference:
- "high-performance"
-
- });
-
- state.renderer.setPixelRatio(
- Math.min(
- window.devicePixelRatio,
- 2
- )
- );
-
- state.renderer.outputColorSpace =
- THREE.SRGBColorSpace;
-
- state.renderer.shadowMap.enabled =
- true;
-
- state.renderer.shadowMap.type =
- THREE.PCFSoftShadowMap;
-
-
- state.controls =
- new OrbitControls(
- state.camera,
- elements.canvas
- );
-
- state.controls.enableDamping =
- true;
-
- state.controls.dampingFactor =
- 0.075;
-
- state.controls.enablePan =
- false;
-
- state.controls.minDistance =
- 4.2;
-
- state.controls.maxDistance =
- 13;
-
- state.controls.target.set(
- 0,
- 0,
- 0
- );
-
-
- state.clock =
- new THREE.Clock();
-
-}
-
-
-function createLights() {
-
- const ambientLight =
- new THREE.HemisphereLight(
- 0x94cfff,
- 0x08101d,
- 1.35
- );
-
- state.scene.add(
- ambientLight
- );
-
-
- const mainLight =
- new THREE.DirectionalLight(
- 0xffffff,
- 3.2
- );
-
- mainLight.position.set(
- 5,
- 7,
- 8
- );
-
- mainLight.castShadow =
- true;
-
- state.scene.add(
- mainLight
- );
-
-
- const cyanLight =
- new THREE.PointLight(
- 0x00d9ff,
- 8,
- 14
- );
-
- cyanLight.position.set(
- -4,
- 1,
- 3
- );
-
- state.scene.add(
- cyanLight
- );
-
-
- const rimLight =
- new THREE.DirectionalLight(
- 0x315dff,
- 1.7
- );
-
- rimLight.position.set(
- -5,
- 2,
- -7
- );
-
- state.scene.add(
- rimLight
- );
-
-}
-
-
-function createMaterial(
- color,
- metalness = 0.5,
- roughness = 0.35
-) {
-
- return new THREE.MeshStandardMaterial({
-
- color,
-
- metalness,
-
- roughness
-
- });
-
-}
-
-
-function createCubeSatBody() {
-
- const root =
- new THREE.Group();
-
- root.name =
- "CubeSatRoot";
-
-
- const frameMaterial =
- createMaterial(
- 0xaeb9c8,
- 0.82,
- 0.24
- );
-
-
- const bodyMaterial =
- createMaterial(
- 0x202c3c,
- 0.55,
- 0.34
- );
-
-
- const darkMaterial =
- createMaterial(
- 0x09111d,
- 0.68,
- 0.28
- );
-
-
- const goldMaterial =
- createMaterial(
- 0xd4a52f,
- 0.72,
- 0.28
- );
-
-
- const solarCellMaterial =
- new THREE.MeshStandardMaterial({
-
- color:
- 0x123f79,
-
- emissive:
- 0x041833,
-
- emissiveIntensity:
- 0.75,
-
- metalness:
- 0.45,
-
- roughness:
- 0.28
-
- });
-
-
- const body =
- new THREE.Mesh(
-
- new THREE.BoxGeometry(
- 1.75,
- 2.75,
- 1.75
- ),
-
- bodyMaterial
-
- );
-
- body.castShadow =
- true;
-
- body.receiveShadow =
- true;
-
- root.add(body);
-
-
- const railGeometry =
- new THREE.BoxGeometry(
- 0.13,
- 3.15,
- 0.13
- );
-
- const railCoordinates = [
-
- [-0.94, -0.94],
- [-0.94, 0.94],
- [0.94, -0.94],
- [0.94, 0.94]
-
- ];
-
- railCoordinates.forEach(
- ([x, z]) => {
-
- const rail =
- new THREE.Mesh(
- railGeometry,
- frameMaterial
- );
-
- rail.position.set(
- x,
- 0,
- z
- );
-
- rail.castShadow =
- true;
-
- root.add(rail);
-
- }
- );
-
-
- const endPlateGeometry =
- new THREE.BoxGeometry(
- 1.98,
- 0.12,
- 1.98
- );
-
- [-1.48, 1.48].forEach(y => {
-
- const plate =
- new THREE.Mesh(
- endPlateGeometry,
- frameMaterial
- );
-
- plate.position.y =
- y;
-
- plate.castShadow =
- true;
-
- root.add(plate);
-
- });
-
-
- const frontPanel =
- new THREE.Mesh(
-
- new THREE.BoxGeometry(
- 1.48,
- 2.35,
- 0.055
- ),
-
- darkMaterial
-
- );
-
- frontPanel.position.z =
- 0.895;
-
- root.add(frontPanel);
-
-
- const sensor =
- new THREE.Mesh(
-
- new THREE.CylinderGeometry(
- 0.22,
- 0.22,
- 0.12,
- 32
- ),
-
- darkMaterial
-
- );
-
- sensor.rotation.x =
- Math.PI / 2;
-
- sensor.position.set(
- 0,
- 0.55,
- 0.97
- );
-
- root.add(sensor);
-
-
- const sensorGlass =
- new THREE.Mesh(
-
- new THREE.CylinderGeometry(
- 0.14,
- 0.14,
- 0.13,
- 32
- ),
-
- new THREE.MeshStandardMaterial({
-
- color:
- 0x38b9ff,
-
- emissive:
- 0x0076a8,
-
- emissiveIntensity:
- 1.1,
-
- metalness:
- 0.15,
-
- roughness:
- 0.16
-
- })
-
- );
-
- sensorGlass.rotation.x =
- Math.PI / 2;
-
- sensorGlass.position.set(
- 0,
- 0.55,
- 1.035
- );
-
- root.add(sensorGlass);
-
-
- for (let row = 0; row < 5; row++) {
-
- for (let column = 0; column < 3; column++) {
-
- const cell =
- new THREE.Mesh(
-
- new THREE.BoxGeometry(
- 0.39,
- 0.31,
- 0.035
- ),
-
- solarCellMaterial
-
- );
-
- cell.position.set(
- -0.46 + column * 0.46,
- -0.82 + row * 0.40,
- 0.94
- );
-
- root.add(cell);
-
- }
-
- }
-
-
- const antennaBase =
- new THREE.Mesh(
-
- new THREE.CylinderGeometry(
- 0.18,
- 0.24,
- 0.16,
- 24
- ),
-
- goldMaterial
-
- );
-
- antennaBase.position.y =
- 1.62;
-
- root.add(antennaBase);
-
-
- const antenna =
- new THREE.Mesh(
-
- new THREE.CylinderGeometry(
- 0.018,
- 0.018,
- 1.65,
- 12
- ),
-
- goldMaterial
-
- );
-
- antenna.position.set(
- 0.2,
- 2.42,
- 0
- );
-
- antenna.rotation.z =
- -0.10;
-
- root.add(antenna);
-
-
- const sidePanels =
- createSolarPanels(
- solarCellMaterial,
- frameMaterial
- );
-
- root.add(
- sidePanels
- );
-
-
- const axisHelper =
- new THREE.AxesHelper(
- 2.7
- );
-
- axisHelper.material.transparent =
- true;
-
- axisHelper.material.opacity =
- 0.8;
-
- root.add(
- axisHelper
- );
-
-
- root.rotation.set(
- 0.25,
- -0.45,
- 0.08
- );
-
- return root;
-
-}
-
-
-function createSolarPanels(
- solarMaterial,
- frameMaterial
-) {
-
- const panelGroup =
- new THREE.Group();
-
-
- const panelGeometry =
- new THREE.BoxGeometry(
- 2.15,
- 1.32,
- 0.075
- );
-
-
- [-1, 1].forEach(side => {
-
- const hinge =
- new THREE.Group();
-
- hinge.position.x =
- side * 1.04;
-
-
- const support =
- new THREE.Mesh(
-
- new THREE.BoxGeometry(
- 0.35,
- 0.16,
- 0.14
- ),
-
- frameMaterial
-
- );
-
- support.position.x =
- side * 0.16;
-
- hinge.add(support);
-
-
- const panel =
- new THREE.Mesh(
- panelGeometry,
- solarMaterial
- );
-
- panel.position.x =
- side * 1.24;
-
- panel.castShadow =
- true;
-
- hinge.add(panel);
-
-
- for (let row = 0; row < 3; row++) {
-
- for (let column = 0; column < 5; column++) {
-
- const separator =
- new THREE.Mesh(
-
- new THREE.BoxGeometry(
- 0.35,
- 0.32,
- 0.015
- ),
-
- new THREE.MeshStandardMaterial({
-
- color:
- 0x1b65a6,
-
- emissive:
- 0x061a36,
-
- emissiveIntensity:
- 0.45,
-
- metalness:
- 0.35,
-
- roughness:
- 0.30
+/* ============================================================
+ OpenMCC — CubeSat 3D view
+ Version 0.3.0
+ Generic educational 2U CubeSat with fixed body solar panels
+ and a deployed tape-measure dipole antenna.
+ ============================================================ */
- })
+const SATELLITE_CONFIG = Object.freeze({
+ version: "0.3.0",
+ cameraDistance: 7.8,
+ autoRotationSpeed: 0.22,
+ orientationSmoothing: 0.10,
+});
- );
+const state = {
+ initialized: false,
+ autoRotation: true,
+ telemetryReceived: false,
+ scene: null,
+ camera: null,
+ renderer: null,
+ controls: null,
+ satelliteRoot: null,
+ clock: null,
+ animationFrame: null,
+ currentOrientation: { roll: 0, pitch: 0, yaw: 0 },
+ targetOrientation: { roll: 0, pitch: 0, yaw: 0 },
+};
- separator.position.set(
- side * (
- 0.48 +
- column * 0.40
- ),
- -0.43 +
- row * 0.43,
- 0.047
- );
+const elements = {};
- hinge.add(separator);
+function writeLog(message, type = "info", metadata = null) {
+ if (window.OpenMCCLogger?.write) {
+ window.OpenMCCLogger.write(message, type, "SATELLITE", metadata);
+ return;
+ }
+ console.log(`[OpenMCC Satellite] ${message}`);
+}
- }
+function cacheElements() {
+ elements.viewport = document.getElementById("satelliteViewport");
+ elements.canvas = document.getElementById("satelliteCanvas");
+ elements.status = document.getElementById("satelliteStatus");
+ elements.resetViewButton = document.getElementById("resetSatelliteView");
+ elements.autoRotationButton = document.getElementById("toggleSatelliteRotation");
+ elements.roll = document.getElementById("orientationRoll");
+ elements.pitch = document.getElementById("orientationPitch");
+ elements.yaw = document.getElementById("orientationYaw");
+}
- }
+function setStatus(text, type = "ready") {
+ if (!elements.status) return;
+ elements.status.textContent = text;
+ elements.status.classList.remove("ready", "error");
+ elements.status.classList.add(type);
+}
+function standardMaterial(color, metalness = 0.5, roughness = 0.35) {
+ return new THREE.MeshStandardMaterial({ color, metalness, roughness });
+}
- panelGroup.add(
- hinge
- );
+function box(size, material, position = [0, 0, 0]) {
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(...size), material);
+ mesh.position.set(...position);
+ mesh.castShadow = true;
+ mesh.receiveShadow = true;
+ return mesh;
+}
+function createScene() {
+ state.scene = new THREE.Scene();
+ state.camera = new THREE.PerspectiveCamera(37, 1, 0.1, 100);
+ state.camera.position.set(5.5, 3.9, SATELLITE_CONFIG.cameraDistance);
+
+ state.renderer = new THREE.WebGLRenderer({
+ canvas: elements.canvas,
+ antialias: true,
+ alpha: true,
+ powerPreference: "high-performance",
});
-
- return panelGroup;
-
+ state.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ state.renderer.outputColorSpace = THREE.SRGBColorSpace;
+ state.renderer.shadowMap.enabled = true;
+ state.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+
+ state.controls = new OrbitControls(state.camera, elements.canvas);
+ state.controls.enableDamping = true;
+ state.controls.dampingFactor = 0.075;
+ state.controls.enablePan = false;
+ state.controls.minDistance = 4.8;
+ state.controls.maxDistance = 15;
+ state.controls.target.set(0, 0, 0);
+ state.clock = new THREE.Clock();
}
+function createLights() {
+ state.scene.add(new THREE.HemisphereLight(0xa9d8ff, 0x07101b, 1.45));
-function createEnvironment() {
-
- const grid =
- new THREE.GridHelper(
- 18,
- 36,
- 0x174567,
- 0x10243a
- );
-
- grid.position.y =
- -2.25;
-
- grid.material.transparent =
- true;
-
- grid.material.opacity =
- 0.28;
-
- state.scene.add(grid);
-
-
- const starsGeometry =
- new THREE.BufferGeometry();
-
- const starCount =
- 280;
+ const main = new THREE.DirectionalLight(0xffffff, 3.5);
+ main.position.set(5, 7, 8);
+ main.castShadow = true;
+ state.scene.add(main);
- const positions =
- new Float32Array(
- starCount * 3
- );
+ const fill = new THREE.PointLight(0x00d9ff, 6, 15);
+ fill.position.set(-4, 1, 4);
+ state.scene.add(fill);
- for (
- let index = 0;
- index < positions.length;
- index += 3
- ) {
+ const rim = new THREE.DirectionalLight(0x315dff, 1.8);
+ rim.position.set(-5, 3, -7);
+ state.scene.add(rim);
+}
- positions[index] =
- (Math.random() - 0.5) * 18;
+function addSolarCellsToZFace(root, z, cellMaterial, frameMaterial) {
+ const panel = box([1.52, 2.62, 0.055], frameMaterial, [0, 0, z]);
+ root.add(panel);
- positions[index + 1] =
- (Math.random() - 0.5) * 12;
+ for (let row = 0; row < 6; row++) {
+ for (let column = 0; column < 3; column++) {
+ const cell = box(
+ [0.42, 0.34, 0.035],
+ cellMaterial,
+ [-0.48 + column * 0.48, -1.02 + row * 0.41, z + Math.sign(z) * 0.047]
+ );
+ root.add(cell);
+ }
+ }
+}
- positions[index + 2] =
- -3 - Math.random() * 10;
+function addSolarCellsToXFace(root, x, cellMaterial, frameMaterial) {
+ const panel = box([0.055, 2.62, 1.52], frameMaterial, [x, 0, 0]);
+ root.add(panel);
+ for (let row = 0; row < 6; row++) {
+ for (let column = 0; column < 3; column++) {
+ const cell = box(
+ [0.035, 0.34, 0.42],
+ cellMaterial,
+ [x + Math.sign(x) * 0.047, -1.02 + row * 0.41, -0.48 + column * 0.48]
+ );
+ root.add(cell);
+ }
}
+}
- starsGeometry.setAttribute(
- "position",
- new THREE.BufferAttribute(
- positions,
- 3
- )
- );
-
+function addCornerFasteners(root, y, frameMaterial) {
+ const geometry = new THREE.CylinderGeometry(0.045, 0.045, 0.025, 16);
+ [[-0.72,-0.72],[-0.72,0.72],[0.72,-0.72],[0.72,0.72]].forEach(([x,z]) => {
+ const screw = new THREE.Mesh(geometry, frameMaterial);
+ screw.position.set(x, y, z);
+ root.add(screw);
+ });
+}
- const stars =
- new THREE.Points(
+function createTapeDipole(root, springMaterial, darkMaterial, frameMaterial) {
+ const topY = 1.69;
+
+ const deployer = box([0.72, 0.22, 0.54], darkMaterial, [0, topY, 0.12]);
+ root.add(deployer);
+
+ const cover = box([0.62, 0.035, 0.44], frameMaterial, [0, topY + 0.13, 0.12]);
+ root.add(cover);
+
+ const slotMaterial = standardMaterial(0x02050a, 0.15, 0.65);
+ root.add(box([0.055, 0.09, 0.18], slotMaterial, [0.37, topY, 0.12]));
+ root.add(box([0.055, 0.09, 0.18], slotMaterial, [-0.37, topY, 0.12]));
+
+ const armLength = 1.72;
+ const armThickness = 0.025;
+ const armWidth = 0.105;
+ const right = box([armLength, armThickness, armWidth], springMaterial, [0.38 + armLength / 2, topY + 0.015, 0.12]);
+ const left = box([armLength, armThickness, armWidth], springMaterial, [-0.38 - armLength / 2, topY + 0.015, 0.12]);
+ right.rotation.z = 0.015;
+ left.rotation.z = -0.015;
+ root.add(right, left);
+
+ const feedMaterial = standardMaterial(0xc99c36, 0.78, 0.25);
+ const feedGeometry = new THREE.CylinderGeometry(0.055, 0.055, 0.08, 20);
+ const feedRight = new THREE.Mesh(feedGeometry, feedMaterial);
+ const feedLeft = new THREE.Mesh(feedGeometry, feedMaterial);
+ feedRight.position.set(0.40, topY + 0.02, 0.12);
+ feedLeft.position.set(-0.40, topY + 0.02, 0.12);
+ feedRight.rotation.z = Math.PI / 2;
+ feedLeft.rotation.z = Math.PI / 2;
+ root.add(feedRight, feedLeft);
+}
- starsGeometry,
+function createCubeSatBody() {
+ const root = new THREE.Group();
+ root.name = "CubeSat2UTapeDipole";
+
+ const frameMaterial = standardMaterial(0xb8c2cd, 0.84, 0.22);
+ const bodyMaterial = standardMaterial(0x151e2a, 0.58, 0.36);
+ const panelFrameMaterial = standardMaterial(0x0a111b, 0.55, 0.34);
+ const darkMaterial = standardMaterial(0x070c13, 0.68, 0.30);
+ const springMaterial = standardMaterial(0xb7ad83, 0.72, 0.25);
+ const solarCellMaterial = new THREE.MeshStandardMaterial({
+ color: 0x0c3b73,
+ emissive: 0x03162c,
+ emissiveIntensity: 0.75,
+ metalness: 0.42,
+ roughness: 0.26,
+ });
- new THREE.PointsMaterial({
+ const body = box([1.78, 3.0, 1.78], bodyMaterial);
+ root.add(body);
- color:
- 0x7ecfff,
+ const railGeometry = new THREE.BoxGeometry(0.12, 3.36, 0.12);
+ [[-0.94,-0.94],[-0.94,0.94],[0.94,-0.94],[0.94,0.94]].forEach(([x,z]) => {
+ const rail = new THREE.Mesh(railGeometry, frameMaterial);
+ rail.position.set(x, 0, z);
+ rail.castShadow = true;
+ root.add(rail);
+ });
- size:
- 0.025,
+ root.add(box([2.00, 0.11, 2.00], frameMaterial, [0, -1.56, 0]));
+ root.add(box([2.00, 0.11, 2.00], frameMaterial, [0, 1.56, 0]));
+ addCornerFasteners(root, -1.625, frameMaterial);
+ addCornerFasteners(root, 1.625, frameMaterial);
- transparent:
- true,
+ addSolarCellsToZFace(root, 0.905, solarCellMaterial, panelFrameMaterial);
+ addSolarCellsToZFace(root, -0.905, solarCellMaterial, panelFrameMaterial);
+ addSolarCellsToXFace(root, 0.905, solarCellMaterial, panelFrameMaterial);
+ addSolarCellsToXFace(root, -0.905, solarCellMaterial, panelFrameMaterial);
- opacity:
- 0.55
+ const cameraBody = new THREE.Mesh(new THREE.CylinderGeometry(0.19, 0.23, 0.13, 28), darkMaterial);
+ cameraBody.rotation.x = Math.PI / 2;
+ cameraBody.position.set(0, 0.46, 0.99);
+ root.add(cameraBody);
- })
+ const glass = new THREE.Mesh(
+ new THREE.CylinderGeometry(0.12, 0.12, 0.14, 28),
+ new THREE.MeshStandardMaterial({ color: 0x3fbfff, emissive: 0x006e9f, emissiveIntensity: 0.9, metalness: 0.12, roughness: 0.12 })
+ );
+ glass.rotation.x = Math.PI / 2;
+ glass.position.set(0, 0.46, 1.055);
+ root.add(glass);
- );
+ createTapeDipole(root, springMaterial, darkMaterial, frameMaterial);
- state.scene.add(stars);
+ const axisHelper = new THREE.AxesHelper(2.45);
+ axisHelper.material.transparent = true;
+ axisHelper.material.opacity = 0.62;
+ root.add(axisHelper);
+ root.rotation.set(0.22, -0.45, 0.07);
+ return root;
}
+function createEnvironment() {
+ const grid = new THREE.GridHelper(18, 36, 0x174567, 0x10243a);
+ grid.position.y = -2.45;
+ grid.material.transparent = true;
+ grid.material.opacity = 0.22;
+ state.scene.add(grid);
-function resizeRenderer() {
-
- if (
- !elements.viewport ||
- !state.renderer ||
- !state.camera
- ) {
-
- return;
-
- }
-
- const width =
- elements.viewport.clientWidth;
-
- const height =
- elements.viewport.clientHeight;
-
- if (
- width <= 0 ||
- height <= 0
- ) {
-
- return;
-
+ const starsGeometry = new THREE.BufferGeometry();
+ const starCount = 320;
+ const positions = new Float32Array(starCount * 3);
+ for (let i = 0; i < positions.length; i += 3) {
+ positions[i] = (Math.random() - 0.5) * 20;
+ positions[i + 1] = (Math.random() - 0.5) * 14;
+ positions[i + 2] = -3 - Math.random() * 12;
}
+ starsGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
+ state.scene.add(new THREE.Points(starsGeometry, new THREE.PointsMaterial({ color: 0x7ecfff, size: 0.025, transparent: true, opacity: 0.52 })));
+}
- state.renderer.setSize(
- width,
- height,
- false
- );
-
- state.camera.aspect =
- width / height;
-
+function resizeRenderer() {
+ if (!elements.viewport || !state.renderer || !state.camera) return;
+ const width = elements.viewport.clientWidth;
+ const height = elements.viewport.clientHeight;
+ if (width <= 0 || height <= 0) return;
+ state.renderer.setSize(width, height, false);
+ state.camera.aspect = width / height;
state.camera.updateProjectionMatrix();
-
}
-
function degreesToRadians(value) {
-
- return THREE.MathUtils.degToRad(
- Number(value) || 0
- );
-
+ return THREE.MathUtils.degToRad(Number(value) || 0);
}
-
function updateOrientationDisplay() {
-
- if (elements.roll) {
-
- elements.roll.textContent =
- `${state.targetOrientation.roll.toFixed(1)}°`;
-
- }
-
- if (elements.pitch) {
-
- elements.pitch.textContent =
- `${state.targetOrientation.pitch.toFixed(1)}°`;
-
- }
-
- if (elements.yaw) {
-
- elements.yaw.textContent =
- `${state.targetOrientation.yaw.toFixed(1)}°`;
-
- }
-
+ if (elements.roll) elements.roll.textContent = `${state.targetOrientation.roll.toFixed(1)}°`;
+ if (elements.pitch) elements.pitch.textContent = `${state.targetOrientation.pitch.toFixed(1)}°`;
+ if (elements.yaw) elements.yaw.textContent = `${state.targetOrientation.yaw.toFixed(1)}°`;
}
-
function processTelemetry(telemetry) {
-
- if (
- !telemetry ||
- typeof telemetry !== "object"
- ) {
-
- return;
-
- }
-
- let orientationUpdated =
- false;
-
- if (
- Number.isFinite(
- Number(telemetry.ROLL)
- )
- ) {
-
- state.targetOrientation.roll =
- Number(telemetry.ROLL);
-
- orientationUpdated =
- true;
-
- }
-
- if (
- Number.isFinite(
- Number(telemetry.PITCH)
- )
- ) {
-
- state.targetOrientation.pitch =
- Number(telemetry.PITCH);
-
- orientationUpdated =
- true;
-
- }
-
- if (
- Number.isFinite(
- Number(telemetry.YAW)
- )
- ) {
-
- state.targetOrientation.yaw =
- Number(telemetry.YAW);
-
- orientationUpdated =
- true;
-
+ if (!telemetry || typeof telemetry !== "object") return;
+ let updated = false;
+ for (const [key, property] of [["ROLL","roll"],["PITCH","pitch"],["YAW","yaw"]]) {
+ if (Number.isFinite(Number(telemetry[key]))) {
+ state.targetOrientation[property] = Number(telemetry[key]);
+ updated = true;
+ }
}
-
- if (orientationUpdated) {
-
- state.telemetryReceived =
- true;
-
- state.autoRotation =
- false;
-
+ if (updated) {
+ state.telemetryReceived = true;
+ state.autoRotation = false;
updateAutoRotationButton();
-
updateOrientationDisplay();
-
}
-
}
-
-function interpolateAngle(
- current,
- target,
- coefficient
-) {
-
- let difference =
- target - current;
-
- while (difference > 180) {
-
- difference -= 360;
-
- }
-
- while (difference < -180) {
-
- difference += 360;
-
- }
-
- return current +
- difference * coefficient;
-
+function interpolateAngle(current, target, coefficient) {
+ let difference = target - current;
+ while (difference > 180) difference -= 360;
+ while (difference < -180) difference += 360;
+ return current + difference * coefficient;
}
-
-function updateSatelliteRotation(
- deltaTime
-) {
-
- if (!state.satelliteRoot) {
-
+function updateSatelliteRotation(deltaTime) {
+ if (!state.satelliteRoot) return;
+ if (state.autoRotation && !state.telemetryReceived) {
+ state.satelliteRoot.rotation.y += deltaTime * SATELLITE_CONFIG.autoRotationSpeed;
return;
-
- }
-
- if (
- state.autoRotation &&
- !state.telemetryReceived
- ) {
-
- state.satelliteRoot.rotation.y +=
- deltaTime *
- SATELLITE_CONFIG.autoRotationSpeed;
-
- return;
-
}
- state.currentOrientation.roll =
- interpolateAngle(
- state.currentOrientation.roll,
- state.targetOrientation.roll,
- SATELLITE_CONFIG.orientationSmoothing
- );
-
- state.currentOrientation.pitch =
- interpolateAngle(
- state.currentOrientation.pitch,
- state.targetOrientation.pitch,
- SATELLITE_CONFIG.orientationSmoothing
- );
-
- state.currentOrientation.yaw =
- interpolateAngle(
- state.currentOrientation.yaw,
- state.targetOrientation.yaw,
- SATELLITE_CONFIG.orientationSmoothing
- );
+ state.currentOrientation.roll = interpolateAngle(state.currentOrientation.roll, state.targetOrientation.roll, SATELLITE_CONFIG.orientationSmoothing);
+ state.currentOrientation.pitch = interpolateAngle(state.currentOrientation.pitch, state.targetOrientation.pitch, SATELLITE_CONFIG.orientationSmoothing);
+ state.currentOrientation.yaw = interpolateAngle(state.currentOrientation.yaw, state.targetOrientation.yaw, SATELLITE_CONFIG.orientationSmoothing);
state.satelliteRoot.rotation.set(
-
- degreesToRadians(
- state.currentOrientation.pitch
- ),
-
- degreesToRadians(
- state.currentOrientation.yaw
- ),
-
- degreesToRadians(
- state.currentOrientation.roll
- ),
-
+ degreesToRadians(state.currentOrientation.pitch),
+ degreesToRadians(state.currentOrientation.yaw),
+ degreesToRadians(state.currentOrientation.roll),
"YXZ"
-
);
-
}
-
function animate() {
-
- state.animationFrame =
- requestAnimationFrame(
- animate
- );
-
- const deltaTime =
- Math.min(
- state.clock.getDelta(),
- 0.05
- );
-
- updateSatelliteRotation(
- deltaTime
- );
-
+ state.animationFrame = requestAnimationFrame(animate);
+ const deltaTime = Math.min(state.clock.getDelta(), 0.05);
+ updateSatelliteRotation(deltaTime);
state.controls.update();
-
- state.renderer.render(
- state.scene,
- state.camera
- );
-
+ state.renderer.render(state.scene, state.camera);
}
-
function resetCameraView() {
-
- state.camera.position.set(
- 5.4,
- 3.8,
- SATELLITE_CONFIG.cameraDistance
- );
-
- state.controls.target.set(
- 0,
- 0,
- 0
- );
-
+ state.camera.position.set(5.5, 3.9, SATELLITE_CONFIG.cameraDistance);
+ state.controls.target.set(0, 0, 0);
state.controls.update();
-
- writeLog(
- "Положение 3D-камеры восстановлено",
- "info"
- );
-
+ writeLog("Положение 3D-камеры восстановлено", "info");
}
-
function updateAutoRotationButton() {
-
- if (!elements.autoRotationButton) {
-
- return;
-
- }
-
- elements.autoRotationButton.textContent =
- state.autoRotation
- ? "Остановить вращение"
- : "Автовращение";
-
+ if (!elements.autoRotationButton) return;
+ elements.autoRotationButton.textContent = state.autoRotation ? "Остановить вращение" : "Автовращение";
}
-
function toggleAutoRotation() {
-
- state.autoRotation =
- !state.autoRotation;
-
- if (state.autoRotation) {
-
- state.telemetryReceived =
- false;
-
- }
-
+ state.autoRotation = !state.autoRotation;
+ if (state.autoRotation) state.telemetryReceived = false;
updateAutoRotationButton();
-
- writeLog(
- state.autoRotation
- ? "Автоматическое вращение модели включено"
- : "Автоматическое вращение модели выключено",
- "info"
- );
-
+ writeLog(state.autoRotation ? "Автоматическое вращение модели включено" : "Автоматическое вращение модели выключено", "info");
}
-
function registerEvents() {
-
- window.addEventListener(
- "resize",
- resizeRenderer
- );
-
-
- window.addEventListener(
- "openmcc:telemetry",
- event => {
-
- processTelemetry(
- event.detail
- );
-
- }
- );
-
-
- elements.resetViewButton
- ?.addEventListener(
- "click",
- resetCameraView
- );
-
-
- elements.autoRotationButton
- ?.addEventListener(
- "click",
- toggleAutoRotation
- );
-
-
- const resizeObserver =
- new ResizeObserver(
- resizeRenderer
- );
-
- resizeObserver.observe(
- elements.viewport
- );
-
+ window.addEventListener("resize", resizeRenderer);
+ window.addEventListener("openmcc:telemetry", (event) => processTelemetry(event.detail));
+ elements.resetViewButton?.addEventListener("click", resetCameraView);
+ elements.autoRotationButton?.addEventListener("click", toggleAutoRotation);
+ const resizeObserver = new ResizeObserver(resizeRenderer);
+ resizeObserver.observe(elements.viewport);
}
-
function initialize() {
-
- if (state.initialized) {
-
- return;
-
- }
-
+ if (state.initialized) return;
try {
-
cacheElements();
-
- if (
- !elements.viewport ||
- !elements.canvas
- ) {
-
- throw new Error(
- "Контейнер 3D-модели не найден"
- );
-
- }
-
+ if (!elements.viewport || !elements.canvas) throw new Error("Контейнер 3D-модели не найден");
createScene();
-
createLights();
-
- state.satelliteRoot =
- createCubeSatBody();
-
- state.scene.add(
- state.satelliteRoot
- );
-
+ state.satelliteRoot = createCubeSatBody();
+ state.scene.add(state.satelliteRoot);
createEnvironment();
-
registerEvents();
-
resizeRenderer();
-
resetCameraView();
-
updateAutoRotationButton();
-
updateOrientationDisplay();
-
- state.initialized =
- true;
-
- setStatus(
- "ГОТОВ",
- "ready"
- );
-
- writeLog(
- `Модуль 3D-модели v${SATELLITE_CONFIG.version} загружен`,
- "success"
- );
-
+ state.initialized = true;
+ setStatus("2U · DIPOLE", "ready");
+ writeLog(`3D-модель CubeSat v${SATELLITE_CONFIG.version}: fixed solar panels + tape-measure dipole`, "success");
animate();
-
- }
- catch (error) {
-
- setStatus(
- "ОШИБКА",
- "error"
- );
-
- writeLog(
- `Ошибка инициализации 3D-модели: ${error.message}`,
- "error"
- );
-
+ } catch (error) {
+ setStatus("ОШИБКА", "error");
+ writeLog(`Ошибка инициализации 3D-модели: ${error.message}`, "error");
console.error(error);
-
}
-
}
-
window.OpenMCCSatellite = Object.freeze({
-
processTelemetry,
-
resetCameraView,
-
toggleAutoRotation,
-
getState() {
-
return {
-
- initialized:
- state.initialized,
-
- autoRotation:
- state.autoRotation,
-
- telemetryReceived:
- state.telemetryReceived,
-
- currentOrientation:
- {
- ...state.currentOrientation
- },
-
- targetOrientation:
- {
- ...state.targetOrientation
- }
-
+ initialized: state.initialized,
+ autoRotation: state.autoRotation,
+ telemetryReceived: state.telemetryReceived,
+ currentOrientation: { ...state.currentOrientation },
+ targetOrientation: { ...state.targetOrientation },
+ model: "generic-2u-tape-dipole",
};
-
- }
-
+ },
});
-
-initialize();
\ No newline at end of file
+initialize();
diff --git a/package.json b/package.json
index 1bcff8e..eca3521 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openmcc",
- "version": "0.2.0",
+ "version": "0.3.0",
"private": true,
"description": "Open Mission Control Center desktop application for educational CubeSat ground stations",
"main": "desktop/main.cjs",